diff --git a/ems/README.md b/ems/README.md index b44386d..e8eeb83 100755 --- a/ems/README.md +++ b/ems/README.md @@ -17,3 +17,7 @@ **GOPACS** - [GOPACS Integration](docs/GopacsIntegration.md) + +**Distro Energy** + +- [Distro Energy Integration](docs/DistroEnergyIntegration.md) diff --git a/ems/docs/DistroEnergyIntegration.md b/ems/docs/DistroEnergyIntegration.md new file mode 100644 index 0000000..4773fac --- /dev/null +++ b/ems/docs/DistroEnergyIntegration.md @@ -0,0 +1,168 @@ +## Distro Energy Integration + +### What is Distro Energy? + +[Distro Energy](https://developers.distro.energy) trades on the day-ahead electricity market on behalf of its +participants. Within Distro each participant is a **portfolio**. A portfolio submits day-ahead orders (volumes per +15-minute interval, optionally with a price range) and Distro places them on the market. + +OpenRemote uses the Distro **Day-Ahead Order Submissions** API to submit the net power forecast of an EMS site as +day-ahead market orders. The API rules that shape the integration: + +- Orders may be submitted up to five days in advance +- A new submission replaces any earlier submission for the same delivery day +- For next-day delivery Distro uses the most recent submission received before **09:00 Europe/Amsterdam** on the + preceding day + +### Getting Started + +To submit day-ahead orders through OpenRemote, you need the following: + +1. **Portfolio identifier** — The participant identifier agreed with Distro. It is a pre-defined prefix, or for + submissions at individual-meter level the prefix followed by the meter's EAN-18 +2. **Client key** — The API key for the Distro API, sent as the `x-client-key` header +3. **Net power forecast** — An `Ems Energy Optimisation Asset` whose `powerNet` attribute has predicted data points + for the coming days. These predicted values are what gets submitted + +#### Configuration + +The following environment variables must be set on the OpenRemote manager: + +| Variable | Required | Description | +| -------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- | +| `DISTRO_ENERGY_CLIENT_KEY` | Yes | Client key for the Distro API, sent as the `x-client-key` header. The handler does not start without it. | +| `DISTRO_ENERGY_BASE_URL` | No | Base URL of the Distro API (default: `https://ibt.prod.distro.energy/api/v1`) | +| `DISTRO_ENERGY_TIMEZONE` | No | Market time zone, used for delivery days, ISP positions and the 09:00 gate closure (default: `Europe/Amsterdam`) | +| `DISTRO_ENERGY_REQUEST_INTERVAL` | No | Minutes between submission runs (default: `60`) | + +#### Asset Setup + +In OpenRemote, create an `Ems Distro Energy Asset` as a child of an `Ems Energy Optimisation Asset` and set the +`portfolio` attribute to your Distro portfolio identifier. The parent's `powerNet` forecast is submitted for this +portfolio. + +| Attribute | Value Type | Read-only | Purpose | +| ---------------- | ---------------- | --------- | --------------------------------------------------------------------------------------------------------------------------- | +| `portfolio` | Text | | Distro portfolio identifier. Setting or changing it (re)starts the handler. | +| `lastSubmission` | Timestamp | ✓ | When the last run that submitted at least one delivery day finished. | +| `daysSubmitted` | Positive integer | ✓ | Number of delivery days submitted by the last run, which in practice is the forecast horizon in days. Written on every run. | + +A run that finds no forecast at all sets `daysSubmitted` to `0` and leaves `lastSubmission` untouched, so a stalled +forecast shows up as a fresh `0` next to an old `lastSubmission`. + +### Developer Guide + +#### Components + +``` +distroenergy/ + DistroEnergyHandler.java Schedules the submission runs, reads the powerNet forecast, + builds the payload and posts it per delivery day + DayAheadResource.java RESTEasy client proxy for POST /trader/{portfolio}/day-ahead/data + dto/DayAheadSubmission.java DTO for one delivery day (data, day, creationTimestamp) + dto/SubmissionData.java DTO for one ISP entry (position, priceLow, priceHigh, volume) +``` + +`EmsOptimisationService` owns the handler lifecycle. It starts a handler at boot for every `Ems Distro Energy Asset` +with a `portfolio`, restarts it when the asset or its `portfolio` changes, and stops it when the asset is deleted. +Handlers are keyed by asset id, so editing the portfolio replaces the handler instead of leaving the old one running. + +A handler is not deployed, and a warning is logged, when: + +- `portfolio` is blank +- the asset has no parent, or the parent is not an `Ems Energy Optimisation Asset` +- `DISTRO_ENERGY_CLIENT_KEY` is not set, or the client cannot be created + +These failures only affect that asset. The rest of the EMS service keeps running. + +#### Data Flow + +```mermaid +sequenceDiagram + participant OR as OpenRemote (EMS) + participant DB as Predicted datapoints + participant API as Distro Day-Ahead API + + loop Every DISTRO_ENERGY_REQUEST_INTERVAL minutes (first run on the next :30) + Note right of OR: Delivery days: tomorrow (before 09:00)
or the day after (from 09:00) up to today + 5 + + loop Per delivery day, until the first day without a forecast + OR->>DB: Query parent powerNet (15-min AVG) + DB-->>OR: Predicted datapoints + Note right of OR: Build one entry per ISP
(92, 96 or 100), fill gaps + OR->>API: POST /trader/{portfolio}/day-ahead/data + API-->>OR: 200 OK + end + + OR->>OR: Update daysSubmitted and lastSubmission + end +``` + +#### Schedule + +The first run starts at the next half hour after deploy, then repeats every `DISTRO_ENERGY_REQUEST_INTERVAL` minutes. Each run submits +the delivery days from the first open day up to five days after today, market time: + +- Before 09:00 the first open day is tomorrow +- From 09:00 tomorrow is closed, and the first open day is the day after tomorrow + +The run stops at the first day without any forecast, since the days after it are beyond the forecast horizon too. There +is no catch-up state: because Distro replaces a day on every submission, the next run simply sends the latest forecast +again. With the default interval the 08:30 run is the last one that reaches tomorrow. + +#### Payload + +One request is sent per delivery day: + +```json +{ + "day": 20260602, + "creationTimestamp": 1780300800, + "data": [ + { "position": 1, "volume": -10.5 }, + { "position": 2, "volume": -9.75 } + ] +} +``` + +| Field | Source | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `day` | Delivery day in `YYYYMMDD` format, in market time | +| `creationTimestamp` | Current time in Unix seconds | +| `position` | ISP number within the day, starting at `1` for 00:00–00:15. A day has 96 positions, 92 on the switch to DST, 100 off it | +| `volume` | Average forecast `powerNet` over the ISP converted to kWh: `-powerNet × 0.25`, rounded to 0.01 kWh | +| `priceLow`, `priceHigh` | Not sent, so Distro submits market orders | + +Distro signs buys negative and sells positive. Positive `powerNet` is import, so a forecast of 42 kW import over an ISP +becomes a volume of `-10.5` kWh. + +#### Gap Handling + +Distro requires every position of the day to carry a volume, so gaps in the forecast are filled before submitting: + +- **No forecast for the whole day** — The day is not submitted. Sending it would post a zero trading position for a day + without a forecast +- **Gaps inside the forecast** — Submitted as `0.0` and logged as a warning, because `0.0` is a real trading position +- **Gaps after the forecast ends** — Submitted as `0.0` up to midnight, logged at fine level. This is expected when the + forecast horizon ends partway through a day +- **Repeated hour on the switch off DST** — A forecast producer that writes a fixed 96-slot day has no values for the + four extra ISPs. They reuse the next forecast value of the day instead of trading the hour away, and a warning is + logged + +#### Resilience + +- A failure for one delivery day (HTTP error or exception) is logged as `Failed to submit day-ahead forecast for +portfolio … and day …` and the run continues with the next day. The schedule itself keeps running +- Stopping the handler cancels the schedule, interrupts any request in flight and closes the HTTP client +- Use `daysSubmitted` and `lastSubmission` to spot a stalled integration without reading the logs + +### Testing + +To test against Distro's development environment, set `DISTRO_ENERGY_BASE_URL` to +`https://ibt.dev.distro.energy/api/v1`. The Distro spec also defines a `GET /healthz` endpoint for checking that the API is reachable. + +Automated tests: + +- `DistroEnergyHandlerTest` — payload building, volume conversion, DST days and gap filling +- `DistroEnergyHandlerStatusTest` — status attributes written after a run and the gate closure boundary +- `EmsOptimisationServiceDistroEnergyTest` — handler lifecycle in `EmsOptimisationService` 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 581ed05..c74aefb 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 @@ -63,11 +63,11 @@ public class DistroEnergyHandler { public static final String DISTRO_ENERGY_CLIENT_KEY = "DISTRO_ENERGY_CLIENT_KEY"; public static final String DISTRO_ENERGY_BASE_URL = "DISTRO_ENERGY_BASE_URL"; public static final String DISTRO_ENERGY_BASE_URL_DEFAULT = - "https://ibt.dev.distro.energy/api/v1"; + "https://ibt.prod.distro.energy/api/v1"; public static final String DISTRO_ENERGY_TIMEZONE = "DISTRO_ENERGY_TIMEZONE"; public static final String DISTRO_ENERGY_TIMEZONE_DEFAULT = "Europe/Amsterdam"; - public static final String REQUEST_INTERVAL_MINUTES = "REQUEST_INTERVAL"; - public static final String REQUEST_INTERVAL_MINUTES_DEFAULT = "60"; + public static final String DISTRO_ENERGY_REQUEST_INTERVAL = "DISTRO_ENERGY_REQUEST_INTERVAL"; + public static final String DISTRO_ENERGY_REQUEST_INTERVAL_DEFAULT = "60"; /** Market settlement interval. One submission entry per ISP. */ protected static final Duration ISP_DURATION = Duration.ofMinutes(15); @@ -138,7 +138,8 @@ public DistroEnergyHandler( Integer.parseInt( container .getConfig() - .getOrDefault(REQUEST_INTERVAL_MINUTES, REQUEST_INTERVAL_MINUTES_DEFAULT)); + .getOrDefault( + DISTRO_ENERGY_REQUEST_INTERVAL, DISTRO_ENERGY_REQUEST_INTERVAL_DEFAULT)); this.clientKey = container.getConfig().get(DISTRO_ENERGY_CLIENT_KEY); if (clientKey == null) {