Recipes
Recipes
Process dispatch events from meter-level webhook
Recipe Description
In this Recipe
- Listen to dispatch event notification
- Identify new event
- Identify cancelled event
- Identify modified event
- Handle new event
- Handle new event - consolidate overlaps
- If max event duration constraints exist
- Handle cancelled event
- Handle modified event
- Optional: determine discharge rate for battery resources
- Optional: filter out voluntary event
Open Recipe
Kotlin
xxxxxxxxxx
// Assume all the dispatches sent by leap will be saved in partner database. Partner consolidate the dispatches and save them in another table.
class WebhookRecipe(
private val rawDispatchRepository: RawDispatchRepository, // dispatches sent by leap
private val consolidatedDispatchRepository: ConsolidatedDispatchRepository // consolidated dispatches by partner
) {
private val maxHoursOfDispatch = 3
// dispatch webhook
@PostMapping("/your-webhook-endpoint")
fun processWebhookNotification(dispatchNotification: ApiDispatchEventNotification) {
val meterDispatches = dispatchNotification.meterDispatches.flatMap { meterDispatch ->
meterDispatch.timeslots.map {
MeterDispatch(
meterEventId = it.meterEventId,
meterId = meterDispatch.meterId,
dispatchStartTime = it.startTime,
dispatchEndTime = it.endTime,
cancelled = it.cancelled,
energyKw = it.energyKw,
priority = it.priority,
performanceCompensationCap = PerformanceCompensationCap.valueOf(it.performanceCompensationCap.value),
isVoluntary = it.isVoluntary,
durationInMinutes = Duration.between(it.endTime, it.startTime).toMinutes()
)
}
}
meterDispatches.forEach {
val dispatch = rawDispatchRepository.findByMeterEventId(it.meterEventId!!)
if (dispatch == null) {
// this is a new event
handleNewEvent(it)
} else { // this is an update of existing event
if (it.cancelled) {
// this is a cancellation of existing event
handleCancelledEvent(it)
} else {
// this is an update of existing event. quantity or end time.
handleModifiedEvent(it, dispatch)
}
}
}
}
private fun handleNewEvent(meterMeterDispatchEvent: MeterDispatch) {
rawDispatchRepository.save(meterMeterDispatchEvent)
val overlappingDispatches = rawDispatchRepository.findOverlappingDispatches(meterMeterDispatchEvent).filter { !it.cancelled }
// consolidate overlapping dispatches
val consolidatedOverlappingDispatches = DispatchConsolidator.consolidate(overlappingDispatches)
// save consolidated event
consolidatedDispatchRepository.saveAll(consolidatedOverlappingDispatches)
// if there's limits on max hours of dispatch per day
val allConsolidatedEvent = consolidatedDispatchRepository.findSameDayDispatches(meterMeterDispatchEvent)
val highPriorityDispatches = DispatchConsolidator.findHighPriorityDispatches(maxHoursOfDispatch, allConsolidatedEvent)
sendOutDispatch(highPriorityDispatches)
}
private fun sendOutDispatch(meterMeterDispatchEvents: List<MeterDispatch>) {
// your dispatch implementation
// ... implementation ...
}
private fun cancelDispatch(meterMeterDispatchEvents: List<MeterDispatch>) {
// your cancel dispatch implementation
// ... implementation ...
}
fun handleCancelledEvent(meterMeterDispatchEvent: MeterDispatch) {
rawDispatchRepository.save(meterMeterDispatchEvent)
val overlappingDispatches = rawDispatchRepository.findOverlappingDispatches(meterMeterDispatchEvent).filter { !it.cancelled }
// reconsolidate the dispatches between meterDispatchEvent.startTime and meterDispatchEvent.endTime
val reconsolidatedDispatches = DispatchConsolidator.consolidate(overlappingDispatches)
.map {
it.copy(
dispatchStartTime = maxOf(it.dispatchStartTime, meterMeterDispatchEvent.dispatchStartTime),
dispatchEndTime = minOf(it.dispatchEndTime, meterMeterDispatchEvent.dispatchEndTime)
)
}
val removedDispatches = consolidatedDispatchRepository.removeDispatchesBetween(
meterMeterDispatchEvent.dispatchStartTime,
meterMeterDispatchEvent.dispatchStartTime
)
cancelDispatch(removedDispatches)
consolidatedDispatchRepository.saveAll(reconsolidatedDispatches)
sendOutDispatch(reconsolidatedDispatches)
}
fun handleModifiedEvent(modifiedMeterDispatch: MeterDispatch, originalMeterDispatch: MeterDispatch) {
rawDispatchRepository.save(modifiedMeterDispatch)
val removedDispatch = consolidatedDispatchRepository.removeDispatchesBetween(
originalMeterDispatch.dispatchStartTime,
originalMeterDispatch.dispatchEndTime
)
cancelDispatch(removedDispatch)
val overlappingDispatches = rawDispatchRepository.findOverlappingDispatches(originalMeterDispatch).filter { !it.cancelled }
val reConsolidatedDispatch = DispatchConsolidator.consolidate(overlappingDispatches)
consolidatedDispatchRepository.saveAll(reConsolidatedDispatch)
sendOutDispatch(reConsolidatedDispatch)
}
}
interface RawDispatchRepository : CrudRepository<MeterDispatch, UUID> {
fun findByMeterEventId(marketGroupEventId: UUID): MeterDispatch?
fun findOverlappingDispatches(meterDispatch: MeterDispatch): List<MeterDispatch>
}
interface ConsolidatedDispatchRepository : CrudRepository<MeterDispatch, UUID> {
fun findSameDayDispatches(meterMeterDispatchEvent: MeterDispatch): List<MeterDispatch>
fun removeDispatchesBetween(start: Instant, end: Instant): List<MeterDispatch>
}
object DispatchConsolidator {
private const val minutesInAnHour = 60
internal fun consolidate(inputMeterDispatches: List<MeterDispatch>): List<MeterDispatch> {
// consolidate overlapping dispatches. example:
val dispatchStartEndTime: Set<Instant> =
(inputMeterDispatches.map { it.dispatchStartTime } + inputMeterDispatches.map { it.dispatchEndTime }).toSortedSet()
val consolidatedOverlappingMeterDispatches = dispatchStartEndTime.windowed(2, 1).mapNotNull { dispatchWindow ->
val dispatches =
inputMeterDispatches.filter { it.fullOverlapsWith(dispatchWindow) }
dispatches.takeIf { it.isNotEmpty() }?.let {
MeterDispatch(
meterId = dispatches.first().meterId,
dispatchStartTime = dispatchWindow.first(),
dispatchEndTime = dispatchWindow.last(),
priority = dispatches.minOf { it.priority }, // choose higher priority.
energyKw = dispatches.mapNotNull { it.energyKw }
.maxByOrNull { it.toDouble() }, // choose higher energy
performanceCompensationCap = dispatches.map { it.performanceCompensationCap }
.maxBy { it.compensation }, // nomination < site load < grid exports
isVoluntary = dispatches.all { it.isVoluntary }
)
}
}
return consolidatedOverlappingMeterDispatches
}
internal fun findHighPriorityDispatches(
maxHoursOfDispatch: Int,
meterDispatches: List<MeterDispatch>
): List<MeterDispatch> {
var totalDispatchedMinutes = 0L
val highPriorityMeterDispatches = meterDispatches
.sortedWith(
compareBy<MeterDispatch> { it.priority }.thenByDescending { it.energyKw }
.thenBy { it.dispatchStartTime }
)
.takeWhile {
totalDispatchedMinutes += it.durationInMinutes
totalDispatchedMinutes <= maxHoursOfDispatch * minutesInAnHour
}
return highPriorityMeterDispatches
}
}
private fun MeterDispatch.fullOverlapsWith(dispatchWindow: List<Instant>) =
this.dispatchStartTime <= dispatchWindow.first() && this.dispatchEndTime >= dispatchWindow.last()