Skip to content

Add Distro Energy day-ahead submission - #96

Open
Miggets7 wants to merge 14 commits into
mainfrom
feature/distro-energy-integration
Open

Miggets7 wants to merge 14 commits into
mainfrom
feature/distro-energy-integration

Conversation

@Miggets7

@Miggets7 Miggets7 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

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.

EmsDistroEnergyAsset carries the portfolio attribute. EmsOptimisationService deploys 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_AHEAD is 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

buildSubmissionData decides 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.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 double.

Handler lifecycle

ResteasyClient is stored as a field and closed in undeploy(), 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 matches GOPACSRedispatchHandler.

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 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.

Scheduling moved out of the constructor into deploy(), called after construction the way startRedispatchHandler calls startPolling(). 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.

startDistroEnergyHandler now resolves the parent and verifies it is an EmsEnergyOptimisationAsset. Previously only the presence of a parent ID was checked, so an asset under any other parent deployed cleanly and then queried powerNet on an asset with no such attribute.

Fixes in the same path

  • 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 (primitive double). Gaps within a covered day are submitted as 0.0.
  • Positions were sent in descending order. The API requires ascending.
  • scheduleAtFixedRate received an absolute epoch-millis initial delay and a millisecond period, both read as MINUTES, 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_TIMEZONE sets the market timezone and defaults to Europe/Amsterdam. DISTRO_ENERGY_CLIENT_KEY, DISTRO_ENERGY_BASE_URL and REQUEST_INTERVAL are unchanged.

Handler deployment is wrapped in try/catch. DistroEnergyHandler throws when DISTRO_ENERGY_CLIENT_KEY is unset, which would otherwise fail EMS service startup for every deployment that does not use Distro Energy.

Testing

./gradlew :ems:test passes for DistroEnergyHandlerTest, GOPACSHandlerTest and GOPACSHandlerEanTest: 46 tests, 0 failures. GOPACSHandlerHttpTest was not run locally because it needs the OpenRemote Postgres and Keycloak stack; CI covers it.

DistroEnergyHandlerTest has 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 plain Specification on purpose: ManagerContainerTrait forces TIMER_CLOCK_TYPE: PSEUDO, whose init() calls TimeZone.setDefault("UTC") JVM-wide, which would hide the DST behaviour under test. Expected counts are cross-checked against shapeshifter's DateTimeCalculation.numberOfIspsOnDay.

Known effects

  • Not retro-active. Days already submitted as all-zeros stay zero at Distro until the horizon reaches them and a later run overwrites.
  • Stopping at the first uncovered day means a single anomalous empty day truncates the run, and it is indistinguishable from the real horizon without probing past it. Acceptable given the producer assumption above.
  • 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

EmsDistroEnergyAsset gains 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) and daysSubmitted (the forecast horizon in days, written on every run). A run that finds no forecast leaves a fresh daysSubmitted of 0 next to a stale lastSubmission; a handler that stopped running leaves both stale. The handler takes the Distro Energy asset id for this. Covered by DistroEnergyHandlerStatusTest.

The DST-collapse warning in buildSubmissionData is now one line per day with a count instead of one per position.

./gradlew :ems:test --tests '*DistroEnergy*': 27 tests, 0 failures.

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.
@Miggets7 Miggets7 added the Feature Development of new feature label Sep 10, 2026
@Miggets7
Miggets7 requested review from igorrutka and wborn September 10, 2026 14:04

@wborn wborn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).
@Miggets7

Copy link
Copy Markdown
Contributor Author

@wborn could you review again?

@wborn wborn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feature Development of new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants