Recipes

Recipes

Process dispatch events from meter-level webhook

Recipe Description

In this Recipe

  1. Listen to dispatch event notification
  2. Identify new event
  3. Identify cancelled event
  4. Identify modified event
  5. Handle new event
  6. Handle new event - consolidate overlaps
  7. If max event duration constraints exist
  8. Handle cancelled event
  9. Handle modified event
  10. Optional: determine discharge rate for battery resources
  11. 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()