Conversation
Submits the parent optimisation asset's net power forecast to the Distro Energy trader API, one POST per day for five days ahead. The API requires exactly one entry per quarter-hour of the market day, which is 92 on the spring-forward day and 100 on the fall-back day rather than the usual 96. Entries are therefore driven by a grid of real instants across the market day instead of by the number of rows the datapoint query returns, so the length follows the DST transition without special-casing it. Predicted datapoints are stored as JVM-local wall-clock rather than UTC (see openremote/openremote#3292), so each instant is mapped back into that frame to look up its value. On the fall-back day the repeated hour collapses onto a single stored row, so those positions reuse the surviving value and log a warning. Once predicted datapoints are stored in UTC the same code becomes exact without changing. Several defects in the original submission block are fixed along the way: - Interval query bounds are inclusive at both ends, so querying a full day returned 97 buckets instead of 96. The upper bound is now the start of the last interval. - Gap-filled buckets carry a null value, which threw NPE when unboxed into SubmissionData.volume. Missing intervals are submitted as 0.0 instead. - Positions were sent in descending order; the API requires ascending. - scheduleAtFixedRate received an absolute epoch-millis initial delay and a millisecond period, both interpreted as MINUTES, so the task never fired. The market timezone is configurable through DISTRO_ENERGY_TIMEZONE and defaults to Europe/Amsterdam.
Nothing constructed DistroEnergyHandler, so the integration was unreachable. Handlers are now created for existing EmsDistroEnergyAsset instances at startup and kept in sync with create, update and delete persistence events, following the pattern already used for the GOPACS handlers. Handlers are keyed by asset id rather than portfolio. GOPACS keys by EAN because inbound messages route on it, whereas this handler has no external routing key and the asset id survives a portfolio edit. Deployment is wrapped in try/catch because DistroEnergyHandler throws when DISTRO_ENERGY_CLIENT_KEY is unset, which would otherwise fail service startup for every deployment that does not use Distro Energy. The asset panel config is added so the portfolio attribute is editable in the UI.
wborn
left a comment
There was a problem hiding this comment.
This is an initial AI-assisted review.
The Distro Energy integration looks well structured overall, and the DST handling is especially thorough. The 92/96/100 interval handling is consistent with the current predicted-datapoint timestamp behavior, and the accompanying tests cover the important transition cases.
One lifecycle issue should be fixed before merging: each DistroEnergyHandler creates its own RESTEasy client, but the client is never closed when the handler is undeployed. Since handlers are recreated whenever the corresponding asset is updated, this can leave old clients and their connection pools allocated over time.
getFirstRequestDelayMillis() clamps the initial delay to zero, so any deploy after the half hour scheduled the recurring task with no delay at all. The task could then start on an executor thread while the constructor was still running, observing a partly constructed handler. Move the scheduling into a deploy() method that EmsOptimisationService calls after construction, matching how startRedispatchHandler calls startPolling() on GOPACSRedispatchHandler. The handler is registered in the map before deploy() runs, so one whose scheduling is rejected during shutdown is still reachable from stop().
The handler created a ResteasyClient into a local variable and kept only the proxy, so undeploy() cancelled the scheduled task but left the client and its connection pool allocated. EmsOptimisationService recreates the handler on every update of the asset, so each edit leaked a client that had already made requests and held sockets. Store the client as a field and close it in undeploy(), cancelling the scheduled future with an interrupt first so an in-flight POST is aborted before the client goes away. This matches GOPACSRedispatchHandler. The constructor also guards the window between createClient and the proxy: client.target throws IllegalArgumentException on a malformed DISTRO_ENERGY_BASE_URL, and no reference escapes a constructor that threw, so undeploy() could never close that client. Closing is safe with the shared executor: WebTargetBuilder.createClient passes it through ResteasyClientBuilder.executorService(ExecutorService), which sets cleanupExecutor to false, so Container.EXECUTOR is untouched.
The comment said the parent must be an EmsEnergyOptimisationAsset, but only the presence of a parent ID was checked. An asset placed under any other parent deployed cleanly and then queried powerNet on an asset with no such attribute, producing an empty forecast rather than an error. Resolve the parent at deploy time instead. find(id, false, type) returns null both for a missing asset and for one of the wrong type, which are the same problem here, so the message reports it as such rather than claiming the type is wrong. loadComplete is false because only the type is being read.
…ndow The handler posted a fixed five days on every run and zero-filled every interval it had no data for. Nothing in this repository writes predicted powerNet; an external producer does. Days past that producer's horizon were therefore submitted as a full day of zeros, every hour, which is a real net power trading position for a day we know nothing about. The fixed five was also wrong in the other direction: a longer forecast was silently truncated. Submit from tomorrow forward until the first day with no forecast. A day that is only partly covered is still sent whole, zero-filled to midnight, because the API requires one entry per quarter-hour of the market day. Each run re-submits and overwrites, so a day is sent by the first run after the horizon reaches it and no catch-up state is needed. buildSubmissionData decides whether a day has a forecast, returning an empty list when it does not. That decision has to come from the ISP grid rather than from whatever the query returned: the two agree only because the query window is derived from the same day bounds, and widening that window would let a neighbouring day's value make this day look covered. An empty list is unambiguous because a submitted day is never shorter than 92 entries. A genuine all-zero forecast is still submitted. The distinction between a forecast 0.0 and a filled 0.0 only survives inside buildSubmissionData, where the grid lookup is a boxed Double and null means missing; it is erased at the boundary because SubmissionData.volume is a primitive. Gap logging is split accordingly. A partly covered final day is now the expected steady state and logs at FINE with the boundary position, so a horizon that is systematically short by a fixed number of intervals stays diagnosable. A gap inside the forecast still warns, because the producer writes every interval it covers and those positions go out as 0.0. MAX_DAYS_AHEAD is a defensive ceiling rather than a business window; the loop stops at the horizon, so it only bounds the damage if a stray far-future datapoint makes the horizon look unbounded.
Logs config on construction, first-run schedule time and interval on deploy, run start with market day and horizon, per-day datapoint count, and submission confirmation.
getFirstRequestDelayMillis clamped to zero and fired immediately whenever deploy happened after the current hour's :30 mark, instead of waiting for the next one. Roll forward to the next hour's :30 when already past it.
CI's spotlessJavaCheck failed on the attribute-event handler for EmsDistroEnergyAsset: an over-length line and over-indented lambda bodies. Applied via spotlessJavaApply, no behaviour change.
processAssetChange stopped the handler by portfolio, but distroEnergyHandlerMap is keyed by asset id (same as the map's own put/remove and the attribute-event path). Map.remove with the wrong key is a silent no-op, so: - DELETE never undeployed the handler: the ResteasyClient and scheduled task leaked, and it kept POSTing forecasts for an asset that no longer exists. - UPDATE never stopped the old handler before creating a new one: both kept submitting concurrently, one of them under the stale portfolio if that's what changed. Stop by asset id instead, which is always available from the entity regardless of whether the portfolio attribute happens to be present, so DELETE no longer needs to be gated on it either. Added EmsOptimisationServiceDistroEnergyTest to cover CREATE/UPDATE/ DELETE handler lifecycle. Uses a recording DistroEnergyHandler subclass (same pattern as RecordingGOPACSHandler in GOPACSHandlerTest) rather than mocking the class directly, so no extra test dependency is needed for a concrete class with no no-arg constructor: the fake Container just has to be enough for the real constructor to succeed, and deploy()/undeploy() are overridden to record calls instead of scheduling or closing a real client.
Adds two READ_ONLY attributes to EmsDistroEnergyAsset so a stalled handler is visible in the UI without reading logs: - lastSubmission (TIMESTAMP): when the last run that submitted at least one market day finished. - daysSubmitted (POSITIVE_INTEGER): how many market days the last run submitted, which is the forecast horizon in days. This was only a FINE log line before. DistroEnergyHandler writes daysSubmitted at the end of every run and lastSubmission only when something was sent, so a run that found no forecast at all leaves a fresh daysSubmitted of 0 next to a stale lastSubmission, and a handler that stopped running leaves both stale. The handler now takes the Distro Energy asset id, since it reports on that asset rather than on the parent it reads the forecast from, and resolves AssetProcessingService from the container like GOPACSHandler. DistroEnergyHandlerStatusTest covers both cases through a subclass that reports a fixed horizon without touching the API.
buildSubmissionData warned for every position whose storage key had already been read, so on the fall-back day it emitted a line per quarter-hour in the repeated hour, per portfolio, per run. Count the collapsed positions and log a single summary for the day, with the count, so the condition stays visible without flooding the log. The root cause is upstream (openremote/openremote#3292).
|
@wborn could you review again? |
wborn
left a comment
There was a problem hiding this comment.
The previously raised lifecycle, parent-validation and missing-forecast issues have been addressed.
The updated handler lifecycle, forecast horizon handling, DST behavior, status attributes, and scheduling changes were re-checked. The implementation now looks consistent with the intended behavior, and the Distro Energy-specific tests cover the important normal-day, spring-forward, fall-back, missing-data and partial-horizon cases.
No remaining issue was identified that should block merging.
This review was AI-assisted.
What
Adds the Distro Energy day-ahead integration. It submits the parent optimisation asset's net power forecast to the Distro Energy trader API, one POST per market day.
EmsDistroEnergyAssetcarries theportfolioattribute.EmsOptimisationServicedeploys a handler per asset at startup and keeps it in sync with create, update and delete persistence events, following the pattern already used for the GOPACS handlers.How far ahead it submits
Submission starts at tomorrow and runs forward until the first market day with no forecast. A day that is only partly covered is still sent whole, zero-filled to midnight, because the API requires one entry per quarter-hour of the market day.
Nothing in this repository writes predicted
powerNet; an external producer does. Sending a fixed window regardless of coverage would post a full day of zeros for days past that producer's horizon, every hour, which is a real net power trading position for a day we know nothing about. A fixed window is also wrong in the other direction, since a longer forecast would be silently truncated.Each run re-submits and overwrites, so a day is sent by the first run after the horizon reaches it. No catch-up state is needed.
MAX_DAYS_AHEADis a defensive ceiling rather than a business window. The loop stops at the horizon, so the ceiling only bounds the damage if a stray far-future datapoint makes the horizon look unbounded, and it warns when hit.This rests on one assumption about the forecast producer: it writes a datapoint for every interval it covers, not only for intervals with non-zero power. Absence of data therefore means "beyond the horizon". If a producer ever writes only non-zero intervals, a genuinely idle day would look uncovered and be skipped.
Quarter-hour counts and DST
The API requires exactly one entry per quarter-hour of the market day: 96 on a normal day, 92 on the spring-forward day, 100 on the fall-back day.
Entries are driven by a grid of real instants stepping across the market day rather than by the number of rows the datapoint query returns. The length then follows the DST transition with no per-transition branching.
Predicted datapoints are stored as JVM-local wall-clock rather than UTC, which is openremote/openremote#3292. Each instant is mapped back into that frame to look up its value. On the fall-back day the repeated hour collapses onto one stored row, because the primary key is
(timestamp, entity_id, attribute_name)and the upsert overwrites. Those positions reuse the surviving value and log a warning. Once predicted datapoints move to UTC the same code becomes exact with no change, and the tests cover that by running every case under both storage frames.The collapse can only duplicate a read, never erase one, so it can never turn a day with a forecast into a skip.
Where the submit-or-skip decision lives
buildSubmissionDatadecides whether a day has a forecast, returning an empty list when it does not. An empty list is unambiguous because a submitted day is never shorter than 92 entries.The decision comes from the ISP grid rather than from whatever the query returned. Those two answers agree only because the query window is derived from the same day bounds, and widening that window would let a neighbouring day's value make this day look covered, which is the bug this change removes.
A genuine all-zero forecast is still submitted. A fully curtailed site is a real trading position, and the distinction between a forecast
0.0and a filled0.0only survives insidebuildSubmissionData, where the grid lookup is a boxedDoubleandnullmeans missing. It is erased at the boundary becauseSubmissionData.volumeis a primitivedouble.Handler lifecycle
ResteasyClientis stored as a field and closed inundeploy(), which covers both service shutdown and the stop-then-start that every asset update performs. The scheduled future is cancelled with an interrupt first, so an in-flight POST is aborted before the client goes away. This matchesGOPACSRedispatchHandler.Closing is safe with the shared executor:
WebTargetBuilder.createClientpasses it throughResteasyClientBuilder.executorService(ExecutorService), which setscleanupExecutorto false, soContainer.EXECUTORis untouched.The constructor also guards the window between
createClientand the proxy.client.targetthrowsIllegalArgumentExceptionon a malformedDISTRO_ENERGY_BASE_URL, and no reference escapes a constructor that threw, soundeploy()could never close that client.Scheduling moved out of the constructor into
deploy(), called after construction the waystartRedispatchHandlercallsstartPolling().getFirstRequestDelayMillis()clamps the initial delay to zero, so any deploy after the half hour scheduled the task with no delay and it could start on an executor thread while the constructor was still running.startDistroEnergyHandlernow resolves the parent and verifies it is anEmsEnergyOptimisationAsset. Previously only the presence of a parent ID was checked, so an asset under any other parent deployed cleanly and then queriedpowerNeton an asset with no such attribute.Fixes in the same path
SubmissionData.volume(primitivedouble). Gaps within a covered day are submitted as0.0.scheduleAtFixedRatereceived an absolute epoch-millis initial delay and a millisecond period, both read asMINUTES, so the task never fired.Logging
Gap logging distinguishes trailing from interior gaps. A partly covered final day is the expected steady state and logs at FINE with the boundary position, so a horizon that is systematically short by a fixed number of intervals stays diagnosable. A gap inside the forecast still warns, because the producer writes every interval it covers and those positions go out as
0.0.Config
DISTRO_ENERGY_TIMEZONEsets the market timezone and defaults toEurope/Amsterdam.DISTRO_ENERGY_CLIENT_KEY,DISTRO_ENERGY_BASE_URLandREQUEST_INTERVALare unchanged.Handler deployment is wrapped in try/catch.
DistroEnergyHandlerthrows whenDISTRO_ENERGY_CLIENT_KEYis unset, which would otherwise fail EMS service startup for every deployment that does not use Distro Energy.Testing
./gradlew :ems:testpasses forDistroEnergyHandlerTest,GOPACSHandlerTestandGOPACSHandlerEanTest: 46 tests, 0 failures.GOPACSHandlerHttpTestwas not run locally because it needs the OpenRemote Postgres and Keycloak stack; CI covers it.DistroEnergyHandlerTesthas 22 cases covering 92/96/100 under both Amsterdam and UTC storage, gap filling, null buckets, a partly covered day filled to midnight, a genuine all-zero forecast, a value belonging to a neighbouring day, and the fall-back collapse. It is a plainSpecificationon purpose:ManagerContainerTraitforcesTIMER_CLOCK_TYPE: PSEUDO, whoseinit()callsTimeZone.setDefault("UTC")JVM-wide, which would hide the DST behaviour under test. Expected counts are cross-checked against shapeshifter'sDateTimeCalculation.numberOfIspsOnDay.Known effects
cancel(true)can log a stack trace on teardown if it interrupts an in-flight POST. Inherited from the GOPACS pattern, and rare since the task is idle between runs.Status attributes
EmsDistroEnergyAssetgains two READ_ONLY attributes so a stalled handler shows in the UI without logs (from #97):lastSubmission(TIMESTAMP, written when a run submitted at least one day) anddaysSubmitted(the forecast horizon in days, written on every run). A run that finds no forecast leaves a freshdaysSubmittedof 0 next to a stalelastSubmission; a handler that stopped running leaves both stale. The handler takes the Distro Energy asset id for this. Covered byDistroEnergyHandlerStatusTest.The DST-collapse warning in
buildSubmissionDatais now one line per day with a count instead of one per position../gradlew :ems:test --tests '*DistroEnergy*': 27 tests, 0 failures.