From c87042587538d2c41f688bbc14bf68aa5b49aa14 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 29 Sep 2026 11:41:03 +0200 Subject: [PATCH] Skip tomorrow in Distro day-ahead runs after 09:00 gate closure Distro rejects a day-ahead submission for tomorrow once the 09:00 market-time deadline has passed, so every hourly run after 09:00 logged a failure for that day. Runs from 09:00 now start at the day after tomorrow. The horizon still ends five days after today, so the loop is bounded by date rather than by a day count. --- .../distroenergy/DistroEnergyHandler.java | 24 ++++++++++----- .../DistroEnergyHandlerStatusTest.groovy | 29 ++++++++++++++++--- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/ems/src/main/java/org/openremote/extension/ems/manager/distroenergy/DistroEnergyHandler.java b/ems/src/main/java/org/openremote/extension/ems/manager/distroenergy/DistroEnergyHandler.java index 8b81953..581ed05 100644 --- a/ems/src/main/java/org/openremote/extension/ems/manager/distroenergy/DistroEnergyHandler.java +++ b/ems/src/main/java/org/openremote/extension/ems/manager/distroenergy/DistroEnergyHandler.java @@ -26,6 +26,7 @@ import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; @@ -77,6 +78,12 @@ public class DistroEnergyHandler { */ protected static final int MAX_DAYS_AHEAD = 5; + /** + * Day-ahead gate closure, market time. From this moment tomorrow is closed and the API rejects a + * submission for it, so a run at or after it starts at the day after tomorrow. + */ + protected static final LocalTime GATE_CLOSURE = LocalTime.of(9, 0); + /** The {@link EmsDistroEnergyAsset} this handler reports its status on. */ protected final String assetId; @@ -189,19 +196,22 @@ public void deploy() { } protected void submitDayAheadForecasts() { - LocalDate firstDay = timerService.getNow().atZone(marketZone).toLocalDate().plusDays(1); + LocalDateTime marketNow = timerService.getNow().atZone(marketZone).toLocalDateTime(); + LocalDate today = marketNow.toLocalDate(); + LocalDate firstDay = today.plusDays(marketNow.toLocalTime().isBefore(GATE_CLOSURE) ? 1 : 2); + LocalDate lastDay = today.plusDays(MAX_DAYS_AHEAD); LOG.fine( "Starting day-ahead submission run for portfolio " + portfolio - + "; first market day " + + "; market days " + firstDay - + ", horizon up to " - + MAX_DAYS_AHEAD - + " day(s)"); + + " to " + + lastDay); int submitted = 0; - for (int day = 0; day < MAX_DAYS_AHEAD; day++) { - LocalDate marketDate = firstDay.plusDays(day); + for (LocalDate marketDate = firstDay; + !marketDate.isAfter(lastDay); + marketDate = marketDate.plusDays(1)) { try { if (!submitDayAheadForecast(marketDate)) { // First uncovered day is the end of the forecast horizon; nothing beyond it to send. diff --git a/ems/src/test/groovy/org/openremote/extension/ems/manager/distroenergy/DistroEnergyHandlerStatusTest.groovy b/ems/src/test/groovy/org/openremote/extension/ems/manager/distroenergy/DistroEnergyHandlerStatusTest.groovy index 34ed167..fe25677 100644 --- a/ems/src/test/groovy/org/openremote/extension/ems/manager/distroenergy/DistroEnergyHandlerStatusTest.groovy +++ b/ems/src/test/groovy/org/openremote/extension/ems/manager/distroenergy/DistroEnergyHandlerStatusTest.groovy @@ -43,13 +43,14 @@ class DistroEnergyHandlerStatusTest extends Specification { AssetProcessingService assetProcessingService Container container + long now = NOW FixedHorizonHandler handler def setup() { assetProcessingService = Mock(AssetProcessingService) def timerService = Stub(TimerService) { - getCurrentTimeMillis() >> NOW - getNow() >> Instant.ofEpochMilli(NOW) + getCurrentTimeMillis() >> { now } + getNow() >> { Instant.ofEpochMilli(now) } } container = Stub(Container) { getConfig() >> [(DistroEnergyHandler.DISTRO_ENERGY_CLIENT_KEY): "test-key"] @@ -96,10 +97,29 @@ class DistroEnergyHandlerStatusTest extends Specification { 0 * assetProcessingService._ } + def "a run before gate closure starts at tomorrow, from gate closure at the day after"() { + given: "a handler whose forecast covers every day" + now = Instant.parse(instant).toEpochMilli() + handler = new FixedHorizonHandler(Integer.MAX_VALUE, container) + + when: + handler.submitDayAheadForecasts() + + then: "the run ends five days after today, market time, either way" + handler.marketDates.first() == LocalDate.parse(firstDay) + handler.marketDates.last() == LocalDate.parse("2026-10-04") + + where: "09:00 Europe/Amsterdam is 07:00Z during CEST" + instant | firstDay + "2026-09-29T06:59:59Z" | "2026-09-30" + "2026-09-29T07:00:00Z" | "2026-10-01" + "2026-09-29T21:59:59Z" | "2026-10-01" + } + // Reports a forecast covering exactly `horizonDays` market days without touching the API. static class FixedHorizonHandler extends DistroEnergyHandler { final int horizonDays - int calls = 0 + final List marketDates = [] FixedHorizonHandler(int horizonDays, Container container) { super(ASSET_ID, new AttributeRef("parent", "powerNet"), "portfolio-a", container) @@ -108,7 +128,8 @@ class DistroEnergyHandlerStatusTest extends Specification { @Override protected boolean submitDayAheadForecast(LocalDate marketDate) { - return calls++