# 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

```kotlin
xxxxxxxxxx
```

```kotlin
// Assume all the dispatches sent by leap will be saved in partner database. Partner consolidate the dispatches and save them in another table.
```

```kotlin
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()
