/console — a single
page served by the service itself, no separate frontend deployment. Open
it, paste the process API key (kept for the browser tab only, in
sessionStorage), and connect.
Finding your way
The console is one application shell in Engrate’s design language: a sidebar on the left with every page in a named group, the page itself on the right, and a top bar with the service state, the Engrate environment, Start/Stop and the key.- Jump anywhere with ⌘/Ctrl+K (or Springe zu … at the bottom of the sidebar): type a few letters, Enter opens the page.
- Light and dark. The console follows the system until Darstellung switches it; the choice is remembered per browser.
- Every screen. On a desktop the sidebar can be folded to an icon rail (the button next to the name); on a tablet it is the rail, and the menu button expands it over the page; on a phone it slides in from the left. Pages with a form and a result — Zuordnung, PARTIN, Bestellungen — show both side by side where there is room and stacked where there is not.
- The last page, the folded sidebar and the theme are remembered per browser; the Marktrollen view per tab.
/console/static/, and the content security policy allows exactly that.
Fast by design
A load of the console costs round trips, not bytes - so the service is built to need as few as possible (console_assets.py):
- Addressed by content. The two scripts and the three fonts are
/console/static/<name>.<digest>.<ext>, cached for a year as immutable. The page itself has a fixed URL and is revalidated: an ETag andCache-Control: no-cache, so a browser that has it asks once and gets a 304. A repeat load is one small round trip for the page and nothing else; a release changes the digests and the browser fetches exactly what changed (/console/app.js, the script’s fixed name, still answers, revalidating). The page holds no secret - the key is typed into it - which is why it may be cached at all. - Compressed once. Page and scripts are hashed and compressed while
the process starts - zstd where the interpreter has it (Python 3.14),
gzip always - and served by
Accept-Encoding(the page: 107 KB → 24 KB gzip; the script 192 KB → 53 KB). Fonts are Brotli inside and left alone. The JSON the console fetches is gzipped on the way out as well, so the 900-row views travel at a fraction of their size; the event stream is never compressed or buffered. - The first paint is the right one. A 400-byte boot script in the
head, before any paint, puts the stored theme and page on
<html>; every page but the overview starts hidden in the markup; the console script is fetched from the head while the page still parses and runs, deferred, once it is parsed. Nothing flashes the system scheme or the overview first. - Live means live. The event stream no longer reads the log every two seconds: an emit wakes every open stream, from whichever thread emits, and the event is in the browser within a frame. An idle stream costs nothing but a keep-alive every fifteen seconds.
- No layout shift. The two faces the first paint needs are preloaded,
and each has a fallback face (Arial / Liberation Sans, Courier New /
Liberation Mono) with
size-adjustand the ascent, descent and line-gap overrides measured from the font files, so text set before a font arrives occupies the same lines and widths. - Cheap frames. Surfaces are solid and bordered, not blurred or
shadowed - a traced page switch spent a third of its time
re-rasterising a blurred top bar and the shadow under a full-page card -
and pages swap in the same frame. Events arrive in bursts (the snapshot
on connect is hundreds at once) and are written in one frame: one
fragment, one trim, one scroll. Rows of the long lists are laid out and
painted only when scrolled to (
content-visibility); a filter typed into re-renders its list once per painted frame, not once per keystroke. The status poll pauses while the tab is hidden, runs at once when it is shown again, asks for the delivery days only while the overview shows, and redraws nothing whose data did not change. A list being refreshed keeps its rows, dimmed, until the new ones arrive. The registry of a thousand Netzbetreiber loads with the first page that needs it, not with every connect.
What it does today
Live event stream
Every pipeline action — forecast received, positions aggregated, CSV
published, schedules submitted, orchestration errors, config changes,
start/stop — appears live via Server-Sent Events, with history on load.
Runtime configuration
Change sFTP host/credentials, Engrate settings, home control area,
active areas, EIC identifiers and more — validated, applied without a
restart, persisted across restarts, secrets write-only.
Start / Stop
Stop pauses the orchestrator (scheduled runs become no-ops); manual
process steps stay available as an operator override. State survives
restarts and is visible in the status pill.
Delivery-day overview
Per-day pipeline state (aggregated / published / submitted), including
whether a step is stale because newer input arrived.
Status, dann Name (the default) puts rejections first, then the ladder
down to “kein Kontakt”. Letzter Kontakt – neueste zuerst orders each
control area by the newest contact on either channel, in either
direction — EDIFACT sent or received, e-mail sent or answered — and puts
partners without any contact last; every row shows that date and names
the contact on hover. The order is fetched from the server
(?sort=kontakt), which decides “newest” on instants across sources
with different offsets; the browser never re-sorts the strings. The
choice is remembered per browser.
Fahrplan/Ist panel. A delivery day’s schedule against what the market
measured, from GET /v1/process/fahrplan-ist, for Germany or one
Regelzone:
- Chart: the Fahrplan and the Ist step by step per quarter-hour. Orange areas are short (more consumed than scheduled), blue areas long. The checkbox adds the net series of each Kategorie: A from the grid operator, monthly; B from the ÜNB, monthly; C from the ÜNB, daily.
- Which Ist: the same one as the app’s Plan gegen Ist. It is Kategorie A plus B, or A plus C until B has arrived.
- Which Fahrplan: what the TSO confirmed, where a confirmation is in. A trade series the TSO changed moves the Regelzone’s Fahrplan by the change, and a note under the chart names the report and the amount (see Fahrplanbestätigung).
- Costs: below the chart, the reBAP from the BIKO’s PRICAT 27001 (EUR/MWh) and the cost per quarter-hour, which is the deviation energy times the reBAP. Orange is to pay, blue a credit. The reBAP is one price for all of Germany, so a Regelzone without its own price list takes another’s. Where no price list has a quarter-hour, the reBAP published on netztransparenz.de stands in, then the reBAP rebuilt from the market data, then the TSOs’ AEP-Schätzer (operational, every 15 minutes), then the reBAP forecast; on the last three the cost reads “geschätzt”, and the hint says which quarter-hours came from where.
- reBAP forecast (
GET /v1/process/rebap?day=, default tomorrow): per quarter-hour the mean of the same wall-clock quarter-hour over the latest 28 days with a published reBAP before the day, working days apart from weekends and nationwide holidays (all days while fewer than 4 of the kind are there), with P10 and P90. The history comes from netztransparenz.de: the orchestrator reads the quality-assured data points (reBAP, AEP modules 1-3, VoAA, NRV-Saldo) of the missing days every 6 hours and the operational ones (AEP-Schätzer, NRV-Saldo, ID AEP, spot price) every hour;POST /v1/process/rebap/aktualisieren?tage=does both at once. It needsNETZTRANSPARENZ_CLIENT_IDandNETZTRANSPARENZ_CLIENT_SECRET, or, on a Mandant, the Zentrale, whose history it copies instead. - reBAP rebuild (
GET /v1/process/rebap/pruefung?von=&bis=): the TSOs’ model description since 01.11.2023 - maximum of modules 1-3 when the NRV-Saldo is short, minimum when long, module 2 at zero. Module 2 (ID AEP plus a minimum gap of 25 %, at least 10 €/MWh, full from |NRV-Saldo| 500 MW) is rebuilt and checked per quarter-hour against the published one, and the published modules combined against the published reBAP. Module 1 is the ENTSO-E price of the activated balancing energy (A84, realised) in the balance’s direction - up when short, down when long - and the VoAA where nothing was activated in that direction. Where mFRR was activated in that direction too, module 1 mixes both by the platforms’ satisfied demand, which is not published: such a quarter-hour is not rebuilt (the check counts it as “nicht berechnet”, the AEP-Schätzer stands in); module 3 the published one, else the scarcity component of the operational NRV-Saldo. The whole reBAP rebuilt from these comes right after the published one, before the AEP-Schätzer; the check compares module 1 and the whole reBAP too. NeedsENTSOE_API_TOKEN. - Kalkulation: a table for the delivery day and the month up to it
(
GET /v1/process/kalkulation?von&bis, at most 31 days), in kWh, € and ct/kWh:- Spot bought and sold: the Fahrplan on the day-ahead market at the spot price (ENTSO-E, DE-LU, quarter-hourly; netztransparenz’s later where ENTSO-E has none). Net consumption is bought, net production sold.
- Ausgleichsenergie: kWh short and long, and the cost (above); “erwartet” while the reBAP is rebuilt or estimated.
- Total cost: both together.
- Delivered to customers: the Ist, the Fahrplan where it has not arrived, and its value at the quarter-hour’s spot price.
- Mehrkosten (total cost minus that value) and the Aufschlag per kWh that covers them. Customers’ cost per kWh is their spot price plus this Aufschlag. The Mehrkosten equal the deviation times reBAP minus spot.
- With the customers’ contractual Aufschlag set (
KUNDEN_AUFSCHLAG_CT_KWH, console group “Kalkulation”; the tariff is the quarter-hour spot price plus it): their revenue (spot value plus Aufschlag) and the margin (revenue minus total cost), in € and ct/kWh. - Germany is the sum of the Regelzonen, each with the Ist it has.
- Bilanzierungsgebiete: a table of the Ist per area, from Kategorie A (Z97) and Kategorie B per area (Z99).
- Incomplete days: until the Ist is complete, the sums cover only the quarter-hours compared, and the cost reads “bisher”.
- Hover: shows every value of one quarter-hour.
The console API
Everything the page does goes through JSON endpoints (scope: process), so it can equally be scripted:Forecast qualities (tab Prognosen)
One row per delivery day from a week back to the end of the forecast horizon, with a badge per stored quality:
The active version comes first; a faded badge is a version that was
replaced and is kept for comparison. Per row: consumption of the active
version (production in the tooltip), the confirmed customers in supply, the
CSV and schedule state, and — where the customer’s forecast replaced a
backup — how far the backup was off in daily energy. A click opens the
quarter-hour chart with every version of the day on top of each other.
Marktrollen (button in the header)
The whole window instead of the event stream and the panels: who n:lead deals with, on which contract, for which customers, and which sum time series the market has really activated. Betrieb switches back.- Head: the BKV (MP-ID, EIC, the Lieferant’s MP-ID) with its Bilanzkreise (Haupt- and Unterbilanzkreis), and one card per Regelzone with its customers, the series active against those expected and the Bilanzkreisvertrag with its ÜNB.
- Tree: Regelzone → BIKO (its aggregates on Regelzone level) and ÜNB → Netzbetreiber → Bilanzierungsgebiet. Per row the contracts, the confirmed customers by group (iMSys ¼h, RLM, SLP, TLP), every Zeitreihentyp in the colours of the Zeitreihen tab, and aktiv / erwartet as a bar. nur offene Punkte keeps the rows with a missing series, an unexpected activation or a missing contract.
- Details: a click on a row shows its master data, the customers by group, the series - and the contracts with a form to record them.
GET /v1/process/marktrollen (the Zeitreihen tree with contracts,
customer groups and counts per node); nothing is recorded by reading.
Verträge
What a contract says - its Fassungen - is in
Verträge und Fassungen; the button Verträge des BKV &
Prüfung in the BKV’s card shows the Lieferant’s and the trader’s
contracts and today’s contract check.
Each is
angefragt, aktiv, gekuendigt - or fehlt where one is
expected and nothing is recorded; an expected contract that is not active
counts as offen. Where the Lieferant is the BKV, the
Zuordnungsvereinbarung is a module of the Lieferantenrahmenvertrag
(BK6-20-160): without an entry of its own it follows that contract.
The Rahmenvertrag ledger (VNB-Kampagne) is what the Quittungs-Nachfass
believes, and a Lieferantenrahmenvertrag there counts as active.
Recording one here keeps it in step:
- aktiv, from today or earlier: recorded there too, with the fixed reason “Verträge (Konsole)” - never the note, because that ledger’s events reach the shared Slack channel. Active from a later day: only here, until then.
- gekuendigt: withdrawn there.
- Entry deleted: withdrawn there only if this console put it there; one the VNB-Kampagne recorded stays.
- Where the two disagree - the campaign sync changed it, or a contract is
active from a later day - the contract shows
!and says why.
Recording a contract sends nothing to anyone.
How runtime config works
Console changes are kept as an overlay on top of the environment variables, persisted toruntime_config.json in the audit directory (the
Azure Files share in production — so they survive restarts and
scale-to-zero). The config panel marks overridden fields with a runtime
badge. Environment variables remain the base; deleting the overlay file
reverts everything to env values.
The Lieferant tab also holds the
document pool of the
Lieferantenrahmenvertrag. Its files are uploaded there and take effect at
once; they are not part of the overlay.
API keys are deliberately not editable via the console — rotating them
stays an infrastructure operation. See Security for how
the stream token works and what the console records.
Events on disk
Events are appended toevents.jsonl next to the audit trail — the stream
you see in the console is the same file, so it doubles as a permanent
operations log.
events.meta.json beside it records the next event id and the log’s epoch.
If the log is ever truncated, restored from a copy or replaced, ids still
never repeat, and the epoch changes so the operator app starts its feed
afresh. Leave the file alone; deleting it only gives up that guarantee
until the next event.
Interaction log (Slack transparency channel)
GET /v1/process/protokoll?since=…&until=… returns every interaction of
the bridge in a window of at most 31 days, oldest first, from four sources:
- the event log, read in full (the console API only serves the latest 1,000 events);
- the EDIFACT exchange in both directions: what each message carried
(
inhalt, e.g.55062 Aktivierung SLS ab 01.11.26 · 11YN10001846-01S (EWE NETZ GmbH)), and for an acknowledgment itsverdict,grund,bezugand what that message carried (bezug_inhalt); - the mail ledgers: hand-written mails with their full text, Quittungs- Nachfass, corrections, Stammdaten requests;
- Erstkontakt mails, their replies and the Wiedervorlagen.
ts (UTC), quelle, typ, richtung (aus/ein/
intern), kanal, mp_id, partner, titel, text, referenz and a
stable id. The Slack channel shared with the customer is filled from it,
each entry posted exactly once, so the customer’s team can follow and learn
from every contact. Housekeeping events (API calls, restarts) are left
out. A naive since/until is read as German legal time.
Posting to Slack
The bridge posts new interactions itself, through a Slack incoming webhook. The customer’s team is external to the workspace and reads along via Slack Connect; an incoming webhook of an app in the host workspace may post into such a shared channel.- In Slack: create an app (“From scratch”) in the workspace, enable Incoming Webhooks and add one for the shared channel.
- In the console: Konfiguration → Slack-Protokoll, paste the URL into
slack_protokoll_webhook_url(stored as a secret, never shown again), and setslack_protokoll_startto where the feed should begin. - Check with
POST /v1/process/protokoll/slack {"dry_run": true}, which returns the messages without posting.
slack_protokoll_intervall_minuten
(default 60): one message per run, “Protokoll 10:04-10:58 · n
Interaktionen”, one line per interaction, hand-written mails and call
notes in full as a quote. Events that only repeat an EDIFACT or mail entry
are left out. The ledger slack_protokoll keeps the high-water mark and
moves only after Slack accepted a message, so an outage delays the log
and loses nothing.
An EDIFACT line says what the message carried, and its acknowledgments
in the same run join it as A✅ (APERAK) or C✅ (CONTRL), a negative
one with its reason:
POST /v1/process/protokoll/notiz (text, mp_id, art such as
Telefonat, person) and appear in the log and the channel.
Planned (not in this draft)
- Configurable communication channels (sFTP, AS4, e-mail, API push)
- Configurable order of pipeline steps
- Message / file format configuration