charging optimization

This commit is contained in:
2026-07-01 12:05:56 +02:00
parent 922cee3c23
commit 71dfe62bbb
7 changed files with 285 additions and 0 deletions

View File

@@ -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<ChargingGroup> {
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"))
)
}
}

View File

@@ -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
)

View File

@@ -0,0 +1,14 @@
package com.frankenergie.smartasset.model
import java.math.BigDecimal
data class ChargingPlan(
val groupAllocations: List<GroupAllocation>,
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
}
}

View File

@@ -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<QuarterAllocation>,
val totalMWh: BigDecimal,
val totalCost: BigDecimal
)

View File

@@ -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
}

View File

@@ -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<ChargingGroup>,
orderBook: OrderBook,
chargedSoFar: Map<String, BigDecimal> = 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<QuarterAllocation>()
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<DeliveryPeriod> {
val quarters = mutableListOf<DeliveryPeriod>()
var current = start
while (current < end) {
val quarterEnd = current.plusMinutes(15)
quarters.add(DeliveryPeriod(current, quarterEnd.coerceAtMost(end)))
current = quarterEnd
}
return quarters
}
}

View File

@@ -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))
}
}