diff --git a/src/main/kotlin/com/frankenergie/smartasset/config/ChargingGroupsConfig.kt b/src/main/kotlin/com/frankenergie/smartasset/config/ChargingGroupsConfig.kt new file mode 100644 index 0000000..a25ee54 --- /dev/null +++ b/src/main/kotlin/com/frankenergie/smartasset/config/ChargingGroupsConfig.kt @@ -0,0 +1,25 @@ +package com.frankenergie.smartasset.config + +import com.frankenergie.smartasset.model.ChargingGroup +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime + +@Configuration +class ChargingGroupsConfig { + + @Bean + fun chargingGroups(): List { + val today = LocalDate.now() + return listOf( + ChargingGroup("A", today.atTime(0, 0), today.atTime(8, 30), BigDecimal("5"), BigDecimal("2")), + ChargingGroup("B", today.atTime(0, 0), today.atTime(11, 0), BigDecimal("10"), BigDecimal("3")), + ChargingGroup("C", today.atTime(13, 0), today.atTime(18, 0), BigDecimal("4"), BigDecimal("1")), + ChargingGroup("D", today.atTime(13, 0), today.atTime(21, 0), BigDecimal("20"), BigDecimal("6")), + ChargingGroup("E", today.atTime(17, 30), today.atTime(22, 0), BigDecimal("5"), BigDecimal("2")), + ChargingGroup("F", today.atTime(17, 30), today.plusDays(1).atTime(0, 0), BigDecimal("15"), BigDecimal("5")) + ) + } +} diff --git a/src/main/kotlin/com/frankenergie/smartasset/model/ChargingOrder.kt b/src/main/kotlin/com/frankenergie/smartasset/model/ChargingOrder.kt new file mode 100644 index 0000000..50855da --- /dev/null +++ b/src/main/kotlin/com/frankenergie/smartasset/model/ChargingOrder.kt @@ -0,0 +1,12 @@ +package com.frankenergie.smartasset.model + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class ChargingGroup( + val id: String, + val startTime: LocalDateTime, + val endTime: LocalDateTime, + val neededChargeMWh: BigDecimal, + val maxPowerMW: BigDecimal +) diff --git a/src/main/kotlin/com/frankenergie/smartasset/model/ChargingPlan.kt b/src/main/kotlin/com/frankenergie/smartasset/model/ChargingPlan.kt new file mode 100644 index 0000000..b7fefed --- /dev/null +++ b/src/main/kotlin/com/frankenergie/smartasset/model/ChargingPlan.kt @@ -0,0 +1,14 @@ +package com.frankenergie.smartasset.model + +import java.math.BigDecimal + +data class ChargingPlan( + val groupAllocations: List, + val totalCost: BigDecimal +) { + val averagePricePerMWh: BigDecimal + get() { + val totalMWh = groupAllocations.sumOf { it.totalMWh } + return if (totalMWh > BigDecimal.ZERO) totalCost.divide(totalMWh, 2, java.math.RoundingMode.HALF_UP) else BigDecimal.ZERO + } +} diff --git a/src/main/kotlin/com/frankenergie/smartasset/model/GroupAllocation.kt b/src/main/kotlin/com/frankenergie/smartasset/model/GroupAllocation.kt new file mode 100644 index 0000000..e6fc838 --- /dev/null +++ b/src/main/kotlin/com/frankenergie/smartasset/model/GroupAllocation.kt @@ -0,0 +1,11 @@ +package com.frankenergie.smartasset.model + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class GroupAllocation( + val groupId: String, + val allocations: List, + val totalMWh: BigDecimal, + val totalCost: BigDecimal +) diff --git a/src/main/kotlin/com/frankenergie/smartasset/model/QuarterAllocation.kt b/src/main/kotlin/com/frankenergie/smartasset/model/QuarterAllocation.kt new file mode 100644 index 0000000..6d76fb1 --- /dev/null +++ b/src/main/kotlin/com/frankenergie/smartasset/model/QuarterAllocation.kt @@ -0,0 +1,13 @@ +package com.frankenergie.smartasset.model + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class QuarterAllocation( + val startTime: LocalDateTime, + val endTime: LocalDateTime, + val mwh: BigDecimal, + val pricePerMWh: BigDecimal +) { + val cost: BigDecimal get() = mwh * pricePerMWh +} diff --git a/src/main/kotlin/com/frankenergie/smartasset/service/ChargingOptimizedService.kt b/src/main/kotlin/com/frankenergie/smartasset/service/ChargingOptimizedService.kt new file mode 100644 index 0000000..88be3c5 --- /dev/null +++ b/src/main/kotlin/com/frankenergie/smartasset/service/ChargingOptimizedService.kt @@ -0,0 +1,76 @@ +package com.frankenergie.smartasset.service + +import com.frankenergie.smartasset.domain.OrderBook +import com.frankenergie.smartasset.model.* +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.time.LocalDateTime + +@Service +class ChargingOptimizerService { + private val quarterDurationHours = BigDecimal("0.25") + + fun optimize( + groups: List, + orderBook: OrderBook, + chargedSoFar: Map = emptyMap() + ): ChargingPlan { + val groupAllocations = groups.map { group -> + optimizeGroup(group, orderBook, chargedSoFar[group.id] ?: BigDecimal.ZERO) + } + + val totalCost = groupAllocations.sumOf { it.totalCost } + return ChargingPlan(groupAllocations, totalCost) + } + + private fun optimizeGroup( + group: ChargingGroup, + orderBook: OrderBook, + alreadyCharged: BigDecimal + ): GroupAllocation { + val remainingNeed = (group.neededChargeMWh - alreadyCharged).max(BigDecimal.ZERO) + val maxPerQuarter = group.maxPowerMW * quarterDurationHours + val quarters = getQuartersInWindow(group.startTime, group.endTime) + + val quartersWithPrices = quarters.mapNotNull { period -> + val bestAsk = orderBook.getBestAsk(period) + bestAsk?.let { period to it.price } + }.sortedBy { it.second } + + val allocations = mutableListOf() + var allocated = BigDecimal.ZERO + + for ((period, price) in quartersWithPrices) { + if (allocated >= remainingNeed) break + + val toAllocate = maxPerQuarter.min(remainingNeed - allocated) + allocations.add( + QuarterAllocation( + startTime = period.startTime, + endTime = period.endTime, + mwh = toAllocate, + pricePerMWh = price + ) + ) + allocated += toAllocate + } + + return GroupAllocation( + groupId = group.id, + allocations = allocations, + totalMWh = allocated, + totalCost = allocations.sumOf { it.cost } + ) + } + + private fun getQuartersInWindow(start: LocalDateTime, end: LocalDateTime): List { + val quarters = mutableListOf() + var current = start + while (current < end) { + val quarterEnd = current.plusMinutes(15) + quarters.add(DeliveryPeriod(current, quarterEnd.coerceAtMost(end))) + current = quarterEnd + } + return quarters + } +} diff --git a/src/test/kotlin/com/frankenergie/smartasset/service/ChargingOptimizerServiceTest.kt b/src/test/kotlin/com/frankenergie/smartasset/service/ChargingOptimizerServiceTest.kt new file mode 100644 index 0000000..fbf7433 --- /dev/null +++ b/src/test/kotlin/com/frankenergie/smartasset/service/ChargingOptimizerServiceTest.kt @@ -0,0 +1,134 @@ +package com.frankenergie.smartasset.service + +import com.frankenergie.smartasset.domain.OrderBook +import com.frankenergie.smartasset.model.ChargingGroup +import com.frankenergie.smartasset.model.DeliveryPeriod +import com.frankenergie.smartasset.model.OrderSide +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.time.LocalDateTime + +class ChargingOptimizerServiceTest { + + private lateinit var optimizer: ChargingOptimizerService + private lateinit var orderBook: OrderBook + + @BeforeEach + fun setUp() { + optimizer = ChargingOptimizerService() + orderBook = OrderBook() + } + + @Test + fun `optimize returns empty allocations when no sell orders`() { + val group = ChargingGroup( + id = "A", + startTime = LocalDateTime.of(2024, 1, 15, 10, 0), + endTime = LocalDateTime.of(2024, 1, 15, 11, 0), + neededChargeMWh = BigDecimal("1"), + maxPowerMW = BigDecimal("2") + ) + + val plan = optimizer.optimize(listOf(group), orderBook) + + assertEquals(1, plan.groupAllocations.size) + assertTrue(plan.groupAllocations[0].allocations.isEmpty()) + assertEquals(BigDecimal.ZERO, plan.totalCost) + } + + @Test + fun `optimize allocates cheapest quarters first`() { + val group = ChargingGroup( + id = "A", + startTime = LocalDateTime.of(2024, 1, 15, 10, 0), + endTime = LocalDateTime.of(2024, 1, 15, 11, 0), + neededChargeMWh = BigDecimal("0.50"), + maxPowerMW = BigDecimal("2") + ) + + val q1 = DeliveryPeriod(LocalDateTime.of(2024, 1, 15, 10, 0), LocalDateTime.of(2024, 1, 15, 10, 15)) + val q2 = DeliveryPeriod(LocalDateTime.of(2024, 1, 15, 10, 15), LocalDateTime.of(2024, 1, 15, 10, 30)) + val q3 = DeliveryPeriod(LocalDateTime.of(2024, 1, 15, 10, 30), LocalDateTime.of(2024, 1, 15, 10, 45)) + + orderBook.processOrder(q1, OrderSide.SELL, BigDecimal("10"), BigDecimal("60.00")) + orderBook.processOrder(q2, OrderSide.SELL, BigDecimal("10"), BigDecimal("40.00")) + orderBook.processOrder(q3, OrderSide.SELL, BigDecimal("10"), BigDecimal("50.00")) + + val plan = optimizer.optimize(listOf(group), orderBook) + + val allocation = plan.groupAllocations[0] + assertEquals(1, allocation.allocations.size) + assertEquals(BigDecimal("40.00"), allocation.allocations[0].pricePerMWh) + } + + @Test + fun `optimize respects max power constraint`() { + val group = ChargingGroup( + id = "A", + startTime = LocalDateTime.of(2024, 1, 15, 10, 0), + endTime = LocalDateTime.of(2024, 1, 15, 10, 30), + neededChargeMWh = BigDecimal("1"), + maxPowerMW = BigDecimal("2") // max 0.5 MWh per quarter + ) + + val q1 = DeliveryPeriod(LocalDateTime.of(2024, 1, 15, 10, 0), LocalDateTime.of(2024, 1, 15, 10, 15)) + val q2 = DeliveryPeriod(LocalDateTime.of(2024, 1, 15, 10, 15), LocalDateTime.of(2024, 1, 15, 10, 30)) + + orderBook.processOrder(q1, OrderSide.SELL, BigDecimal("10"), BigDecimal("50.00")) + orderBook.processOrder(q2, OrderSide.SELL, BigDecimal("10"), BigDecimal("50.00")) + + val plan = optimizer.optimize(listOf(group), orderBook) + + val allocation = plan.groupAllocations[0] + assertEquals(2, allocation.allocations.size) + assertEquals(BigDecimal("0.50"), allocation.allocations[0].mwh) + assertEquals(BigDecimal("0.50"), allocation.allocations[1].mwh) + } + + @Test + fun `optimize accounts for already charged energy`() { + val group = ChargingGroup( + id = "A", + startTime = LocalDateTime.of(2024, 1, 15, 10, 0), + endTime = LocalDateTime.of(2024, 1, 15, 10, 30), + neededChargeMWh = BigDecimal("1"), + maxPowerMW = BigDecimal("2") + ) + + val q1 = DeliveryPeriod(LocalDateTime.of(2024, 1, 15, 10, 0), LocalDateTime.of(2024, 1, 15, 10, 15)) + val q2 = DeliveryPeriod(LocalDateTime.of(2024, 1, 15, 10, 15), LocalDateTime.of(2024, 1, 15, 10, 30)) + + orderBook.processOrder(q1, OrderSide.SELL, BigDecimal("10"), BigDecimal("50.00")) + orderBook.processOrder(q2, OrderSide.SELL, BigDecimal("10"), BigDecimal("50.00")) + + val chargedSoFar = mapOf("A" to BigDecimal("0.50")) + val plan = optimizer.optimize(listOf(group), orderBook, chargedSoFar) + + val allocation = plan.groupAllocations[0] + assertEquals(BigDecimal("0.50"), allocation.totalMWh) + } + + @Test + fun `optimize calculates total cost correctly`() { + val group = ChargingGroup( + id = "A", + startTime = LocalDateTime.of(2024, 1, 15, 10, 0), + endTime = LocalDateTime.of(2024, 1, 15, 10, 30), + neededChargeMWh = BigDecimal("1"), + maxPowerMW = BigDecimal("4") + ) + + val q1 = DeliveryPeriod(LocalDateTime.of(2024, 1, 15, 10, 0), LocalDateTime.of(2024, 1, 15, 10, 15)) + val q2 = DeliveryPeriod(LocalDateTime.of(2024, 1, 15, 10, 15), LocalDateTime.of(2024, 1, 15, 10, 30)) + + orderBook.processOrder(q1, OrderSide.SELL, BigDecimal("10"), BigDecimal("40.00")) + orderBook.processOrder(q2, OrderSide.SELL, BigDecimal("10"), BigDecimal("60.00")) + + val plan = optimizer.optimize(listOf(group), orderBook) + + // Should use q1 fully (1 MWh at 40) = 40 EUR + assertEquals(0, BigDecimal("40.00").compareTo(plan.totalCost)) + } +}