charging optimization
This commit is contained in:
@@ -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"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user