edmd Operator Guide

edmd operator guide: Energy Data Management daemon. Stores MSCONS meter readings, iMSys direct push for §41a real-time billing, Hampel-filter quality scoring (V01–V10 validation), virtual meters (§42b EnWG GGV Solarpaket I — GgvConstantAllocation CCI+ZG6 Beispiel 1 + GgvProportionalAllocation variable Beispiel 3, with Pos() cap per §42b Abs. 5), § 60 Abs. 2 MsbG substitution + forecasting, reading-order scheduling (Ablesesteuerung), MeterBillingPeriod (RLM Spitzenleistung + Gas Brennwert/Zustandszahl), Mehr-/Mindermengensaldo imbalance, BSI TR-03109 SMGW lifecycle, meterstore hot/cold tier, MCP server. meterstore-backed (PostgreSQL + Apache Iceberg), OIDC-secured, CloudEvents webhook.

edmd Operator Guide

edmd is the Energy Data Management daemon — the service that stores meter readings and computes billing-relevant energy quantities for downstream services.

Key responsibilities:

  • Store MSCONS meter readings (SLP and RLM) via the webhook from marktd.
  • Accept iMSys / SMGW direct push (15-min intervals in JSON, bypassing EDIFACT) for §41a real-time billing.
  • Run the Hampel-filter quality scorer and V01–V10 validation engine on all inbound interval data. Emit de.messwert.reading.quality.warning CloudEvents for grade C/F data.
  • Schedule and track reading orders (Ablesesteuerung) for the market roles LF, MSB, NB and ESA (an ESA may order value delivery under §60 Abs. 1 MsbG). Auto-creates INSRPT_STOERUNG orders when a WiM INSRPT PID 23001 Störungsmeldung arrives.
  • Compute and serve virtual meter time series (Sum, Residual, PvSelfConsumption, GgvConstantAllocation, GgvProportionalAllocation per §42b EnWG Solarpaket I GGV community solar) on demand.
  • Generate § 60 Abs. 2 MsbG annual forecasts (Jahresprognose — daily-average projection with automatic prior-year seasonal correction when the same window one year earlier has data) and prior-period substitute values for gap intervals.
  • Provide resampled Lastgang (hourly / daily / monthly / yearly buckets) and monthly Summenzeitreihe for MaBiS.
  • Provide a time-series query API for ERP and netzbilanzd.
  • Export BO4E Lastgang objects and Zeitreihe objects for ERP and API-Webdienste Strom consumers.
  • Compute MeterBillingPeriod — RLM Spitzenleistung (kW) and Gas Brennwert / Zustandszahl — required by netzbilanzd for Leistungspreis billing.
  • Accumulate Mehr-/Mindermengensaldo imbalance records per MaLo.
  • meterstore hot/cold tiering: meter_reads (and the non-authoritative esa_typ2_reads stream) are stored through the meterstore crate — a recent window in PostgreSQL and the settled history in Apache Iceberg V2 on S3/GCS/Azure, split by an explicit tiering watermark. Reads are version-resolved and tier-split; store.as_of(...) reproduces a past settlement. edmd's own business tables (receipts, corrections, confirmations, billing-period cache, reading orders, SMGW) stay in edmd's PostgreSQL pool.

The domain calculation logic is provided by the external metering library crate (zero I/O, no async):

Function / Type§-basisUsed in
gas_m3_to_kwh_hs(m3, hs, z)§25 Nr. 4 MessEV / DVGW G 685Gas direct push
aggregate(intervals, AggregationConfig)§ 12 StromNZVMeterBillingPeriod
classify_messtyp(intervals, source)§3/§ 12 StromNZV, §41a EnWGiMSys classification
compute_imbalance(actual, contracted)§ 13 StromNZVMehr-/Mindermengensaldo
score_intervals(intervals, config)Hampel quality scoring (A/B/C/F)
validate_intervals(intervals, config)§ 60 Abs. 2 MsbG (Plausibilisierung)V01–V10 validation engine
resample(intervals, config)§ 13 StromNZV, MaBiSHourly/daily/monthly resampling
compute_virtual_meter(rule, sources)§42b EnWG (GGV); Residuallast = ordinary supplyGGV community solar, Residuallast
project_annual_consumption(intervals, _)§ 60 Abs. 2 MsbG JahresprognoseAnnual consumption forecast
prior_period_substitutes(gap, _, _, prior, _)§ 60 Abs. 2 MsbGPrior-period gap filling
SmgwSession, ClsChannelBSI TR-03109, §14a EnWGSMGW lifecycle + CLS management
graph TB
    marktd["marktd :8180<br/>EventBus"]
    smgw["SMGW / iMSys<br/>(direct push)"]
    edmd["edmd :8380<br/>(this service)"]

    subgraph store["meterstore — hot/cold tiered store for meter_reads / esa_typ2_reads"]
        hot["Hot tier — PostgreSQL<br/>(recent window)"]
        cold["Cold tier — S3 / GCS / AzureDLS<br/>Iceberg V2 settled history"]
    end

    subgraph edmdpg["edmd PostgreSQL — business tables"]
        biz["meter_billing_periods
ablese_auftraege
direct_push_sessions
gdpr_deletions"]
    end

    erp["ERP / netzbilanzd<br/>mabis-syncd"]
    duckdb["DuckDB / Spark<br/>Trino / PyIceberg"]
    catalog["/api/v1/iceberg<br/>meterstore CatalogFacade<br/>(read-only · Cedar read-archive-olap)"]
    qa["quality engine<br/>score_intervals_f64<br/>AVX2/NEON auto-vectorise"]

    marktd -->|"de.mako.process.initiated (23001 INSRPT)<br/>de.mako.edifact.inbound (MSCONS)<br/>HMAC POST /webhook"| edmd
    smgw -->|"POST /api/v1/meter-reads/rlm/{malo_id}<br/>POST /api/v1/meter-reads/gas/{malo_id}"| edmd
    edmd --> qa
    qa -->|"grade A/B/C/F<br/>de.messwert.reading.quality.warning"| store
    edmd -->|"store_reads (version-resolved, tier-split)"| store
    edmd --> edmdpg
    edmd -->|"mounts"| catalog
    catalog -.->|"table schemas + locations"| cold
    hot -->|"tiering watermark<br/>(one-week settlement lag)"| cold
    erp -->|"GET /api/v1/lastgang Accept: arrow.stream<br/>→ Arrow IPC (10× faster than JSON)"| edmd
    erp -->|"GET /api/v1/billing-period/{malo_id}"| edmd
    erp -->|"POST /api/v1/query/sql (DataFusion)"| edmd
    duckdb -->|"ATTACH — metadata only"| catalog
    duckdb -->|"read Parquet directly<br/>(engine's own object-store creds)"| cold

Tenant is part of a reading's identity

meter_reads is keyed (tenant, malo_id, dtm_from, obis_code_norm).

Leaving tenant out of the key made two tenants holding the same MaLo-ID collide on one row, and the ingest upsert resolved that collision by overwriting the value and reassigning ownership (SET tenant = EXCLUDED.tenant) — silent cross-tenant data loss that every read path then hid, because reads filter on tenant and the row had already changed hands.

MSCONS stores what it validates

MSCONS is the primary meter-data message in German MaKo. Its interval readings are parsed from the ProcessCompleted event and written through the same batched store_reads path as every other family, so a MSCONS reading lands with the same primary key, unit and quality record as one that arrived by direct push.

An interval whose quantity will not parse is dropped and counted, not defaulted to zero: a zero-kWh interval asserts that no energy flowed, which a decode failure does not establish.

Both the receipt write and the interval store answer 500 on failure. marktd treats 2xx as delivered and will not redeliver, so answering 204 on a failed write would lose the process with only a log line to show for it.

Authentication is required, not defaulted

edmd and mabis-syncd refuse to start without an [oidc] section unless allow_insecure_no_auth = true is set explicitly.

The reason is what the absence would mean: OidcVerifier::disabled admits every request as dev-admin holding every market role, which satisfies every Cedar policy — including GDPR erasure and POST /api/v1/query/sql. Requiring the opt-out by name makes running unauthenticated a decision someone wrote down rather than one they reached by leaving a section out.

# Development only. Every request is admitted as dev-admin with all roles.
allow_insecure_no_auth = true

Internal services authenticate with a service key, not a JWT

Sibling services — einsd (fetching ¼h feed-in for §51), billingd, vertragd, portald — call edmd machine-to-machine, where minting a per-request OIDC JWT would be ceremony without a user behind it. edmd accepts a service key: a caller sends a static opaque (non-JWT) Authorization: Bearer <key>, and edmd matches it — in constant time — against a registered key, admitting the request as a synthetic service principal scoped to the deployment tenant with the roles the key declares.

[[oidc.service_keys]]
name  = "einsd"
key   = "env:EDMD_EINSD_SERVICE_KEY"
roles = ["nb"]          # what this caller may do (same Cedar roles as a JWT)
# sparte = "strom"      # optional Sparte scope

A JWT Bearer still takes the OIDC path unchanged — the branch is chosen by whether the token looks like a JWT — and with no [[oidc.service_keys]] entries edmd behaves exactly as before. The caller stores the same value as its edmd_api_key.

The MCP write tools carry the same role gate as REST

The /mcp surface is admitted by one blanket Cedar action (use-mcp, same-tenant, any role). That is right for the read tools, but the two destructive tools — trigger_substitution (Ersatzwertbildung) and trigger_jahresablesung (§40 campaign) — call the same cores as their REST endpoints, which require write-timeseries / write-reading-order (MSB/NB/admin). The MCP auth middleware inspects the tool name of each tools/call and enforces that same write action, so an LF-role token cannot escalate through MCP to a write it is refused on REST.

Outbound CloudEvents are signed

Every edmd-originated CloudEvent — direct-push stored/quality.warning, reading-order failed, confirmation-overdue, SMGW compliance and cert-expiry alerts, from both the request path and the background workers — is delivered through one emitter that adds an x-mako-signature: sha256=<hex> HMAC over the body when erp_webhook_secret is set. This is the counterpart to inbound_secret: the ERP receiver authenticates edmd's events exactly as edmd authenticates its inbound webhook. Without the secret the body is unsigned and the transport is the trust boundary.

The quality vocabulary and interval bounds are DB constraints

The 8-value QualityFlag vocabulary is a CHECK on every stored quality column (the authoritative meter_reads, plus the meter_billing_periods cache and the meter_read_corrections audit rows), pinned to metering::QualityFlag::CODES by a schema_code_guard test — a drifting literal fails the write, it is not read back as UNKNOWN. Every table holding an interval or period carries a forward-time CHECK (dtm_to > dtm_from / period_to >= period_from), so a zero-width or reversed span cannot be stored.

The reading's source (the IngestionSource provenance) is a meterstore coded attribute column (coded_column("source", IngestionSource::ALL…)), so that vocabulary is enforced by a DB CHECK on the authoritative store too, derived straight from the enum — the same guarantee, extended to a deployment-declared column. sender_mp_id and allocation_version carry open values (an MP-ID, a MaBiS version label) and stay unconstrained.

The cold tier's shape is meterstore's

edmd implements no archival logic of its own and defines no Iceberg partition spec: meter_reads is a meterstore table, configured rather than reimplemented here. edmd's TableConfig sets the partition step, archival step, settlement lag and cold-tier file size from its [archive] config, and declares tenant as the non-nullable identity column so two tenants' readings for one measuring point never merge. edmd then starts meterstore's maintenance loop (one per store, on maintenance_interval_secs), which is what actually advances the watermark — archiving settled windows from the hot PostgreSQL tier into Iceberg V2 and checking the tier invariant each cycle. Every read is version-resolved and tier-split before it reaches edmd.

GDPR Art. 17 is pseudonymisation, not a file rewrite

The cold tier is append-only (Iceberg V2, no deletion vectors), so Art. 17 over history cannot mean rewriting Parquet in place. It means destroying the subject mapping: each MaLo is enrolled as an erasure subject at ingest — a pseudonymous subject_ref stamped on every row it owns — and erasure deletes that mapping in meterstore's registry (meterstore_subject_map / meterstore_erasures). The readings survive in both tiers but become unattributable, so the § 60 Abs. 6 MsbG audit trail is preserved while the personal link is gone. No external Spark/Trino rewrite is scheduled, and there is no archive_deletion_pending obligation left over to discharge.

Queries are tenant-scoped

tenant is the store's identity column, so a MaLo is unique only within a tenant. Every typed read binds it: the repository scopes each series read with .column_eq("tenant", …), so two tenants' readings for one MaLo can never fold into a single series even in a store that holds both. The GDPR erasure subject is qualified the same way (tenant:malo), so erasing one tenant's MaLo cannot unlink another's. The structured archive endpoints inherit that scoping; /api/v1/archive/portfolio binds tenant in its GROUP BY too. Only the ad-hoc POST /api/v1/query/sql runs unscoped over the version-resolved relation — a query naming the raw, every-version relation is rejected with 403 so no statement can double-count corrected intervals, and it is single-tenant by deployment (cfg.tenant is written to every row).

GDPR erasure is one transaction

The request record, the subject-mapping erasure (SubjectRegistry::erase_in) and the derived-table deletes (billing periods, quality assessments, substitute-value log) commit together on edmd's pool — meterstore's registry lives in the same database, so one transaction encloses them all. An erasure either completed or it did not: a partial one reported as success would close out the Art. 17 request while personal data remained, indistinguishable from a MaLo that legitimately held no readings.

Cached billing periods are invalidated on ingest

meter_billing_periods is populated read-through. store_reads drops any cached aggregate the new readings fall inside, and the read-through write refreshes a stale row rather than skipping it.

Without that, a query issued mid-period caches a partial sum that is then served for that period indefinitely — including to billingd — because the read path prefers the cache.

Late corrections are meterstore's to resolve

A correction for an already-settled interval is not edmd's to reconcile against the cold tier. It is appended at a higher MSCONS version; meterstore routes it to the tier that owns the interval and applies latest-version-wins on read. The displacements the append returns drive edmd's § 60 audit — the durability of the corrected value in the cold tier is the tiering watermark's business, not a per-row archived flag edmd maintains.

Substitution is atomic

A substitute reading and its § 60 Abs. 6 MsbG audit row commit in one transaction. As two independent writes a failure part-way would leave billable SUBSTITUTED values in meter_reads with no record of who substituted them or why.

Corrections name their register

CorrectionRecord carries obis_code, and the update is keyed on (tenant, malo_id, dtm_from, obis_code_norm). Matching on dtm_to instead would rewrite every OBIS register at that timestamp, so correcting an import reading also overwrote the export one.

A correction advances allocation_version to CORRECTION (an initial ingest leaves it INITIAL). It rides as a meterstore attribute column, folded from the newest delivery, so a read-back read reports the version the currently-in-force value belongs to rather than the version of whatever landed first.

Tenant scoping is not optional on any statement

A MaLo-ID is not unique across tenants, so every statement touching a MaLo binds tenant. What each would otherwise do:

StatementConsequence of an unscoped query
GET /api/v1/billing-periods?tenant=Cedar authorises against the deployment tenant; honouring a caller-supplied parameter lets a principal cleared for its own tenant read any other tenant's portfolio
update_gas_qualityone tenant's calorific value rewrites every tenant's gas billing rows for the same MaLo-ID, changing invoiced kWh
GDPR erasureone tenant's Art. 17 request deletes another tenant's billing aggregates
quality rescoreone tenant's gap verdict is stamped onto another's readings

update_gas_quality takes tenant in its trait signature so the scope cannot be forgotten at a call site, and a ?tenant= that differs from the authorised tenant is 403.

Ingest is batched and validated

All ingest goes through store_reads, which routes the whole batch through one meterstore append. The append splits current from below-watermark intervals across the two tiers in one call and makes a partial failure impossible: the batch lands whole or not at all, which is what keeps the 100M-intervals/day target reachable.

store_reads carries the reading's provenance as meterstore attribute columns — declared once in build_stores, out of the merge key, folded from the newest delivery on read. allocation_version carries the MaBiS version a value belongs to, sender_mp_id carries § 60 Abs. 6 MsbG per-interval MSB attribution across a WiM switch, and source records the ingest door. A MeasurementSeries is a channel of numbers and cannot hold these, so the typed read recovers them through collect_resolved rather than reconstructing them with guessed defaults: a read-back MeterRead names its true source, reporting operator and allocation version, not MSCONS / None / INITIAL.

The § 60 audit row covers the interval it displaced

When a later delivery overwrites a stored interval, the displacement meterstore returns carries the interval end as well as its start, so the immutable § 60 Abs. 6 MsbG audit row (and the § 60 Abs. 2 confirmation it opens) spans [dtm_from, dtm_to) rather than collapsing to a zero-width [dtm_from, dtm_from).

A correction keeps the operator that scoped the value

The corrected interval is appended at a higher version so latest-version-wins supersedes the prior value. Its version scope is derived from the reporting operator (sender_mp_id), so the correction reads the operator back off the existing interval (collect_resolved) and re-uses it. Dropping it would move the correction into the tenant's scope — a different scope from the value it corrects — and meterstore's one-operator exclusion would then reject the correction as a conflicting claim on the interval instead of accepting the supersede.

Every ingest family validates before it stores

The IoT, RLM/gas direct-push and bulk-import paths all route through validate_and_annotate, which runs V01–V10 before storing and attaches each issue to the rows it names. One code path means a reading lands with the same quality record whichever door it came in by.

Validation annotates and never rejects. Whether an interval is billable is a separate decision from whether it is stored — discarding a suspect reading would destroy the evidence needed to resolve it. Findings land in quality_warnings; when a row already carries a session-level Hampel summary, the rule findings are added under a validation key rather than replacing it. A batch with any issue returns 202 Accepted with a validation block instead of 201.

Billable-only reads and the current reading are the storage layer's job

The read side never re-implements "billable". The § 60 Abs. 2 rule lives once in metering::QualityFlag::is_billable (every flag except FAULTY/UNKNOWN), and the aggregate reads — imbalance/MMM, on-the-fly billing periods — push that set into the scan through meterstore's SeriesQuery::quality_in, so a FAULTY interval never reaches a saldo rather than being filtered out in memory at each call site. Which qualities count as billable is edmd's rule to state; applying it to the scan is meterstore's job to execute.

The "current reading" (latest_read) resolves through SeriesQuery::latest_resolvedORDER BY from DESC LIMIT 1 at the storage layer — instead of loading the whole history and taking the maximum in memory.

GET /api/v1/feed-in/{malo_id}?from=&to= serves the same billable set to einsd's §51 Negativpreisregel: it returns the ¼h Einspeisung intervals (export-OBIS, ObisCode::is_einspeisung), each carrying its own billable flag, alongside a coverage_pct and billable_pct for the window. einsd overlays those intervals on its EPEX spot store to find the negative-price quarter-hours — and its §60 Abs. 2 gate reads exactly these two percentages, skipping the automatic reduction when the month's data is incomplete rather than under-reducing on gaps.

An unrecognised quality flag is refused at the boundary with 422. The column is CHECK-constrained, so binding an unrecognised value raw fails the insert; coercing it to UNKNOWN instead would silently strip the row from every billing aggregate.

stored_count reports rows that actually committed. Each ingest family writes one batched statement, so a batch lands whole or not at all.

One unit contract across the ingest families

Direct push parses units with MeasurementUnit::parse_scaled — the same machinery the IoT path uses — and cross-checks the result against the Sparte. A unit that is neither the Sparte's measured unit nor its billing unit is rejected.

This closed two mis-billing paths. The gas endpoint compared the unit string against "m3", which never matches the superscript "m³" that MeasurementUnit accepts, so a gas push in m³ was stored unconverted — roughly a tenfold under-count, and a value labelled "m3" reaching the electricity endpoint would be multiplied by the gas Brennwert.

brennwert_kwh_per_m3 is required when gas arrives in m³, on every path. §25 Nr. 4 MessEV requires a value determined by the recognised rules of technology, which a national average is not: an L-Gas supply area (Hs ≈ 8.8) billed at the H-Gas 10.55 is a systematic ~20 % over-charge, with nothing on the row recording that a default was used.

Substitute values do not overwrite measurements

POST /api/v1/meter-reads/{malo_id}/substitute requires an obis_code. It is part of the primary key, so a substitute filed without one lands on the empty-string register rather than against the reading it stands in for — leaving both rows in the table, and a 100 kWh reading plus its substitute billing 1099 kWh.

The substitution path first reads the existing intervals and marks every slot that already holds a billable value (QualityFlag::is_billable — every quality except FAULTY/UNKNOWN), then writes a substitute only for the slots that do not. § 60 Abs. 2 MsbG authorises an Ersatzwert where no usable measurement exists, not in place of one, so a window overlapping billable data leaves that data untouched and returns those intervals in skipped_measured.

Four methods are honoured: PriorPeriodAverage, LinearInterpolation, ZeroFill and LastValueCarryForward. Anything else is 422.

Each interval records the method that actually produced it in intervals[].method, which may differ from the request: a prior-period average with no matching reference slot degrades to carry-forward, then to zero, and linear interpolation with no closing value has no slope to follow. The response reports method_requested alongside the set of methods_applied — a § 60 Abs. 6 MsbG audit record naming a method that did not run would be a claim the value does not support.

ESA "Werte nach Typ 2" live in a separate store, unreachable from billing

ESA-delivered values (MSCONS PID 13027, "Werte nach Typ 2") are non-authoritative. Codeliste der Konfigurationen 1.4 Kap. 4.6 and WiM Strom Teil 2 §4 are explicit: these values have no bearing on Netznutzungs-, Bilanzkreis- or Mehr-/Mindermengenabrechnung, and on any divergence only the Kapitel-2 (Typ-1) values are relevant.

edmd enforces this as a schema decision, not a runtime filter. Typ-2 values land in their own table, esa_typ2_reads, and never in meter_reads:

  • The ingest forks at the source. edmd still subscribes to 13027 (it must receive the values), but on a 13027 delivery the handler forks on ESA_TYP2_PIDS, routes to Typ2Repository::store_typ2_reads, and returns — it never reaches the meter_reads upsert, validation, or substitution paths.
  • The billing read paths are structurally blind. Every billing consumer (billingd, netzbilanzd, mabis-syncd, invoicd) reads values through edmd endpoints that aggregate meter_reads only. There is no source/pid discriminator on the billing store that could leak a Typ-2 row by omission, because the row is not there at all.
  • No billing machinery hangs off the Typ-2 store. esa_typ2_reads has no meter_billing_periods aggregation, no meter_read_corrections audit, no substitute_value_log, no allocation_version, and no Iceberg archival. A Typ-2 value is stored as delivered and read back verbatim via GET /api/v1/esa/typ2/{malo_id}; it is never reconciled against, corrected, or substituted for a Typ-1 value.

The separation is a table boundary, not a session one. meter_reads and esa_typ2_reads are built as two tables of a single meterstore::MeterCatalog (build_stores), so they share one Iceberg SqlCatalog — one metadata pool — and one DataFusion session, while each keeps its own watermark, archiver and tiering (§15.3). Sharing the session costs no isolation: a billing query names meter_reads and a Typ-2 read names esa_typ2_reads, and neither can reach the other's rows.

The separation is guarded by schema_code_guard tests (the table must exist, and 13027 must be in ESA_TYP2_PIDS so the handler forks it) and by a real-Postgres test proving a 13027 delivery lands in esa_typ2_reads with meter_reads untouched — and a companion test proving the two stores, though they share one catalog, return only their own values.

meter_reads tiering and retention are meterstore's

meter_reads is not an edmd PostgreSQL table — it is a meterstore table spanning a hot PostgreSQL window and a cold Iceberg V2 history, split by a tiering watermark. edmd configures its shape (daily partition step, daily archival step, one-week settlement lag, tenant identity column); meterstore owns the mechanics:

  • Overlap and double-count safety. The hot tier enforces a per-partition exclusion, so a later delivery whose range overlaps a stored one cannot land twice. The version axis is resolved away on read, so a redelivery or correction appears once, carrying the value in force. edmd does not maintain this — it is the store's invariant.
  • Retention reclaims disk by tiering, not by DELETE. meterstore's maintenance loop (started by edmd, on maintenance_interval_secs) moves settled intervals past the watermark from PostgreSQL into object storage, so the hot tier stays bounded without a bulk DELETE competing with ingest for I/O. edmd implements no archival logic itself and keeps no archived flag; durability is the watermark's business.
  • Reproducing a past settlement. store.as_of(snapshot) pins a cold-tier Iceberg snapshot, and store.as_known_at(t) pins the row-level recorded_at axis across both tiers — either reads the history as it stood at a past instant, something a partition-drop model could not offer.

edmd keeps only its business tables in its own PostgreSQL pool (receipts, corrections audit, confirmations, billing-period cache, reading orders, SMGW); those are ordinary tables under edmd's control.

Rate limiting

Ingest endpoints accept unbounded batches, so an unthrottled client can saturate the write path for every other tenant. Two limiters apply:

LimiterKeyBounds
with_tenant_rate_limitauthenticated tenant, else peer addressany single caller
with_rate_limitglobaltheir sum

A global bucket alone lets one busy tenant consume the whole allowance and starve every other tenant on a shared deployment, so both are applied.

Rejections return 429 with a Retry-After header rounded up to whole seconds — rounding down would invite an immediate retry that is rejected again. The bucket key is a hash of the bearer token, never the token itself.

[rate_limit]
requests_per_second            = 500   # global sustained
burst                          = 1000  # metered ingest is bursty by nature
per_tenant_requests_per_second = 100

burst is deliberately above the sustained rate: an MSCONS batch or an IoT gateway flushing a backlog arrives all at once but fits comfortably within the hourly budget.

V07 — DST ambiguity

Germany repeats local 02:00–03:00 when CEST ends, so that day has 25 hours. A series converted from local time without carrying the UTC offset collapses the two passes into one and silently loses an hour of energy.

V07 fires when a series covers a whole local fall-back day but carries less than 25 hours. Anchoring on whole-day coverage is what makes it immune to truncated query windows: a series that merely starts inside the repeated hour is short, not corrupt.

Reading-order idempotency

ON CONFLICT DO NOTHING needs a unique index to fire on. With only the surrogate id primary key every redelivered INSRPT minted a fresh UUID and created a duplicate order. Two partial unique indexes back it:

IndexCovers
ablese_insrpt_unique (tenant, insrpt_process_id)INSRPT-triggered orders
ablese_scheduled_unique (tenant, malo_id, anlass, geplant_am)campaign/scheduled orders, which carry no process id

Port layout

┌────────────────────────────────────────────────────────────────────────────┐
│  edmd  :8380                                                                │
│                                                                            │
│  POST /webhook                              ← marktd CloudEvents          │
│  GET  /api/v1/deliveries/{malo_id}          ← BO4E Energiemenge           │
│  GET  /api/v1/billing-period/{malo_id}      ← MeterBillingPeriod          │
│  GET  /api/v1/billing-periods               ← collection (mabis-syncd)    │
│  GET  /api/v1/imbalance/{malo_id}/{y}/{m}   ← Mehr-/Mindermengen          │
│  GET  /api/v1/lastgang/{malo_id}            ← BO4E Lastgang               │
│  GET  /api/v1/feed-in/{malo_id}             ← ¼h Einspeisung (§51 einsd)  │
│  GET  /api/v1/zeitreihe/{malo_id}           ← BO4E Zeitreihe              │
│  GET  /api/v1/lastgang/{malo_id}/resampled  ← hourly/daily/monthly        │
│  GET  /api/v1/summenzeitreihe/{malo_id}     ← MaBiS monthly aggregate     │
│  GET  /api/v1/forecast/{malo_id}            ← § 60 Abs. 2 MsbG Jahresprognose   │
│  GET  /api/v1/gas-quality/{malo_id}         ← Brennwert + Zustandszahl    │
│  GET  /api/v1/corrections/{malo_id}         ← bitemporal audit trail      │
│  GET  /api/v1/quality-assessments/{malo_id} ← Hampel rescore history      │
│  GET  /api/v1/sharing/{community_id}/alloc  ← §42c Energy Sharing VZW     │
│  GET  /api/v1/sharing/readiness             ← §42c delivery readiness    │
│                                                                            │
│  ── iMSys direct push ────────────────────────────────────────────────── │
│  POST /api/v1/meter-reads/rlm/{malo_id}     ← Strom 15-min direct push   │
│  POST /api/v1/meter-reads/gas/{malo_id}     ← Gas direct push (m³→kWh_Hs)│
│                                                                            │
│  ── §14a SMGW session registry (MsbG §21c / BSI TR-03109) ─────────────  │
│  PUT  /api/v1/smgw/{malo_id}                ← upsert SmgwSession          │
│  GET  /api/v1/smgw/{malo_id}                ← session + recent issues     │
│  GET  /api/v1/smgw                          ← fleet list with issue counts│
│  GET  /api/v1/smgw/compliance               ← read-only compliance scan   │
│  POST /api/v1/smgw/compliance/scan          ← side-effecting fleet sweep  │
│    (background: daily cert-expiry worker → de.messwert.smgw.cert.expiry_   │
│     warning at 90/30/7 days before SMGW_CERT_ABLAUFDATUM, once per tier)   │
│                                                                            │
│  ── Reading order scheduling (Ablesesteuerung) ──────────────────────── │
│  POST|GET /api/v1/reading-orders            ← schedule / list orders     │
│  GET  /api/v1/reading-orders/{id}           ← order detail               │
│  PUT  /api/v1/reading-orders/{id}/complete  ← record reading result       │
│  PUT  /api/v1/reading-orders/{id}/cancel    ← cancel                     │
│  POST /api/v1/reading-orders/campaign       ← bulk Jahresablese-Kampagne  │
│                                                                            │
│  ── Quality scoring ──────────────────────────────────────────────────── │
│  POST /api/v1/quality-score/{malo_id}       ← retroactive Hampel rescore  │
│                                                                            │
│  ── Analytical / OLAP (over meterstore's resolved relation) ───────────  │
│  GET  /api/v1/archive/status                ← tiering / store stats      │
│  GET  /api/v1/archive/olap/{malo_id}        ← MMM aggregation (OLAP)     │
│  GET  /api/v1/archive/portfolio             ← portfolio-level OLAP        │
│  GET  /api/v1/archive/timeseries/{malo_id}  ← historical time-series      │
│  POST /api/v1/query/sql                     ← arbitrary DataFusion SQL    │
│                                                                            │
│  ── Iceberg REST catalog · read-only · meterstore CatalogFacade ───────  │
│  GET  /api/v1/iceberg/v1/config             ← Cedar read-archive-olap    │
│  GET  /api/v1/iceberg/v1/namespaces[/{ns}/tables[/{table}]]              │
│    (DuckDB / Spark / Trino / PyIceberg attach for schema; mutating       │
│     routes → 405; engines read Parquet with their own object-store creds) │
│                                                                            │
│  ── § 60 Abs. 2 MsbG + §22 EnWG ──────────────────────────────────────── │
│  GET  /api/v1/confirmations                 ← Schätzwert-Bestätigungen    │
│  GET  /api/v1/netzverlust                   ← indicative grid-loss balance│
│                                                                            │
│  ── GDPR ─────────────────────────────────────────────────────────────── │
│  DELETE /api/v1/gdpr/erasure/{malo_id}      ← Art. 17 DSGVO erasure      │
│                                                                            │
│  GET  /metrics                              ← Prometheus metrics          │
│  GET  /health/live  /health/ready                                         │
│  POST|GET /mcp      ← MCP Streamable HTTP (LLM tooling)                   │
└────────────────────────────────────────────────────────────────────────────┘

§42c Energy-Sharing readiness

GET /api/v1/sharing/readiness answers the delivery half of §42c eligibility: which delivery points are actually producing the quarter-hour series that §42c Abs. 1 EnWG requires.

ParameterDefaultMeaning
from / tolast 30 daysRFC 3339 observation window
malo_idsevery MaLo with readingsComma-separated candidate list
min_coverage_pct95.0Share of expected quarter-hour slots required

Per point it returns DELIVERING · INSUFFICIENT · ABSENT plus the detected interval length, coverage, and a required_action.

Capability and delivery are separate questions. marktd GET /api/v1/melos/{id}/sharing-eligibility answers whether the installed metering qualifies; this endpoint answers whether values are arriving. The distinction is the point — a meter that supports Zählerstandsgangmessung but has none configured is capable but not delivering, and needs a configuration order rather than an iMSys rollout. Collapsing both into one boolean hides exactly the state an operator must act on.

Resolution is derived per point from the median of dtm_to - dtm_from (metering::classification::detect_interval_length) — meter_reads stores no resolution column. The shared rule set lives in metering::sharing.


Inbound event routing

ce_typemakopidAction
de.mako.process.completedMSCONS setStore meter readings
de.mako.process.completed55001 (GPKE Anmeldung)Auto-create LIEFERBEGINN reading order (GPKE Beginn-/Schlussablesung)
de.mako.process.completed55004 / 55007 (GPKE Abmeldung / Beendigung der Zuordnung)Auto-create LIEFERENDE reading order (GPKE Beginn-/Schlussablesung)
de.mako.process.initiated23001 (INSRPT Störungsmeldung)Auto-create INSRPT_STOERUNG reading order (WiM Störungsmeldung)
de.mako.process.initiated23003 / 23004 / 23008 (INSRPT Technische Änderung / Gerätebefund)Auto-create SONDERABLESUNG reading order
de.mako.process.initiated23005 / 23009 (WiM Gas INSRPT)Auto-create SONDERABLESUNG reading order
anything else204 No Content (ignored)

MSCONS PIDs handled

PIDDescriptionDirection
13005Lastgang Messwerte StromNB → LF
13006Zählerstand / Ersatzwert StromNB → LF
13007Gasbeschaffenheitsdaten — Brennwert + ZustandszahlNB → LF
13013Allokationsliste Gas MMMA (GaBi Gas 2.1)NB → LF
13015Lastgang Summenzeitreihe SLP StromNB → LF
13016Ausfallarbeit StromNB → LF
13017Zählerstand Strom — Ablese-ÜbermittlungNB → LF
13018Messwerte Strom — korrigierte WerteNB → LF
13019Netzverluste StromNB → LF
13020–13023, 13026Redispatch 2.0 ZeitreihenNB / ÜNB → LF
13025Lastgang Gas — Zustandsmengen / EnergiemengenNB → LF
13027Werte nach Typ 2 (ESA, non-authoritative — routed to esa_typ2_reads, never meter_reads)MSB → ESA

PID 13007 (Gasbeschaffenheitsdaten): When a de.mako.process.completed event arrives for PID 13007, edmd automatically extracts brennwert_kwh_per_m3 (from QTY+Z08) and zustandszahl (from QTY+Z10) and populates meter_billing_periods. This makes Gas NNE billing possible without manual data entry.

To request Gas quality data on-demand, use makod command geli.gas.datenabruf.anfragen (dispatches ORDERS 17103 to the GNB, 10-Werktage response deadline).


iMSys direct push (§41a)

For iMSys / SMGW customers with 15-min interval meters, edmd accepts direct JSON push bypassing the EDIFACT/MSCONS pipeline entirely. This is required for §41a EnWG dynamic tariffs where the MSCONS round-trip adds 15–60 min latency.

POST /api/v1/meter-reads/rlm/{malo_id}
Content-Type: application/json

{
  "session_id": "SMGW-SN-00112233-20260713T0600Z",
  "source": "SMGW",
  "obis_code": "1-0:1.8.0",
  "intervals": [
    { "from": "2026-07-13T00:00:00Z", "to": "2026-07-13T00:15:00Z", "value": "2.345", "unit": "kWh" },
    { "from": "2026-07-13T00:15:00Z", "to": "2026-07-13T00:30:00Z", "value": "2.412", "unit": "kWh" }
  ]
}

Gas variant (/api/v1/meter-reads/gas/{malo_id}): supply unit = "m3" plus brennwert_kwh_per_m3 and optionally zustandszahl; edmd converts m³ × Hs × Z to kWh_Hs before storing.

The response includes a quality report (see below). HTTP 201 = clean data; 202 = stored with quality warnings.

Idempotent on session_id — re-submitting the same key returns 200 with the original result.


IoT meter ingest (LoRaWAN, M-Bus, REST heat meters)

POST /api/v1/meter-reads/iot/{malo_id}

Heat and water submetering points usually have no Smart-Meter-Gateway, so a purely MSCONS pipeline cannot see them at all. This endpoint is the ingest path for LoRaWAN uplinks, wM-Bus/M-Bus concentrators and REST-capable heat meters.

Why it matters commercially. HeizkostenV §5 Abs. 3 requires every non-remote-readable device to be retrofitted or replaced by 31 December 2026 (subject to the Satz 2 hardship exception, and distinct from the §5 Abs. 4 smart-meter-gateway deadline of 31 December 2031), and §6a requires a monthly consumption message to each user. §12 Abs. 1 backs both with a 3 % Kürzungsrecht — Satz 2 for a missing remote-readable device and, separately, Satz 3 for information that is "nicht oder nicht vollständig" supplied. Missing an ingest path here is a direct revenue deduction.

POST /api/v1/meter-reads/iot/62345678906
Content-Type: application/json

{
  "sparte": "WAERME",
  "unit": "KWH",
  "session_id": "70B3D57ED0012345:4711",
  "transport": "LORAWAN",
  "device_id": "70B3D57ED0012345",
  "obis_code": "6-0:1.0.0",
  "eichung_bis": "2027-12-31",
  "raw_payload": "AwAAECcAAA==",
  "intervals": [
    { "from": "2026-07-13T00:00:00Z", "to": "2026-07-13T01:00:00Z", "value": "4.120" }
  ]
}

The payload must already be decoded

edmd deliberately does not decode wM-Bus/OMS frames. German submetering payload specs are gated in practice: ista's protocol is proprietary, Kamstrup's byte-level wireless document (5512-1034) exists nowhere in public and its AES keys require a mykamstrup.com login plus serial number or an invoice copy, and Itron formally answered "No" to "Is the payload structure available for decoding?" on its own LoRa Alliance device questionnaire for the Cyble 5. Apator's APT-WMBUS-NA-1 manual states outright that "the application layer is Apator proprietary". Every working open-source decoder for these vendors is reverse-engineered rather than spec-derived.

Decoding belongs at the network server or vendor codec, which holds the device keys. edmd retains raw_payload verbatim: network-server codecs are mutable and carry no version on the uplink, so a stored value can only be re-derived from the original frame.

Idempotency

session_id is required; there is no timestamp-derived fallback. Use devEUI:fCnt for LoRaWAN or the telegram access number for OMS/M-Bus. A committed session replays as 200 already_committed. A batch in which nothing landed is not committed, so it stays retryable.

Unit and Sparte

A Sparte has two units and the endpoint accepts either:

sparteas measuredas billed
STROMkWhkWh
GASkWh
WAERMEkWhkWh
WASSER

A gas meter registers volume, so a raw gas uplink arrives in m³ and brennwert_kwh_per_m3 is required; zustandszahl defaults to 1.0. The calorific value varies by supply area and month, so it is not defaulted. Submit unit = KWH to supply pre-converted values. The response reports unit_submitted, unit_stored and converted.

Anything outside those two units is a decode error and 422s, including a WASSER reading in kWh.

The conversion rests on the Eichrecht exceptions to §33 Abs. 1 MessEG, which permits only measured values: §25 Nr. 4 MessEV covers the Brennwert itself and §25 Nr. 7 MessEV a value formed as a "Produkt" of measured values. DVGW G 685 is the anerkannte Regel der Technik referenced by Nr. 4.

Unit strings are liberal, storage is canonical. kWh, Wh, MWh, GWh, GJ and MJ are all accepted for energy, /m3/cbm and l/ltr/liter for volume — German heat meters ship with kWh, MWh or GJ registers depending on the ordered variant, and water submeters commonly report litres. Values are rescaled to kWh/m³ before storage using exact rational factors (GJ→kWh is 2500/9, a repeating decimal), so 3.6 GJ stores as exactly 1000 kWh.

Negative values are rejected. BDEW requires quantities to be positive or zero; direction belongs in the OBIS code, so a negative here is a decode error.

Calibration (Eichfrist)

An expired Eichfrist produces a warning, never a rejection. §37 Abs. 1 Satz 1 Nr. 1 MessEG bars use of the Messgerät once the Eichfrist has run, and §33 Abs. 1 MessEG then bars the resulting values, since a device used contrary to §37 was not "bestimmungsgemäß verwendet". BGH VIII ZR 112/10 holds that in civil billing such a reading loses only its Vermutung der Richtigkeit and remains usable with the burden of proof shifted. Public-law Gebührenabrechnung is stricter (BayVGH 20 B 21.2421 requires estimation), which is a billing-side decision.

§37 Abs. 2 also ends a Eichfrist early on defect or tampering, so an expiry date alone is not the whole eichrechtliche validity test.

Note that §34 Abs. 2 MessEV ends a Eichfrist of at least a year only "mit dem Ende des Jahres, in dem die Frist rechnerisch endet", so callers send YYYY-12-31. Leave eichung_bis unset for Heizkostenverteiler. They have no Eichfrist because they are not Messgeräte under MessEG at all — "Heizkostenverteiler" appears nowhere in MessEV, neither in Anlage 1 nor in the Eichfristen of Anlage 7. HeizkostenV §5 Abs. 1 admits them through a conditional clause ("soweit nicht eichrechtliche Bestimmungen zur Anwendung kommen") that requires expert-body confirmation against EN 834 / EN 835 instead of Eichung.

Note also that no German law prescribes a unit for heat meters. MID Annex VI (MI-004) contains no units clause, and EN 1434-1 cl. 6.3.1 permits "Joules, Watt-hours or decimal multiples of those units" — a GJ meter is exactly as compliant as a kWh one. This is why the endpoint accepts GJ and MJ rather than assuming kWh. The one hard kWh mandate is HeizkostenV §6a Abs. 2 Nr. 1, and it governs the monthly consumption message, not the meter.

Status codes

CodeMeaning
201All intervals stored, no warnings
202Stored, with calibration warnings and/or per-interval rejections
200session_id already committed — no-op replay
422Unknown sparte/unit, unit/Sparte mismatch, or nothing storable

Kafka batch ingest (head-end systems)

Head-end systems and LoRaWAN network servers that manage large gateway fleets stream reading batches instead of pushing per-gateway HTTP. The optional Kafka consumer drains such a topic through the same path as every other ingest: V01–V10 validation, quality-warning annotation, PK-idempotent upsert with the § 60 Abs. 6 MsbG overwrite audit trail.

[kafka_ingest]
enabled           = true
bootstrap_servers = "kafka-1:9092,kafka-2:9092"
topic             = "edmd.meter-reads"     # default
group_id          = "edmd-ingest"          # default

One JSON document per Kafka record, the same batch shape the bulk REST endpoint accepts:

{
  "malo_id": "51238696780",
  "sparte": "STROM",
  "source": "IOT_PUSH",
  "intervals": [
    {"from": "2026-07-01T00:00:00Z", "to": "2026-07-01T00:15:00Z",
     "value_kwh": "1.25", "quality": "MEASURED", "obis_code": "1-0:1.8.0"}
  ]
}

Optional per-message authentication: set message_hmac_secret (supports "env:VAR") in [kafka_ingest] and every record must carry an x-mako-signature header (sha256=<hex> over the record value, the platform's webhook signing scheme); forged or unsigned records are skipped like poison pills — never stored. Without the secret, the topic ACL is the trust boundary: restrict produce rights to the head-end system.

Delivery is at-least-once: offsets commit only after the batch is stored, and a replay is idempotent on the primary key (a value-changing replay leaves a correction-audit row like any other redelivery). Unparseable records are logged and skipped so a poison pill cannot wedge the partition; storage failures abort without committing and the batch is redelivered. A fresh consumer group starts at the earliest offset — readings produced before the group's first commit are a backlog to drain, not a feed to tail.

The path runs the full pipeline against krafka's in-process FakeBroker (test-broker feature) over an actual TCP socket — produce → group join → fetch → V01–V10 → audited store → offset commit, poison pill included, with no Kafka container. A dedicated end-to-end suite for it is follow-up work (the previous one was removed with the embedded-Iceberg storage layer it depended on).

Hampel-filter quality scoring

edmd runs the Hampel filter (window k=3, threshold t=3.0, MAD × 1.4826 robust σ) on every inbound interval batch via metering::score_intervals_f64.

Thresholds are media-awareQualityConfig::for_sparte. The k=3/t=3.0 defaults suit 15-minute RLM electricity profiles, which are noisy and rarely flat. Heat and water profiles are dominated by long legitimate zero runs and need two wider tolerances:

  • Zero runs. Electricity has a standby floor; water and heat do not, and a vacant flat reads zero for months. max_zero_run_allowed is 2 for Strom, 48 for Gas, 720 for Wärme/Wasser.
  • Sigma floor. Across a flat window the median absolute deviation is 0, so t × σ is 0 and every nonzero value scores as an outlier. min_sigma floors the scale estimate, making the test "deviates by more than the floor".

On the IoT path an outlier is stored as PRELIMINARY (MSCONS Z84, vorläufiger Wert) rather than discarded: measured, not yet confirmed. FAULTY would assert a defect the filter cannot establish, and § 60 Abs. 2 MsbG substitution is a downstream decision. This function:

  • Converts Decimal quantities to f64 once per batch — lossless for kWh ≤ 10¹³
  • Uses tight loops over contiguous f64 slices that auto-vectorise to AVX2 (4×f64/cycle) on x86-64 and NEON (2×f64/cycle) on AArch64 at opt-level = 2
  • Returns a full QualityReport with gap positions, outlier timestamps, zero-run length, coverage %, and grade A/B/C/F — not just a scalar score

The Decimal path is kept for exact billing arithmetic; quality scoring uses f64 because outlier detection doesn't require accounting precision.

Quality checks

CheckDetectionGrade impact
Gap detectionAdjacent intervals where to[i] ≠ from[i+1]Warnings
Consecutive zero-runMax run of zero-value intervalsWarnings if run > max_zero_run_allowed (Strom 2 · Gas 48 · Wärme/Wasser 720)
Hampel outliers|x[i] − window_median| > 3.0 × 1.4826 × MADWarnings
Spike detectionvalue > 10 × window_median of neighboursWarnings
Interval consistencyMixed SLP/RLM interval durationsWarnings
Coverageaccepted / expected × 100 %Grade degrades if < 99 %

Quality grades

GradeMeaningBilling action
ANo anomaliesNormal billing run
BMinor issuesProceed with note
CSignificant issuesManual review recommended
FUnusableBlock billing run

Any validation finding (grade C or F) emits a de.messwert.reading.quality.warning CloudEvent to the ERP webhook. In agentd that event triggers the msb-history-agent (LanceDB RAG indexing), the meter-data-agent (grade-F investigation), and the replacement-value-agent (§ 60 Abs. 2 MsbG Ersatzwertbildung via edmd trigger_substitution).

Retroactive rescoring

To re-score existing historical data (e.g. after a MSCONS delivery of old data, or after a firmware fix):

POST /api/v1/quality-score/{malo_id}?from=2026-01-01T00:00:00Z&to=2026-07-01T00:00:00Z

Returns { malo_id, rows_rescored, warnings_found, grade }.


Reading order scheduling (Ablesesteuerung)

edmd is the scheduling authority for all three market roles:

RoleTypical anlass values
LFLIEFERBEGINN, LIEFERENDE, ZWISCHENABLESUNG, JAHRESABLESUNG
NBJAHRESABLESUNG, SPERRUNG, ENTSPERRUNG
MSBSONDERABLESUNG, INSRPT_STOERUNG, ISMS_AUSLESUNG

§ 60 Abs. 2 MsbG — Schätzwert-Bestätigungsschleife

Jedes gespeicherte Intervall mit Qualität ESTIMATED/SUBSTITUTED öffnet eine Bestätigungspflicht in estimated_read_confirmations — der MSB schuldet einen plausibilisierten realen Wert. Die Auflösung geschieht automatisch, sobald für denselben Slot (MaLo, dtm_from, Register) ein MEASURED- oder CORRECTED-Wert eintrifft (Ingest oder Korrekturpfad). Der tägliche Worker ([confirmation], Standard aktiv) eskaliert offene Einträge nach deadline_weeks (Standard 8 — angelehnt an das MaBiS-BKA-Korrekturfenster; eine gesetzliche Frist existiert nicht) auf UEBERFAELLIG und emittiert de.messwert.reading.confirmation.overdue. Abfrage: GET /api/v1/confirmations?status=UEBERFAELLIG.

[confirmation]
enabled        = true
deadline_weeks = 8

INSRPT → reading order automation (WiM Störungsmeldung)

When edmd receives de.mako.process.initiated for PID 23001 (INSRPT Störungsmeldung), it automatically creates an INSRPT_STOERUNG reading order:

  • geplant_am = tomorrow
  • ausfuehrt_bis = + 7 calendar days (covers 5 Werktage WiM Strom window)
  • auftraggeber_rolle = MSB
  • Idempotent on insrpt_process_id

This eliminates the risk of billing a zero-reading period after a device swap — the field-service scheduler is unblocked immediately on INSRPT arrival, without any ERP action required.


MCP server tools

edmd exposes an MCP server at /mcp with the following tools:

ToolDescription
get_timeseriesMeter data time-series for a MaLo in a date range
get_imbalanceMehr-/Mindermengen imbalance report
get_billing_periodMeterBillingPeriod (arbeitsmenge, spitzenleistung, brennwert)
get_device_historyMSB device history text for LanceDB RAG indexing
get_quality_warningsHampel-filter quality warnings (grade A/B/C/F)
list_reading_ordersAblesesteuerung orders for a MaLo
list_overdue_reading_orders§40 EnWG compliance gaps
trigger_jahresablesungLaunch or preview annual reading campaign
trigger_substitutionGenerate + store § 60 Abs. 2 MsbG Ersatzwerte for a gap window
get_correction_historyBitemporal correction audit trail (§ 60 Abs. 6 MsbG)
validate_timeseriesRun V01–V10 validation on stored meter reads
get_quality_assessmentsPer-batch quality history (§ 60 Abs. 6 MsbG)
get_summenzeitreiheMonthly aggregated kWh for MaBiS
get_annual_forecast§ 60 Abs. 2 MsbG Jahresprognose
get_gas_qualityPID 13007 Brennwert + Zustandszahl

Prompts: analyze-consumption, submit-mscons, quality-assessment, jahresablesung-workflow, reading-order-lifecycle.



BO4E Energiemenge deliveries export

GET /api/v1/deliveries/{malo_id}?from=RFC3339&to=RFC3339

Returns all stored meter readings for a MaLo as a BO4E Energiemenge array — the canonical business object for metered energy quantities, identical in structure to what MSCONS messages carry per OBIS register per interval.

This endpoint is the primary data feed for ERP billing-import pipelines and Mehr-/Mindermengen reconciliation tools. The response is a hard-typed BO4E contract — not a raw database dump — so ERP systems can consume it without parsing EDIFACT format-version details.

curl -s "http://edmd:8380/api/v1/deliveries/10001234567?from=2026-01-01T00:00:00Z&to=2026-04-01T00:00:00Z" \
  -H "Authorization: Bearer <token>" | jq '.[0] | {
    obisKennzahl,
    menge_wert: .menge.wert,
    menge_einheit: .menge.einheit,
    zeitraum_start: .zeitraum.startdatum,
    zeitraum_ende:  .zeitraum.enddatum
  }'

Response shape (one Energiemenge per stored interval read):

[
  {
    "_typ": "ENERGIEMENGE",
    "obisKennzahl": "1-0:1.29.0",
    "menge": {
      "wert": 42.375,
      "einheit": "KWH"
    },
    "zeitraum": {
      "startdatum": "2026-01-01",
      "startuhrzeit": "00:00:00+00:00",
      "enddatum":    "2026-01-01",
      "enduhrzeit":  "00:15:00+00:00"
    }
  }
]

Filtering. Both from and to are optional; omitting them returns all stored readings. Times are RFC 3339 UTC; use ?from=2026-01-01T00:00:00Z for calendar-day boundaries.

Grouping. One Energiemenge object per stored interval row. For grouped aggregate views (one object per register with all intervals nested), use GET /api/v1/lastgang/{malo_id} instead.

Cedar action: read-timeseries


MeterBillingPeriod

The MeterBillingPeriod struct contains the billing-relevant quantities for a MaLo over a calendar billing period:

FieldTypeSource
spitzenleistung_kwOption<f64>RLM: highest 15-min demand in kW
brennwert_kwh_per_m3Option<f64>Gas: calorific value (Brennwert H)
zustandszahlOption<f64>Gas: state conversion factor
total_kwhf64Consumption sum over billing period

Used by netzbilanzd to compute the Leistungspreisanteil (kW × kW-price) and Gas quantity conversion (m³ × Brennwert × Zustandszahl = kWh).


BO4E Zeitreihe export

GET /api/v1/zeitreihe/{malo_id}?from=RFC3339&to=RFC3339

Returns the meter time series as a BO4E Zeitreihe object array — the generic time-series format used by API-Webdienste Strom consumers. Unlike Lastgang, Zeitreihe carries commodity metadata (medium, messart, einheit) without interval-specific fields (zeit_intervall_laenge, OBIS structure). One Zeitreihe is returned per distinct OBIS register.

curl -s "http://edmd:8380/api/v1/zeitreihe/10001234567?from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z" \
  -H "Authorization: Bearer <token>" | jq '.[0] | {
    bezeichnung,
    medium,
    messart,
    einheit,
    werte_count: (.werte | length)
  }'

Response shape:

[
  {
    "bezeichnung": "Zeitreihe MaLo 10001234567 OBIS 1-0:1.29.0",
    "medium":      "STROM",
    "messart":     "MITTELWERT",
    "einheit":     "KWH",
    "werte": [
      {
        "zeitraum": {
          "startdatum": "2026-01-01", "startuhrzeit": "00:00:00+00:00",
          "enddatum":   "2026-01-01", "enduhrzeit":   "00:15:00+00:00"
        },
        "wert": 1.234,
        "status": "ABGELESEN"
      }
    ]
  }
]

When to use Zeitreihe vs. Lastgang. Use Lastgang when the consumer needs interval metadata (register, sparte, interval length) for structured RLM/SLP processing. Use Zeitreihe when the consumer is an API-Webdienste Strom client that expects the generic time-series contract, or when the commodity context (medium, messart) is more relevant than the EDIFACT structure.


BO4E Lastgang export

GET /api/v1/lastgang/{malo_id}?from=RFC3339&to=RFC3339

Returns the meter time series as a BO4E Lastgang object array, suitable for direct import into ERP systems and for the API-Webdienste Strom interface. Readings are grouped by OBIS-Kennzahl — one Lastgang per distinct measurement register.

curl -s "http://edmd:8380/api/v1/lastgang/10001234567?from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z" \
  -H "Authorization: Bearer <token>" | jq '.[0] | {
    sparte,
    obis_kennzahl,
    zeit_intervall_laenge,
    werte_count: (.werte | length)
  }'

Response shape (one element per OBIS register):

[
  {
    "sparte": "STROM",
    "obis_kennzahl": "1-0:1.29.0",
    "zeitIntervallLaenge": { "wert": 15, "einheit": "VIERTELSTUNDE" },
    "werte": [
      {
        "zeitraum": {
          "startdatum": "2026-01-01", "startuhrzeit": "00:00:00+00:00",
          "enddatum":   "2026-01-01", "enduhrzeit":   "00:15:00+00:00"
        },
        "wert": 1.234,
        "status": "ABGELESEN"
      }
    ]
  }
]

Interval detection. The zeitIntervallLaenge is inferred from the first consecutive read pair (15 min → VIERTELSTUNDE, 60 min → MINUTE(60), 1440 min → TAG). RLM reads are typically 15-minute intervals.

Point-in-time reconstruction — ?as_of=RFC3339. § 60 Abs. 6 MsbG lets an auditor reconstruct the exact billing basis as it stood at a past instant. Adding &as_of=2026-02-05T00:00:00Z reads the series through meterstore's transaction-time axis (store.as_known_at): version resolution runs under a recorded_at ceiling, so a correction delivered after that instant — and an interval first stored after it — are both invisible, and the values returned are the ones that were in force then. This is a true bitemporal read, not a value overlay: it reconstructs the set of readings, so a later-inserted interval no longer leaks into a historical view. It works across both tiers, so recent settlements reconstruct as faithfully as archived ones. A malformed as_of is a 400, never a silent fall-back to current values. GET /api/v1/zeitreihe/... honours ?as_of= identically; the non-authoritative ESA Typ-2 stream does not (it is never corrected). The correction log itself — who changed what, when and why — remains queryable via GET /api/v1/corrections/{malo_id}.

OBIS codes. Each MeterRead carries an optional obis_code field populated from the MSCONS PIA segment. Common values:

OBISMeaningSparte
1-0:1.8.0Active energy import, cumulativeStrom
1-0:1.29.0Active energy max demand (Spitzenleistung)Strom RLM
7-20:3.0.0Gas volume unconverted (m³)Gas
7-20:15.0.0Gas energy (kWh, after Brennwert conversion)Gas

Ablesesteuerung — Reading Order API

All three market roles schedule meter readings through the same edmd API. Reading orders are stored in ablese_auftraege and linked to auftrag_positionen (O2C) or MaKo process IDs (makod-triggered).

sequenceDiagram
    autonumber
    participant LF as vertragd (LF)
    participant edmd
    participant MSB as MSB / iMSys
    participant billingd

    LF->>edmd: POST /api/v1/reading-orders<br/>{ malo_id, anlass: "LIEFERBEGINN",<br/>  auftraggeber_rolle: "LF",<br/>  geplant_am: lieferbeginn_date }
    edmd-->>LF: 201 { id, status: "OFFEN" }

    Note over MSB: Field technician or iMSys<br/>auto-reads on geplant_am

    MSB->>edmd: PUT /api/v1/reading-orders/{id}/complete<br/>{ zaehlerstand_kwh: 12345.678 }
    edmd-->>MSB: 204 No Content

    Note over edmd: status = AUSGEFUEHRT<br/>emits de.messwert.ablesung.ausgefuehrt

    edmd->>billingd: de.messwert.ablesung.ausgefuehrt CloudEvent
    Note over billingd: Schlussrechnung can now<br/>use actual reading value

Anlass types

AnlassTriggered byPurpose
LIEFERBEGINNvertragd after NB confirms LieferbeginnBilling cutoff for outgoing supplier
LIEFERENDEvertragd on KündigungBilling cutoff for final invoice
JAHRESABLESUNGNB background job or ERP§40 EnWG annual billing accuracy
ZWISCHENABLESUNGLF or ERPOn-demand (tariff change, billing dispute)
EINZUGNB on customer move-in
AUSZUGNB on customer move-out
SPERRUNGsperrd before disconnection§19 StromGVV / §33 GasGVV
ENTSPERRUNGsperrd after reconnection
SONDERABLESUNGMSB on INSRPT faultBilling restart after meter replacement
ISMS_AUSLESUNGiMSys automaticSmart meter daily/15-min auto-readout

Endpoints

MethodPathDescription
POST/api/v1/reading-ordersCreate reading order
GET/api/v1/reading-ordersList (?malo_id=&status=&anlass=&limit=)
GET/api/v1/reading-orders/{id}Get status and result
PUT/api/v1/reading-orders/{id}/completeRecord meter reading result
PUT/api/v1/reading-orders/{id}/cancelCancel pending order — no longer owed
PUT/api/v1/reading-orders/{id}/failRecord an Ablesehindernis — still owed
GET/api/v1/compliance/jahresablesung/{year}§40 Abs. 2 EnWG compliance report

Cancelled vs failed

Both are terminal, but only STORNIERT discharges the obligation.

OFFEN → BEAUFTRAGT → AUSGEFUEHRT   reading taken, obligation met
   └──────────────→ STORNIERT      no longer owed
   └──────────────→ FEHLGESCHLAGEN Ablesehindernis — still owed
PUT /api/v1/reading-orders/{id}/fail
{ "grund": "KEIN_ZUTRITT", "notiz": "3x angetroffen, niemand vor Ort" }
GrundMeaning
KEIN_ZUTRITTNo access to the premises
ZAEHLER_UNZUGAENGLICHMeter present but blocked
ZAEHLER_DEFEKTMeter faulty — reading not usable
ZAEHLER_NICHT_AUFFINDBARMeter not found at the recorded location
KUNDE_VERWEIGERTCustomer refused the reading
ABLESUNG_UNPLAUSIBELValue read but implausible
SONSTIGESAnything else — use notiz

A CHECK constraint rejects FEHLGESCHLAGEN without a fehlschlag_grund, so the status cannot be used to silently retire an order.

Quality history

Every scoring path — MSCONS, direct push, IoT, bulk, and retroactive rescoring — records a verdict in quality_assessments. The table is a history of how a MaLo's data quality moved, not a snapshot of the latest opinion: a billing dispute is answerable only if it shows when a gap appeared, when it was substituted, and what the grade was at the moment an invoice was raised.

Re-scoring a window supersedes the previous verdict for the same source rather than appending a duplicate, so the history reads as a sequence of decisions.

Only grade F blocks billing. C is significant but still billable, which is why billing_blocked is stored rather than derived from the letter by each reader.

§40 Abs. 2 EnWG compliance report

GET /api/v1/compliance/jahresablesung/{year} reports what became of each order, because only AUSGEFUEHRT discharges the annual-reading obligation:

OutcomeObligation
AUSGEFUEHRTdischarged
STORNIERTwithdrawn
FEHLGESCHLAGENoutstanding, with a documented Ablesehindernis
OFFEN / BEAUFTRAGT past ausfuehrt_bislate

fehlschlag_gruende breaks the failures down by Ablesehindernis, which is what decides whether the NB may estimate under §40a EnWG or must re-dispatch.

ablesequote is computed over orders raised, not over the SLP population: this service knows what was ordered, and marktd owns how many MaLos exist. A MaLo that was never scheduled has no order here at all, so the population must be cross-checked against marktd — reporting a population-based rate from edmd would overstate coverage.

A failed JAHRESABLESUNG past ausfuehrt_bis is still a §40 Abs. 2 EnWG gap, so it keeps appearing in list_overdue_reading_orders until the reading is re-dispatched or the quantity is estimated under §40a EnWG. Failing an order emits de.messwert.reading.order.failed; the reason decides whether the NB may estimate or must re-dispatch.

iMSys auto-close

For smart meters (iMSys), MSCONS data arrives automatically via makodedmd webhook. edmd auto-closes open reading orders for the same malo_id when the MSCONS timestamp matches geplant_am within ±1 day.


Virtual meters (§42b EnWG GGV — Solarpaket I)

edmd computes virtual meter time series on demand for MaLo IDs that have a virtual_meter_configs row. Virtual meters are used for:

RuleLegal basisTypical use-case
SumPortfolio totals, Summenmessung (multiple transformers, shared substations)
Residual— (ordinary supply, no special §)Grid feed-in = gross generation − own consumption
PvSelfConsumption§42b EnWGProsumer: net grid draw after PV self-use
GgvConstantAllocation§42b Abs. 5 EnWGGGV tenant with fixed allocation fraction (UTILTS CCI+ZG6)
GgvProportionalAllocation§42b Abs. 5 EnWGGGV tenant with dynamic consumption-based allocation. Also carries §42c Energy Sharing: the allocation arithmetic is identical and the regimes are distinguished by legal_basis (§42b = in-building, no grid transit; §42c = via the public grid). Per BNetzA Mitteilung Nr. 73 (07.07.2026, Az. BK6-06-009), Energy Sharing §42c is implemented via the Dienstleistungsmodell inside the existing market model — no §42c-specific process arithmetic is mandated.

GGV allocation formulas (BDEW Anwendungshilfe, 25.01.2024)

Both GGV variants compute the tenant's net grid draw after PV allocation — the energy each participant draws from the public grid after their community PV share has been credited. This is the Malo_i Verbrauch quantity in the BDEW formula sheets, and directly corresponds to the Verbrauchszeitreihe submitted to the BKV in UTILTS.

The critical invariant (§42b Abs. 5 EnWG, sentence 2) is that the allocated PV energy can never exceed the tenant's actual consumption in any 15-minute interval. This is enforced by the Pos() = max(0, x) operator:

§42b Abs. 5: "Die einem einzelnen teilnehmenden Letztverbraucher im Wege der
rechnerischen Aufteilung innerhalb eines 15-Minuten-Zeitintervalls zuteilbare
Strommenge ist begrenzt auf die durch ihn in diesem Zeitintervall verbrauchte
Strommenge."

Constant allocation (BDEW Beispiel 1 — UTILTS CCI+ZG6):

$$\text{net_grid_draw}_i[t] = \max!\bigl(0,\ c_i[t] - f_i \times g[t]\bigr)$$

where $c_i[t]$ is tenant $i$'s consumption, $f_i$ is the static fraction, and $g[t]$ is plant generation.

Proportional allocation (BDEW Beispiel 3 — variable):

$$r_i[t] = \frac{c_i[t]}{\sum_j c_j[t]} \qquad \text{(0 if } \sum c_j = 0 \text{)}$$

$$\text{net_grid_draw}_i[t] = \max!\bigl(0,\ c_i[t] - r_i[t] \times g[t]\bigr)$$

graph LR
    PLANT["MELO1 Erzeugung<br/>(plant generation g)"]
    MELO2["MELO2 Verbrauch<br/>(tenant 2 consumption c₂)"]
    MELO3["MELO3 Verbrauch<br/>(tenant 3 consumption c₃)"]

    subgraph GgvConstant["GgvConstantAllocation (Beispiel 1, CCI+ZG6)"]
        direction TB
        F2["fraction₂ = 10 %"]
        F3["fraction₃ = 90 %"]
        NET2_C["Malo2 net draw<br/>= max(0, c₂ − 0.1×g)"]
        NET3_C["Malo3 net draw<br/>= max(0, c₃ − 0.9×g)"]
    end

    subgraph GgvProportional["GgvProportionalAllocation (Beispiel 3, variable)"]
        direction TB
        RATIO["r₂ = c₂/(c₂+c₃)<br/>r₃ = c₃/(c₂+c₃)"]
        NET2_P["Malo2 net draw<br/>= max(0, c₂ − r₂×g)"]
        NET3_P["Malo3 net draw<br/>= max(0, c₃ − r₃×g)"]
    end

    PLANT --> F2 & F3
    MELO2 --> NET2_C
    F2 --> NET2_C
    MELO3 --> NET3_C
    F3 --> NET3_C

    PLANT & MELO2 & MELO3 --> RATIO
    RATIO --> NET2_P & NET3_P

Configuring virtual meters

Create a virtual meter config via the REST API (stored in virtual_meter_configs).

The table is keyed by virtual_malo_id — a virtual meter is a Marktlokation, addressed by its own MaLo-ID — and carries legal_basis so a community records which regime it operates under. rule_type is constrained to the variants of metering::aggregation_rule::AggregationRule; edmd deserialises rule_json into that enum, so a value the enum does not know is an unreadable row.

sqlx::query is unchecked, so a column that does not exist is a runtime error rather than a compile error. The schema_code_guard test suite reads the migration and the handler queries and asserts they agree — column set, upsert conflict key, and the rule_type list against the enum.

Routes: POST /api/v1/virtual · GET /api/v1/virtual · GET|DELETE /api/v1/virtual/{virtual_malo_id} · GET /api/v1/virtual/{virtual_malo_id}/lastgang.

# Tenant 2: constant 10 % allocation (GgvConstantAllocation)
curl -X POST http://edmd:8380/api/v1/virtual \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" -d '{
    "virtual_malo_id": "10001234004",
    "display_name":    "GGV MaLo2 — Wohnung 2",
    "sparte":          "STROM",
    "legal_basis":     "§42b EnWG Solarpaket I",
    "valid_from":      "2026-01-01T00:00:00Z",
    "rule_json": {
      "GgvConstantAllocation": {
        "plant_melo_id":  "DE0001234560001",
        "tenant_melo_id": "DE0001234560002",
        "fraction":       "0.10"
      }
    }
  }'

# Tenant 2: proportional/variable allocation (GgvProportionalAllocation)
curl -X POST http://edmd:8380/api/v1/virtual \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" -d '{
    "virtual_malo_id": "10001234004",
    "display_name":    "GGV MaLo2 — proportional",
    "sparte":          "STROM",
    "legal_basis":     "§42b EnWG Solarpaket I",
    "valid_from":      "2026-01-01T00:00:00Z",
    "rule_json": {
      "GgvProportionalAllocation": {
        "plant_melo_id":      "DE0001234560001",
        "tenant_melo_id":     "DE0001234560002",
        "all_tenant_melo_ids": ["DE0001234560002", "DE0001234560003"]
      }
    }
  }'

Querying virtual meter time series

# Net grid draw for tenant MaLo2 — computed live from plant + tenant consumption MeLos
curl -s "http://edmd:8380/api/v1/virtual/10001234004/lastgang?from=2026-07-01T00:00:00Z&to=2026-07-02T00:00:00Z" \
  -H "Authorization: Bearer <token>" | jq '{
    virtual_malo_id: "10001234004",
    first_interval: .[0].werte[0]
  }'

Results carry source = "VIRTUAL", quality propagated as the worst of all source MeLo qualities, and obis_code = null (set by the caller).

Design: one rule per tenant MaLo

Each GGV tenant has its own virtual_meter_configs row referencing the shared PV plant MeLo plus that tenant's consumption MeLo. For proportional allocation the rule also lists all tenant MeLos so the denominator $\sum c_j[t]$ can be computed.

Config fieldGgvConstantAllocationGgvProportionalAllocation
plant_melo_idshared PV plant MeLoshared PV plant MeLo
tenant_melo_idthis tenant's MeLothis tenant's MeLo
fractionstatic 0–1
all_tenant_melo_idsall participating tenant MeLos

UTILTS encoding (BDEW CCI+ZG6)

The BDEW UTILTS message encodes both allocation methods as CCI+ZG6 segments (Aufteilungsfaktor Energiemenge). Constant fractions use CAV+Z28:::0.10 for 10%, proportional allocation uses CAV+Z74 (Divisionsquotient). makod handles UTILTS encoding/decoding transparently — edmd only deals with the computed net-grid-draw intervals.


edmd reads its configuration from a TOML file (default: edmd.toml), with secrets deferred to environment variables via "env:VAR_NAME" values.

CLI flags

FlagEnv varDefaultDescription
--config / -cEDMD_CONFIGedmd.tomlPath to edmd.toml
--log-levelRUST_LOGinfoLog level
--checkEDMD_CHECKfalseValidate config + DB connectivity, then exit 0. Used by Dockerfile HEALTHCHECK.
edmd --config /etc/edmd/edmd.toml
# or: EDMD_CONFIG=/etc/edmd/edmd.toml edmd

Full edmd.toml reference

[http]
addr = "0.0.0.0:8380"          # default

[database]
url                  = "env:DATABASE_URL"  # required; use env: for secrets
pool_size            = 20     # max connections (default 10)
min_connections      = 0      # kept-warm minimum (default 0)
acquire_timeout_secs = 30     # fail a request rather than queue forever (default 30)
idle_timeout_secs    = 600    # reap idle connections (default 600)
max_lifetime_secs    = 1800   # recycle connections, e.g. across a failover (default 1800)

[identity]
tenant = "9900357000004"        # required — MP-ID of the operator

[marktd]
url     = "http://marktd:8180"       # required
api_key = "env:EDMD_MARKTD_API_KEY" # required

[webhook]
inbound_secret = "env:EDMD_INBOUND_SECRET"  # optional; omit for dev

[subscription]
# Self-registers with marktd on startup — no manual curl required.
webhook_url   = "http://edmd:8380/webhook"  # public URL marktd POSTs to
subscriber_id = "edmd"                       # default
event_types   = [
  "de.mako.process.initiated",
  "de.mako.process.completed",
  "de.mako.edifact.inbound",
]

# [oidc]          # omit to disable auth (dev only — never omit in production)
# issuer   = "https://login.microsoftonline.com/{tenant-id}/v2.0"
# audience = "api://mako-edmd"
# jwks_refresh_secs = 300

# [otel]          # omit to disable tracing
# endpoint = "http://otel-collector:4317"

Connection budget. edmd opens more PostgreSQL pools than most services, so size pool_size and the server's max_connections with all of them in mind: the main pool (pool_size, which also backs meterstore's hot tier), plus the single shared Iceberg catalog metadata pool — meter_reads and esa_typ2_reads are two tables of one MeterCatalog, so they share one SqlCatalog (one metadata pool, bounded to 4 connections), not one pool each. The pool is built through the shared DatabaseConfig::connect builder, so the configured size and lifetimes actually take effect and every connection is tagged edmd in pg_stat_activity.


marktd subscription

edmd auto-registers its EventBus subscription with marktd on startup when subscription.webhook_url is set in the config — no manual curl required.

To force re-registration or verify the subscription:

curl -s http://marktd:8180/api/v1/subscriptions/edmd \
  -H "Authorization: Bearer <token>" | jq .

Query examples

# BO4E Energiemenge — all meter readings for a MaLo (typed, ERP-consumable)
curl -s "http://edmd:8380/api/v1/deliveries/10001234567?from=2026-01-01T00:00:00Z&to=2026-04-01T00:00:00Z" \
  -H "Authorization: Bearer <token>" | jq '.[0] | {obisKennzahl, menge_kwh: .menge.wert}'

# Billing period for a MaLo (used by netzbilanzd)
curl -s "http://edmd:8380/api/v1/billing-period/10001234567?from=2026-01-01&to=2026-03-31" \
  -H "Authorization: Bearer <token>" | jq '{
    spitzenleistung_kw,
    arbeitsmenge_kwh,
    period_from,
    period_to
  }'

# Mehr-/Mindermengensaldo for January 2026
curl -s "http://edmd:8380/api/v1/imbalance/10001234567/2026/1" \
  -H "Authorization: Bearer <token>" | jq .

# BO4E Lastgang export — one object per OBIS register
curl -s "http://edmd:8380/api/v1/lastgang/10001234567?from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z" \
  -H "Authorization: Bearer <token>" | jq '.[0] | {sparte, obis_kennzahl, zeit_intervall_laenge}'

# BO4E Zeitreihe export — one object per OBIS register (medium/messart metadata)
curl -s "http://edmd:8380/api/v1/zeitreihe/10001234567?from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z" \
  -H "Authorization: Bearer <token>" | jq '.[0] | {bezeichnung, medium, messart, einheit}'

Cold-tier OLAP over the meterstore Iceberg history

meter_reads is a meterstore table: a recent window in PostgreSQL and the settled history in Apache Iceberg V2 on S3, GCS, or Azure Data Lake, split by a tiering watermark. meterstore owns the Iceberg format, the partitioning, the object-store layout and the cold-tier catalog; edmd configures only the daily partition/archival steps, the one-week settlement lag, and the tenant identity column. Every read is version-resolved (latest-version-wins) and tier-split before it reaches edmd, so a query never sees a superseded value or has to know which tier an interval lives in. Apache DataFusion runs the analytical endpoints over that resolved relation with Iceberg partition pruning, for MMM aggregation that spans years without a full PostgreSQL scan.

Why a tiered store?

ChallengeSolution
35 000 rows/RLM MaLo/year — PG scan degrades after year 2Settled intervals roll past the watermark into columnar Parquet on object storage
MMM aggregation spans 3+ yearsDataFusion pushes predicates to Iceberg partitions + Parquet row-group statistics
Multi-engine access (Spark, Trino, DuckDB)Read-only Iceberg REST catalog (meterstore's CatalogFacade, mounted by edmd under /api/v1/iceberg)
Reproduce a past settlementstore.as_of(snapshot) (cold-tier snapshot) or store.as_known_at(t) (recorded_at ceiling, both tiers) reads history as it stood then

Layout and partitioning

The Iceberg V2 format, the partition spec and the object-store directory layout are meterstore's, driven by edmd's TableConfig: a partition step, an archival step and a settlement lag (all configurable, see below), plus tenant as the non-nullable identity column so two tenants' readings for one measuring point never merge. Files land under the configured storage_uri warehouse. edmd defines no partition spec of its own and implements no archival logic — it starts meterstore's maintenance loop, which drives the tiering.

Configuration

meterstore is a library edmd links in-process (the published meterstore crate), so it reads no config of its own — its tiering knobs come from edmd's [archive] section:

[archive]
enabled              = true
storage_uri          = "s3://my-bucket/edmd/warehouse"   # scheme picks the backend
region               = "eu-central-1"
# access_key_id / secret_access_key optional — omit to use the instance-role chain.
# endpoint_url       = "http://minio:9000"   # S3-compatible (MinIO/Ceph/R2 → path-style)
settlement_lag_days  = 7    # age at which an interval settles hot → cold
partition_step_days  = 1    # cold-tier partition granularity
archival_step_days   = 1    # watermark advance per archival sweep
cold_file_target_mib = 512  # target Parquet file size
maintenance_interval_secs = 3600  # how often the tiering loop runs a cycle

enabled turns the cold tier on; the storage_uri scheme selects the warehouse backend — file://, memory:// (dev), s3:// (and S3-compatible minio:// / r2://), gs://, abfss://. For S3, region plus the optional access_key_id/secret_access_key (prefer "env:…" refs) configure access; omit the keys to let the EC2/IRSA instance-role chain supply them — the recommended production path. GCS and Azure use their platform credential chains (ADC / managed identity). Retention is not a window to set — meterstore reclaims the hot tier through the tiering watermark, and settlement_lag_days is simply how long a reading stays correctable before it settles. maintenance_interval_secs sets how often meterstore's in-process maintenance loop archives due windows: without it the cold tier never fills, since the watermark only advances when a cycle runs.

Archive OLAP endpoints

EndpointDescription
GET /api/v1/archive/statusStore / tiering statistics (rows per tier, watermark position)
GET /api/v1/archive/olap/{malo_id}?from=&to=MMM aggregation: total kWh, read count, period bounds for one MaLo over the resolved relation
GET /api/v1/archive/portfolio?from=&to=&limit=NPortfolio-level aggregation: top-N MaLo by consumption, tenant-scoped, ordered by consumption descending
GET /api/v1/archive/timeseries/{malo_id}?from=&to=Historical time-series export (up to 50 000 rows)

Typical MMM aggregation (runs over the version-resolved, tier-split series):

curl "http://edmd:8380/api/v1/archive/olap/10001234567?from=2023-01-01T00:00:00Z&to=2025-12-31T23:59:59Z" \
  -H "Authorization: Bearer <token>" | jq '{total_kwh, read_count, from, to}'

Response:

{
  "malo_id":    "10001234567",
  "total_kwh":  "123456.78900",
  "read_count": 105120,
  "from":       "2023-01-01 00:00:00 +00:00:00",
  "to":         "2025-12-31 23:59:59 +00:00:00"
}

Dependencies

The Iceberg core, the object-store FileIO, the Iceberg catalog and the DataFusion query engine are meterstore's dependencies, not edmd's: edmd links meterstore and hands it a TableConfig. edmd carries no iceberg crate and defines no Iceberg tables of its own.


Arrow IPC bulk export

For high-throughput bulk reads — such as mabis-syncd fetching a month of 15-min data for 50 000 MaLos — edmd supports the Apache Arrow IPC stream binary format as an alternative to JSON. Set the Accept header to request Arrow IPC; the response carries the same data as the JSON endpoint but serialised as a self-describing columnar stream. This delivers 10–50× higher throughput and eliminates the JSON parsing overhead in the consumer.

# Request Arrow IPC stream from the Lastgang endpoint
curl -s "http://edmd:8380/api/v1/lastgang/10001234567?from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z" \
  -H "Authorization: Bearer <token>" \
  -H "Accept: application/vnd.apache.arrow.stream" \
  > reads.arrows

# Consume directly in DuckDB (no conversion needed)
duckdb -c "SELECT SUM(quantity_kwh), quality FROM read_ipc_stream('reads.arrows') GROUP BY quality"

# Consume in Python / Polars
python3 -c "
import pyarrow.ipc as ipc
with open('reads.arrows', 'rb') as f:
    reader = ipc.open_stream(f)
    tbl = reader.read_all()
    print(tbl.schema)
    print(f'{len(tbl)} intervals')
"

Endpoints supporting Arrow IPC:

EndpointJSON responseArrow IPC available
GET /api/v1/lastgang/{malo_id}BO4E Lastgang
GET /api/v1/zeitreihe/{malo_id}BO4E Zeitreihe
GET /api/v1/esa/typ2/{malo_id}ESA Typ-2 values (esa_typ2_reads) — non-authoritative, never billing

Arrow schema (per response row):

ColumnTypeNotes
malo_idUtf811-digit Marktlokations-ID
dtm_fromTimestamp(µs, UTC)Interval start
dtm_toTimestamp(µs, UTC)Interval end
quantity_kwhFloat64Energy in kWh
qualityUtf8MEASURED / ESTIMATED / …
sparteUtf8STROM / GAS
obis_codeUtf8?nullable
pidInt32Source MSCONS PID

DataFusion SQL endpoint

POST /api/v1/query/sql executes an arbitrary SQL query via Apache DataFusion in meterstore's own session, over the version-resolved relation meterstore registers under the name meter_reads (store.resolved_table()). This is the power-user interface for ad-hoc OLAP analysis, programmatic SQL without a database client.

The resolved relation exposes these columns: malo_id, "from" (interval start — a SQL reserved word, so it must be quoted), to, value (the kWh value), sparte, obis_code, quality, unit, tenant, subject_ref, and the provenance attribute columns source, sender_mp_id and allocation_version.

# Aggregate annual consumption per MaLo over the resolved relation
curl -s -X POST http://edmd:8380/api/v1/query/sql \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" -d '{
    "sql": "SELECT malo_id, CAST(SUM(\"value\") AS DOUBLE) AS kwh_total FROM meter_reads WHERE \"from\" >= TIMESTAMP '\'2025-01-01T00:00:00Z\'' GROUP BY malo_id ORDER BY kwh_total DESC LIMIT 20",
    "limit": 20
  }' | jq .

Results come back as JSON rows by default, or as an Arrow IPC stream when the body carries "format": "arrow_ipc". Every result carries the tier-provenance fields spans_tiers and touched_hot_tier, so a caller can tell whether a query crossed the watermark into the settled cold tier or was served entirely from the recent hot window.

Access control: requires Cedar action read-archive-olap. Only SELECT and WITH statements are accepted; INSERT/UPDATE/DROP are rejected.


Iceberg REST catalog — external OLAP

edmd mounts meterstore's CatalogFacade (meterstore's catalog-facade feature) under /api/v1/iceberg, exposing the standard Iceberg REST catalog protocol so DuckDB, Spark, Trino, and PyIceberg can attach to the cold Iceberg tier without any ETL pipeline. It is a live, correct catalog over the actual settled history — GET /api/v1/iceberg/v1/config, …/v1/namespaces, …/v1/namespaces/{ns}, …/v1/namespaces/{ns}/tables and …/v1/namespaces/{ns}/tables/{table} all return the real schema and table locations.

Read-only by design. PostgreSQL holds the rows at or above the tiering watermark and Iceberg holds those below it, so an external writer coming through this endpoint would break that split — mutating routes answer 405. edmd and meterstore stay in the metadata path only, never the data path: the catalog returns table locations but carries no storage credentials, and each engine reads the Parquet directly from object storage with its own credentials.

Cedar-gated. An axum middleware runs the shared OIDC Claims extractor and CedarEnforcer.check("read-archive-olap", tenant) in front of the nested router, so the catalog is authenticated and authorised exactly like the other archive endpoints. Unauthenticated requests receive 403 Forbidden.

-- DuckDB: attach edmd's Iceberg REST catalog (metadata), then read Parquet
-- directly from object storage with DuckDB's own S3 credentials.
ATTACH 'rest+http://edmd:8380/api/v1/iceberg' AS mako (TYPE ICEBERG);

-- Annual energy by MaLo over the real relation
SELECT
    malo_id,
    DATE_TRUNC('month', "from") AS month,
    SUM("value")                AS arbeitsmenge_kwh
FROM mako.meter_reads
WHERE "from" BETWEEN TIMESTAMP '2025-01-01' AND TIMESTAMP '2025-12-31'
  AND quality NOT IN ('FAULTY', 'UNKNOWN')
GROUP BY 1, 2
ORDER BY 1, 2;

The relation is meter_reads, with meterstore's resolved columns — "from" (the interval start, quoted because it is a SQL reserved word) and "value" (the kWh value).


§14a Fernsteuerbarkeit compliance — SMGW session registry

edmd maintains a SMGW (Smart Meter Gateway) session registry and runs a daily compliance sweep per MsbG §21c and BSI TR-03109-4 §6.3.

Why here?

edmd already owns meter-data push sessions (direct_push_sessions) and reading-order scheduling. SMGW connectivity is a metering-domain concern: when a gateway's TLS cert expires or a CLS channel loses its §14a Konfigurationsprodukt, meter data stops flowing and substitute values (§ 60 Abs. 2 MsbG) become mandatory. edmd detects both conditions and emits de.messwert.cls.compliance-issue CloudEvents so agentd's smgw-diagnostics-agent can escalate to the MSB and ERP system automatically.

Data model

smgw_sessions (1) ──────────────────────────────────► cls_compliance_log (N)
  malo_id (PK)          append-only audit trail
  device_id             per issue detected (CRITICAL / WARNING)
  gateway_status        ← promoted column for fast pre-filtering
  session (JSONB)       ← full SmgwSession (certs + CLS channels)
  last_contact_at
  geraet_konfigurationen (from marktd) drives SMGW_CERT_ABLAUFDATUM here

The session JSONB column is GIN-indexed, enabling direct SQL queries on the certificate and CLS channel arrays without application-layer deserialization.

Compliance check logic

The pure function check_session_compliance() in edmd/src/smgw.rs checks six issue types in priority order:

Priorityissue_typeSeverityLegal basis
1GATEWAY_REVOKEDCRITICALMsbG §29 — replace immediately
2COMMUNICATION_FAULTCRITICAL§ 60 Abs. 2 MsbG — substitute values required after 2h silence
3TLS_CERT_MISSINGCRITICALBSI TR-03109-4 — SMGW Admin Protocol unreachable
4CERT_EXPIREDCRITICALBSI TR-03109-4 §6.3 — §14a eligibility lost
5CERT_EXPIRINGWARNINGBSI TR-03109-4 §6.3 — 30-day renewal window
6CLS_NOT_COMPLIANTWARNINGBK6-24-174 §4.3 — DSO load control impossible

Background worker

spawn_cls_compliance_worker() runs daily (configurable), with a 30s startup delay and graceful shutdown via CancellationToken. On each sweep:

  1. Query all smgw_sessions for the tenant.
  2. For each session, run check_session_compliance() (pure — no I/O).
  3. For each issue found: insert into cls_compliance_log + emit de.messwert.cls.compliance-issue.
  4. Tracing logs the sweep result (sessions scanned, issue count, has_critical).

SMGW session API

# Register or update a SMGW session (after BSI TR-03109-4 Admin session or GWA sync)
curl -s -X PUT "http://edmd:8380/api/v1/smgw/10001234567" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "device_id":       "SMGW-2026-001",
    "firmware_version": "3.1.2",
    "msb_mp_id":       "9900000000003",
    "malo_id":         "10001234567",
    "status":          "OPERATIONAL",
    "certificates": [
      {
        "serial_number": "AA:BB:CC:DD",
        "cert_type":     "TLS",
        "subject_cn":    "SMGW-2026-001",
        "issuer_cn":     "BSI-Smart-Meter-CA",
        "valid_from":    "2025-01-01",
        "valid_to":      "2027-06-30",
        "is_revoked":    false
      }
    ],
    "cls_channels": [
      {
        "channel_id":     "CLS-00042",
        "malo_id":        "10001234567",
        "device_type":    "HEAT_PUMP",
        "max_power_kw":   "8.50",
        "channel_status": "ACTIVE",
        "produktcode":    "FLEX-001",
        "valid_from":     "2026-01-01"
      }
    ],
    "last_contact_at": "2026-07-18T07:55:00Z",
    "installed_at":    "2025-06-01"
  }'
# → 204 No Content when compliant
# → 200 { "status": "accepted_with_compliance_issues", "issues": [...] } when issues detected

# Get session + 10 most recent compliance events
curl -s "http://edmd:8380/api/v1/smgw/10001234567" \
  -H "Authorization: Bearer <token>" | jq '{gateway_status, recent_issues}'

# Fleet overview (with 24-hour issue counts)
curl -s "http://edmd:8380/api/v1/smgw?status=OPERATIONAL" \
  -H "Authorization: Bearer <token>" | jq '.sessions[] | {malo_id, critical_issues_24h}'

# On-demand read-only compliance scan (no CloudEvents emitted, no DB writes)
curl -s "http://edmd:8380/api/v1/smgw/compliance" \
  -H "Authorization: Bearer <token>" | jq '{sessions_scanned, has_critical, compliance_pct}'

# Force a full side-effecting sweep (logs + emits CloudEvents)
curl -s -X POST "http://edmd:8380/api/v1/smgw/compliance/scan" \
  -H "Authorization: Bearer <token>" | jq '{sessions_scanned, sessions_with_issues}'

de.messwert.cls.compliance-issue CloudEvent

{
  "specversion": "1.0",
  "id":          "a1b2c3d4-...",
  "type":        "de.messwert.cls.compliance-issue",
  "source":      "urn:mako:edmd:tenant:9900000000003",
  "subject":     "10001234567",
  "time":        "2026-07-18T05:00:00Z",
  "data": {
    "malo_id":        "10001234567",
    "device_id":      "SMGW-2026-001",
    "issue_type":     "CERT_EXPIRING",
    "severity":       "WARNING",
    "cert_serial":    "AA:BB:CC:DD",
    "cert_type":      "TLS",
    "days_to_expiry": 12,
    "channel_id":     null,
    "description":    "SMGW SMGW-2026-001 TLS cert AA:BB:CC:DD expires in 12 days — renew now"
  }
}

agentd's smgw-diagnostics-agent subscribes to de.messwert.cls.compliance-issue and automatically escalates to the MSB team, suggests remediation steps, and checks whether the same device has open § 60 Abs. 2 MsbG substitute-value orders.

Mermaid: daily sweep flow

sequenceDiagram
    participant Worker as edmd daily worker<br/>(05:00 UTC)
    participant DB as edmd PostgreSQL<br/>(smgw_sessions)
    participant Log as cls_compliance_log
    participant ERP as ERP webhook

    Worker->>DB: SELECT malo_id, session FROM smgw_sessions
    DB-->>Worker: Vec<SmgwSession>
    loop for each session
        Worker->>Worker: check_session_compliance()<br/>(pure — no I/O)
        alt has issues
            Worker->>Log: INSERT cls_compliance_log
            Worker->>ERP: POST de.messwert.cls.compliance-issue<br/>(CloudEvent per issue)
        end
    end
    Worker->>Worker: tracing::info!(sessions_scanned, compliance_pct)

GDPR Art. 17 erasure

DELETE /api/v1/gdpr/erasure/{malo_id} implements the GDPR right to erasure for meter data. Because meter_reads lives in meterstore's append-only tiered store, erasure is pseudonymisation, not a Parquet rewrite: every MaLo is enrolled as an erasure subject at ingest — a subject_ref stamped on each of its rows — and Art. 17 destroys that subject mapping. In one transaction on edmd's pool the endpoint:

  1. Records the erasure request in gdpr_deletions (idempotent on malo_id + tenant).
  2. Destroys the MaLo's subject mapping in meterstore's registry (SubjectRegistry::erase_in) — the readings survive in both tiers but become unattributable everywhere at once.
  3. Deletes the derived edmd tables (meter_billing_periods, quality_assessments, substitute_value_log), tenant-scoped.

meterstore's registry tables (meterstore_subject_map / meterstore_erasures) live in the same database, so all of the above commits or rolls back together.

curl -X DELETE "http://edmd:8380/api/v1/gdpr/erasure/10001234567" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" -d '{
    "reason":        "Customer right-to-erasure request #2026-42",
    "authorized_by": "gdpr-officer@example.com"
  }'

Response 200 OK:

{
  "malo_id":          "10001234567",
  "status":           "erased",
  "subject_unlinked": true,
  "mechanism":        "meterstore subject-registry pseudonymisation (append-only tiers)",
  "legal_basis":      "DSGVO Art. 17 right to erasure"
}

The readings remain for § 60 Abs. 6 MsbG reconciliation but no longer identify the MaLo. subject_unlinked is false when the MaLo had no mapping — never stored or already erased — which is recorded, not treated as an error.


Cedar ABAC

edmd enforces two layers with Cedar: every action requires the caller's tenant to match the deployment tenant, and write actions additionally require a market role (mako_roles JWT claim). An LF-role service account of the same tenant — a portal integration, a billing reader — can read everything but write nothing.

Action groupActionsRequired role
Readsread-timeseries, read-imbalance, read-billing-period, read-corrections, read-archive-olap, read-archive-status, read-reading-order, use-mcpany (tenant match only)
Reading ingestwrite-meter-reads (direct push, gas, IoT, SMGW registry)MSB or admin
Series mutationwrite-timeseries, write-corrections, write-quality-rescore (bulk import, § 60 Abs. 6 MsbG corrections, § 60 Abs. 2 MsbG substitutes, virtual meters, rescore)MSB, NB, or admin
Field dispatchwrite-reading-order (orders + §40 EnWG campaign)NB, MSB, or admin
Erasurewrite-gdpr-erasure (Art. 17 DSGVO)NB, MSB, or admin

POST /api/v1/query/sql is gated by read-archive-olap (the archive capability), not the generic hot-tier read action.

The shipped policy is policies/edmd.cedar; the cedar_policy test suite pins these gates, so a widening edit fails CI. Example — a same-tenant read grant:

permit(
  principal,
  action == Action::"read-timeseries",
  resource
) when {
  context.principal_tenant == context.resource_tenant
};

Monitoring

MetricTarget
Webhook de.mako.edifact.inbound success rate> 99 %
DB pool utilisation< 80 %
meterstore tiering-watermark lag (age of the oldest hot-tier interval)Bounded — settled intervals should roll to the cold tier within the one-week settlement lag

Edit this page ↗