rubo4e
Rust implementation of BO4E — Geschäftsobjekte für die Energiewirtschaft, the object model the German energy industry uses to exchange contracts, metering points, invoices, and the parties involved.
rubo4e generates the full object model from the official JSON Schema, then adds
what the schema cannot express: market identifiers that verify their own BDEW
check digits, enums you can parse strictly at an ingest boundary, and JSON that
reads what Python, Go, and .NET write — and writes what the reference Python
implementation does.
Independent implementation. Not affiliated with or endorsed by the BO4E project or BDEW; the reference implementation is BO4E-python.
Features
- Generated types from the official BO4E JSON Schema, generated from a committed snapshot (
v202607.1.0) so the codegen is reproducible - Strong domain identifiers — the complete BDEW identifier family (
MaloId,MeloId,NeloId,NebeId,CrId,SgId,SrId,TrId,PaketId,EicCode,ObisCode,MarktpartnerId, …) plus the SEPA pair (Iban,Bic), with spec-accurate check digits and domain helpers - Three-layer validation — constructor checks,
gardestruct rules, cross-field business logic - Strict enum parsing & introspection —
from_wire(reject out-of-schema values),VARIANTS/COUNT/iter_known,Display/AsRef<str>,is_unknown, unified by theBo4eEnumtrait — all without thestrumfeature - Recursive strict decoding —
Bo4eStrict::ensure_known_enums()rejects anyUnknownenum value anywhere in a deserialized payload, with JSON-paths — one call replaces hand-written per-field checks - Recursive unknown-field detection —
Bo4eExtensions::ensure_no_extension_data()finds every field BO4E does not define, at any depth; a decode cannot, since a misspelled key decodes cleanly and reads back asNone - Typed builders — readable, diffable construction via
typed-builder; setters accept bothTandOption<T>(note: BO4E BO fields are schema-optional, so AHB-mandatory contracts are enforced by your ingest layer, not the type system);LastgangandTarif, the two the schema marksrequired, get a feature-freenew(…) - Type-level
_typfacts —T::TYP,T::TYP_WIRE,T::SCHEMA_VERSION,T::SCHEMA_SERIESas associated constants on every BO and COM, so generic code needs no value and noDefaultbound - German / snake_case / canonical JSON — BO4E wire format out of the box, with a hardened path for untrusted input
Eq+Hashon generated types without thejsonfeature, so a BO can key aHashMap; enums are alwaysEq + Ord + Hash- Time-series audit —
Lastgang/Zeitreiheplaced on a timeline in one call: gaps, overlaps, wrong-length intervals, unusable readings, coverage ratio — andintegrate(), the step from a load profile in kW to the energy an invoice bills - One reading shape for every interval series —
Lastgang,ZeitreiheandEnergiemengeall produce anIntervalReading, and all three read it back;total_energy()answers in kWh from a kW load profile or a kWh series alike - Lokationsbündelstrukturen — BO4E has no
LokationsbuendelBO, so this ships the published EDI@Energy codelist (15 structures, 27 object codes) as data, plusaudit_buendel(): unknown codes, an object filed under the wrong type, every cardinality the structure states - Namespaced
ZusatzAttributs — ahems:/mako:convention with typed get/set on every BO and COM, so two producers writing what BO4E does not model cannot overwrite each other - Market rules beyond the schema — the
Zählpunktthat is deliberately not a Messlokation, the resting Aggregationsverantwortung, the Bilanzierungsgebiet EIC typed as an area code — read off BO4E fields, never as a forked enum - Register arithmetic —
Zaehlwerkconsumption per BO4E's own formula, correcting a meter wrap-around (999998 → 000012is 14, not −999 986) and refusing where a meter exchange makes the difference meaningless - Unit dimensions —
Mengeneinheitgrouped into eleven physical dimensions, with exact conversion, the energy ↔ power pairing, and calendar units refused rather than averaged - Ergonomic convenience API — extension traits, billing-period helpers, EDIFACT agency codes
- JSON Schema via
schemars, OpenAPI viautoipa— every identifier with a pattern, a German description and a check-digit-valid example, identical in both — PostgreSQL viasqlx - Golden corpus, fuzz harnesses, and drift guards that fail the build when the committed codegen stops matching the pinned schema
Installation
That gives you the identifier types only. Add the features you need:
Quick Start
use *; // identifiers, BetragExt, MengeExt, PreisExt, Bo4eJsonExt
use ;
Feature Gates
| Feature | Default | Description |
|---|---|---|
identifiers |
✓ | Identifier types (MaloId, EicCode, ObisCode, …) + serde — zero schema overhead |
serde |
✓ | Serde derives + extension-data map |
json |
serde_json helpers (to_json_german(), …) |
|
time |
time crate — Date for date fields, OffsetDateTime for timestamps; also turns on utoipa/time |
|
decimal |
rust_decimal::Decimal for amounts and prices; also turns on schemars/rust_decimal1 and utoipa/decimal |
|
builder |
typed-builder derives on all BO/COM structs |
|
validate |
garde validation — constructor + cross-field rules |
|
schemars |
JSON Schema generation with patterns and examples | |
sqlx |
Type/Encode/Decode/PgHasArrayType for every identifier and every enum (PostgreSQL) |
|
utoipa |
ToSchema with pattern/example/description for OpenAPI |
|
strum |
Enum iteration and string conversion | |
versioned |
Versioned schema modules (v202607, current) |
|
tracing |
Structured diagnostics via the tracing crate |
|
metrics |
Counter export hooks (metrics ecosystem) |
Typical full setup:
JsonSchema/ToSchema for Decimal and the time types ride on decimal and
time, not on schemars/utoipa, so this crate does not become your
workspace's accidental sole provider of them. If you derive JsonSchema over a
Decimal of your own, declare schemars = { features = ["rust_decimal1"] }
yourself — see Ecosystem.
Schema Versions
| Module | Built from | Status |
|---|---|---|
v202607 |
v202607.1.0 | Current stable |
use Marktlokation; // the v202607 series
use Marktlokation; // whichever series is newest — moves with crate updates
Three spellings of a release. The Rust module is the series (v202607).
The git tag carries a v and the full triple (v202607.1.0). The _version
field inside a payload has the triple without the v (202607.1.0).
Bo4eTyped::SCHEMA_VERSION is the wire spelling, SCHEMA_SERIES the series.
The _typ facts are associated constants on Bo4eTyped, carried by every
BO and COM, so one bound reaches both and generic code needs no value — hence
no Default bound, which is what admits Lastgang and Tarif, the two types
the schema marks required:
use ;
assert_eq!;
assert_eq!;
assert_eq!; // a BO
assert_eq!; // a COM
// `Bo4eObject` / `Bo4eComponent` narrow it and bind the discriminant enum.
T::TYP is what the type is, never the _typ a payload claimed — the public
typ field holds that.
Dispatch on the series, not the release. BO4E ships patch releases inside a
series, so a sender one patch ahead stamps a _version that an equality match
rejects — for a payload these types read perfectly:
match incoming_version.split.next
Versioning contract, stated honestly. The module path pins the series; the
rubo4e version pins the values. Enum membership can move inside a series
because BO4E moves it — v202607.1.0 removed Messgroesse::PREISE and dropped
two enums outright. Importing rubo4e::v202607::… rather than rubo4e::current::…
means you will not silently cross a format-version cutover, but it does not
freeze a variant set: for that, pin the crate version and upgrade deliberately.
Guard the rest structurally with T::VARIANTS / T::COUNT so a schema bump
fails in CI. Every release that changes schema-derived membership records it in
the CHANGELOG Schema deltas section, removals included. See
Schema Versioning for the full contract.
Enum Introspection & Strict Parsing
Every generated BO4E enum carries an Unknown forward-compatibility catch-all,
so the lenient serde / FromStr path never fails on an unrecognized wire value
— it maps to Unknown. That is the right default for forward-compatibility, but
the wrong default at an ingest boundary that must reject typos, legacy codes, or
values from a newer schema. Every enum therefore also exposes a uniform,
strum-free surface (also unified by the Bo4eEnum trait for generic use):
use ;
// Introspection without `strum` — drift-guard SQL CHECK lists & mappings:
assert_eq!;
for v in iter_known
// Strict parsing at the boundary — Err instead of a silent Unknown:
assert_eq!;
assert!; // legacy/typo rejected
assert!; // catch-all is not a real value
// Detect lenient-decode fall-through after a serde round-trip, in one call:
let z: Zaehlertyp = from_value.unwrap;
assert!;
| Member | Feature | Purpose |
|---|---|---|
T::VARIANTS |
none | &'static [T] of known variants (excludes Unknown) |
T::COUNT |
none | stable per-version variant count |
T::iter_known() |
none | iterator over known variants |
T::as_wire(&self) |
none | canonical BO4E wire string |
T::from_wire(s) |
none | strict parse → Result<T, UnknownVariant> |
T::is_known / is_unknown |
none | detect the Unknown catch-all |
Display, AsRef<str> |
none | canonical wire string, without strum |
Bo4eEnum trait |
versioned |
the above, generic over the enum type |
Display,AsRef<str>,as_wire,from_wire,VARIANTS,COUNT, anditer_knownare all feature-independent. Thestrumfeature adds onlyFromStr,EnumIter, andInto<&'static str>.
Strict decoding of whole payloads (Bo4eStrict)
Per-enum from_wire is strict at the field level. But the common pattern is a
lenient whole-object decode (serde_json::from_value::<Rechnung>()) used as a
schema gate — and that decode silently turns every unrecognized enum value into
Unknown, anywhere in the tree. Bo4eStrict closes that gap: one call finds
every out-of-schema enum value in a nested value and reports its JSON-path.
use ;
let nelo: Netzlokation = from_value?; // lenient decode (never fails on enums)
nelo.ensure_known_enums?; // Err lists e.g. ["zaehler[1].zaehlertyp"]
ensure_known_enums() returns StrictError with the dotted, index-bracketed
paths of every Unknown enum value; unknown_enum_paths() returns them directly.
Implemented for every BO, COM, enum, and AnyBo. This replaces the hand-written
record.field == T::Unknown re-checks a strict ingest boundary would otherwise
need. Unlike Bo4eTyped/Bo4eEnum, Bo4eStrict is not sealed, so you can
implement it on your own domain wrappers to extend the recursive check.
A decode does not validate field names (Bo4eExtensions)
Serde ignores keys a struct does not declare, and this crate keeps them in
_additional so a payload from a newer schema survives. So decoding a document
is not a check on it — a misspelled key decodes cleanly and reads back as None:
let body = json!;
let kosten: Kosten = from_value?; // cannot fail
assert_eq!;
Bo4eExtensions is the recursive check that answers, with JSON-paths:
use Bo4eExtensions;
assert_eq!;
kosten.ensure_no_extension_data?; // Err(UnknownFieldError { paths })
Or make the decode itself the check — from_json_value and
from_json_value_hardened are the serde_json::Value counterparts of the &str
readers, with the same depth and extension budgets:
let closed = unlimited.with_max_extension_field_count;
from_json_value_hardened?; // Err on any stray key
A payload can leave the schema in two ways, and neither check sees the other's finding:
| Question | Call |
|---|---|
| Does it use a value this schema version does not define? | ensure_known_enums() — Bo4eStrict |
| Does it use a field this schema version does not define? | ensure_no_extension_data() — Bo4eExtensions |
Rejecting an unknown value is usually right at an ingest boundary; rejecting an unknown field usually is not — that is how a counterparty one release ahead reaches you. Run the field check on documents you produce. Better still, construct values typed, where a rename is a compile error. See Serialization.
Identifiers
All domain identifiers validate their format at construction time. There are no panicking constructors.
| Type | Format / Rule |
|---|---|
MaloId |
11 digits, first 1–9, BDEW §8.1 check digit — Marktlokation / Tranche |
NeloId |
Codetyp 'E' + 9 [A-Z0-9] + §8.2 check digit — Netzlokation (BK6-22-128) |
NebeId |
Codetyp 'F' + 9 [A-Z0-9] + §8.2 check digit — Netzbereich (BK6-22-300) |
CrId |
Codetyp 'A' + 9 [A-Z0-9] + §8.2 check digit — Cluster Ressource |
SgId |
Codetyp 'B' + 9 [A-Z0-9] + §8.2 check digit — Steuergruppe |
SrId |
Codetyp 'C' + 9 [A-Z0-9] + §8.2 check digit — Steuerbare Ressource |
TrId |
Codetyp 'D' + 9 [A-Z0-9] + §8.2 check digit — Technische Ressource |
PaketId |
Codetyp 'P9' + 8 [A-Z0-9] + §8.2 check digit — Netzbetreiberwechsel |
MeloId |
33 chars: 2-char ISO country code + 31 alphanumeric |
Zaehlpunktbezeichnung |
the same 33 chars — a Zählpunkt that is not a Messlokation (MaBiS; BK6-20-160 §1.6.2) |
EicCode |
16-char EIC with ENTSO-E check character and object type |
BilanzkreisId |
16-char EIC restricted to object type 'X' (Party) — Bilanzkreis, MaBiS / GaBi Gas |
BilanzierungsgebietId |
16-char EIC restricted to object type 'Y' (Area) — Bilanzierungsgebiet, MaBiS |
ObisCode |
[A-B:]C.D[.E][*F], value groups are octets; C=0 permitted (IEC 62056-61 general metering group) |
MarktpartnerId |
13 decimal digits — BDEW (99), DVGW (98), or GS1 GLN; check digit opt-in |
Lokationsbuendelcode |
13 decimal digits, §8.1 check digit — which Lokationsbündelstruktur (EDI@Energy Codeliste v1.0) |
LokationsbuendelObjektcode |
13 decimal digits, §8.1 check digit — where in it an object sits |
AkivId |
1–36 printable ASCII chars — Aktivierungsidentifikator Redispatch 2.0 (BK6-24-174) |
TranchennummerId |
1–6 decimal digits, no leading zeros — MABIS Bilanzkreisabrechnung (PID 13003) |
Section numbers refer to the BDEW Anwendungshilfe "Identifikatoren in der
Marktkommunikation" v1.2 (7 February 2025). Chapter 8 defines a single
check-digit arithmetic — sum the mapped values at odd positions, add twice the sum
at even positions, take the difference to the next multiple of 10 — in two flavours:
§8.1 for numeric IDs and §8.2 (the "ASCII-Verfahren", where A–Z map to their
ASCII codes) for alphanumeric ones. Both are implemented once and pinned to the
worked examples printed in the specification.
// Build from base — the check digit is computed, never typed by hand.
let malo = from_base?; // → "41373559241" (BDEW §8.1 example)
let c = check_digit?; // → 1u8
assert_eq!;
// Every §8.2 identifier shares the same API and enforces its own Codetyp.
let nelo = from_base?; // → "E0000000019"
let tr = from_base?; // → "D0000000010"
let paket = from_base?; // → "P9000000010"
assert!; // Codetyp mismatch — that is a TrId
// Country code extraction (MeloId)
let melo = new?;
assert_eq!;
assert!;
// EDIFACT agency codes (MarktpartnerId) — eliminates duplicate mapping tables
let mp = new?;
assert_eq!;
assert_eq!; // EDIFACT NAD DE3055
assert_eq!; // EDIFACT UNB DE0007
// MP-IDs carry either a BDEW (§8.1) or a GS1/EAN-13 check digit; opt in explicitly.
assert!;
assert!;
// Integer round-trip for legacy systems
assert_eq!;
// Serde as integer (opt-in, field-level)
pub partner_id: MarktpartnerId,
EIC codes and object types
Position 3 of an EIC carries the ENTSO-E object type, and the German market
leans on it: a Bilanzkreis is a market party (11X…), while a Bilanzierungsgebiet
is an area (11Y…). EicType exposes all seven types, and the two restricted
newtypes make the roles unswappable at a call site.
use ;
let area = new?; // TenneT control area
assert_eq!; // 'Y'
let party = new?; // a Bilanzkreis
assert_eq!; // 'X'
// The restricted types pin position 3, so the two cannot be confused.
let bk = new?;
assert!;
let bg = new?;
assert!;
// The check character is derived, never typed by hand.
assert_eq!;
// Widening is infallible; narrowing is checked.
let eic: EicCode = bk.into;
assert!;
OBIS codes (EDIFACT support)
ObisCode parses once at construction and stores a canonical form, so two
spellings of the same code are equal and hash alike. Value groups are single
octets, as IEC 62056-61 specifies.
// Standard OBIS codes
let obis = new?; // active energy total
let obis = new?; // C=0 — general metering group (IEC 62056-61)
// Canonicalisation: `&` becomes `*`, and leading zeros are dropped.
assert_eq!;
assert_eq!;
assert_eq!;
// Value groups are octets — 256 is not an OBIS value.
assert!;
// Components are stored, so this neither re-parses nor allocates.
let parts = new?.components;
assert_eq!;
// PIA item-number form drops the F component.
assert_eq!;
IBAN and BIC
Zahlungsinformation.iban and .bic are the two fields on a BO4E invoice that
money moves against, and the schema declares both as bare strings. An IBAN's
ISO 7064 MOD-97-10 check digits catch every single-character error and
every adjacent transposition.
// Grouping spaces and lowercase normalise away, so a value pasted from a bank
// statement parses; `as_ref()` always returns the compact wire form.
let iban = new?;
assert_eq!;
assert_eq!;
assert_eq!;
assert!; // transposed digits
let bic = new?;
assert_eq!;
assert!; // location code ending in 1, per ISO 9362
The generated Zahlungsinformation keeps both fields as String, deliberately:
it hangs off Rechnung and nothing else, so a masked IBAN
(DE89 **** **** 3000, routine on an invoice) would take the whole invoice down
with it. Run the check on demand instead — the error costs you the field, not the
invoice:
match zahlungsinformation.iban_checked
Multi-version Dispatch
When a storage layer (e.g. PostgreSQL JSONB) writes a bo4e_version column alongside
BO4E JSON, the idiomatic dispatch is a plain match — on the series, not the
exact release:
use ;
Matching the full _version string instead would reject a payload from a sender
one BO4E patch ahead of you — "202607.2.0" against a "202607.1.0" arm — even
though the v202607 types read it perfectly. schema_series() returns exactly
the value the match keys on, so a test can assert the two agree.
This pattern:
- Requires no new rubo4e API —
schema_series()is already on every BO and COM viaBo4eTyped - Is trivially extensible: each new schema series is one
matcharm, and patches inside a series need none - Localises migration to the storage layer; business logic only handles the series it was written for
- Keeps the branch a branch.
AnyBois the sum type over the Geschäftsobjekte, for a payload whose_typis unknown until it is read — it is not a version abstraction, and there is deliberately noAnyVersion: two schema series have different field sets, so anything unifying them would have to erase the difference that made the dispatch necessary
See Schema Versioning for the full upgrade workflow.
Convenience API
Extension traits — flatten Option<Com> to Option<Decimal>
use *; // brings BetragExt, MengeExt, PreisExt into scope
// Replaces the `.as_ref().and_then(|b| b.wert)` chain.
let net = pos.gesamtpreis.wert_decimal; // Option<Decimal> via BetragExt
let qty = pos.positions_menge.wert_decimal; // Option<Decimal> via MengeExt
let unit = pos.einzelpreis.wert_decimal; // Option<Decimal> via PreisExt
Billing and validity helpers
use ;
use date;
// Rechnung — closed billing period, as a RangeInclusive<Date>
if let Some = rechnung.billing_period
// Navigate rechnungsperiode fields directly
let start: = rechnung.period_start;
let end: = rechnung.period_end;
// Iterate line items
for pos in rechnung.positions
// Decimal totals — direct access
let net = rechnung.gesamtnetto_decimal; // Option<Decimal>
let tax = rechnung.gesamtsteuer_decimal; // Option<Decimal>
let gross = rechnung.gesamtbrutto_decimal; // Option<Decimal>
let pay = rechnung.zu_zahlen_decimal; // Option<Decimal> — final amount due
let disc = rechnung.rabatt_netto_decimal; // Option<Decimal> — net discount
let next = rechnung.zukuenftiger_abschlag_decimal; // Option<Decimal>
let adv = rechnung.vorauszahlungen_summe; // Option<Decimal> — sum of advance payments
// Invoice flags — unwrap_or(false), no Option juggling
if rechnung.is_storno
if rechnung.is_original
// Date fields
let due: = rechnung.faelligkeitsdatum_date;
// Rechnungsposition — delivery period from embedded Zeitraum
let von: = pos.lieferung_von_date; // reads lieferungszeitraum.startdatum
let bis: = pos.lieferung_bis_date; // reads lieferungszeitraum.enddatum
let in_period: bool = pos.lieferungszeitraum_contains;
// PreisblattNetznutzung — point-in-time validity check
let valid = preisblatt.is_valid_at;
// Zeitraum — BO4E declares *both* dates inclusive: the period is [start, end]
let range = z.as_inclusive_range; // Option<RangeInclusive<Date>>
let bounds = z.bounds; // (Option<Date>, Option<Date>)
let days = z.whole_days; // Option<i64> — January is 31
let contains = z.contains; // bool — the end date is inside
let dauer = z.duration; // Option<Result<time::Duration, _>>
let start = z.startuhrzeit_parsed; // Option<Result<(Time, Option<UtcOffset>), _>>
Interval conventions are not uniform in BO4E, and this is the trap:
| Kind | Interval |
|---|---|
date-time pairs (vertragsbeginn/vertragsende, von/bis) |
[start, end) |
Zeitraum's date pair |
[start, end] |
Zeitraum's time pair (startuhrzeit/enduhrzeit) |
[start, end) |
Zeitraum's instant pair (all four boundary fields) |
[start, end) |
price-tier bounds (staffelgrenzeVon/Bis) |
[von, bis], plus a gap rule |
enddatum is inclusive — "Enddatum des betrachteten Zeitraums ist
inklusiv", with '2025-01-01' given as the example for both date fields,
so start == end is a valid one-day period. Reading it exclusively drops a day
from every period. as_inclusive_range returns a RangeInclusive so the
convention travels with the value.
tests/interval_conventions.rs reads each
convention out of the committed schema and checks it against the code.
Three Zeitraum values have no time type that holds them, so they keep the
wire string and an accessor parses on demand: dauer is an ISO 8601 duration
(duration() refuses P1Y/P1M rather than guessing their length), and the two
*uhrzeit fields are times of day with a UTC offset.
PreisstaffelSliceExt::select_for picks a price tier, including BO4E's rule that
a value between two tiers "rutscht in die obere Zone" — which a plain
von <= x <= bis scan misses entirely.
Time Series and Units
BO4E carries readings over time in two shapes. A Zeitreihenwert on a Lastgang
or Zeitreihe is a value over an interval; a Messwert on a Zaehlwerk is
the meter's cumulative state at an instant. The first you sum or integrate,
the second you difference.
Interval series — Bo4eTimeSeries
Nothing in the schema requires the entries to be sorted, contiguous, disjoint, or
the length the Lastgang declares. audit() walks them once and reports:
use Bo4eTimeSeries;
let report = lg.audit; // against the span the entries cover
let report = lg.audit_over; // …or the period it was meant to cover
report.gaps; // stretches nothing covers
report.overlaps; // …and stretches more than one entry covers
report.wrong_length; // indices whose length is not zeitIntervallLaenge
report.unplaced; // entries with no resolvable interval, each with a reason
report.unusable; // indices whose status is FEHLT / NICHT_VERWENDBAR
report.is_complete; // the timeline is covered exactly once
report.is_usable; // …and every entry carries a usable value
lg.sum; // None — messgroesse is KW, and adding kW is meaningless
lg.integrate; // Some(450) — Σ value × interval_hours
lg.integrated_unit; // Some(Mengeneinheit::Kwh)
is_complete() is a claim about the timeline, not the readings: a FEHLT entry
still occupies its slot. BO4E requires none of these properties, so nothing here
is wired into .validate().
One reading shape for all three — Bo4eIntervals
Lastgang, Zeitreihe and Energiemenge put a value on a stretch of time in
three shapes that look nothing alike — two of them a Vec<Zeitreihenwert> whose
unit lives on the enclosing BO, the third a single Menge over a Zeitraum.
IntervalReading is the one mapping, and it goes both ways:
use ;
for r in lastgang.intervals
// A power series and an energy series answer in the same unit.
lastgang.total_energy; // Some((400, Mengeneinheit::Kwh)) — kW × hours
zeitreihe.total_energy; // Some((400, Mengeneinheit::Kwh)) — already kWh
energiemenge.total_energy; // Some((400, Mengeneinheit::Kwh)) — one interval
// …and back out again.
let zr = from_intervals;
let lg = from_intervals; // the required field, stated
Unusable readings are skipped rather than counted as zero — a FEHLT slot
carrying 0 is an absence — and audit() is where the gap they leave is
reported.
Register series — Zaehlwerk
BO4E states the formula on wandlerfaktor itself: "Mit diesem Faktor wird eine
Zählerstandsdifferenz multipliziert, um zum eigentlichen Verbrauch im Zeitraum zu
kommen."
let register = Zaehlwerk ;
register.consumption_between; // Ok(2_000) — 50 × 40
register.consumption_between; // Ok(560) — 14 × 40
register.total_consumption; // the whole series
The wrap-around is the trap: 999998 → 000012 is 14 register steps, not
−999 986, and vorkommastelle is what BO4E gives you to know it.
total_consumption() refuses rather than guessing on a meter exchange
(Z78_GERAETEWECHSEL), a fall no register width explains, or a reading in a unit
that does not convert.
Zeitraum's third mode: an instant range
A quarter-hourly Zeitreihenwert states all four boundary fields — BO4E's
"Startzeitpunkt (Datum und Uhrzeit) bis Endzeitpunkt". It is half-open,
[start, end): startuhrzeit is "inklusiv", enduhrzeit "exklusiv" — the
opposite of the date pair on the same struct.
let slot = from_instants;
slot.as_instant_range; // Option<Result<Range<OffsetDateTime>, _>>
slot.instant_duration; // Some(Ok(15 minutes))
slot.contains_instant; // [start, end)
slot.is_instant_range; // does this value state all four fields?
Route on is_instant_range(): the date accessors read the date pair and only
that, so whole_days() on a 15-minute slot is Some(1). A time of day with no
UTC offset is a wall-clock reading, not a moment, so start_instant() returns
ZeitpunktError::MissingOffset rather than guessing. .validate() enforces the
matching rule — with all four fields present, the start instant must be strictly
before the end.
Units have dimensions
Mengeneinheit is one flat enum over energies, powers, a volume, eleven
durations, a percentage and a frequency. rubo4e::units says which may be added
and which convert:
Kwh.dimension; // Some(Dimension::Energy)
Mwh.conversion_factor; // Some(1000)
Kwh.conversion_factor; // None — another dimension
ViertelStunde.exact_duration; // Some(15 minutes)
Monat.exact_duration; // None — no fixed length
menge.convert_to; // through the base unit
menge.energy_over; // 400 KW → 100 KWH
menge.as_duration; // reads zeitIntervallLaenge
MONAT / QUARTAL / HALBJAHR / JAHR have no factor and no duration — the
same call iso8601_duration makes about P1Y. is_extensive() separates what
may be summed over a period from what may not, which is what makes sum() and
integrate() mean different things: for a stated unit, exactly one of them
answers.
See Time Series & Units.
Lokationsbündelstrukturen
BO4E v202607.1.0 defines no Lokationsbuendel Geschäftsobjekt, and BoTyp
has no LOKATIONSBUENDEL member. The bundle is a Lokationszuordnung plus two
13-digit BDEW codes: lokationsbuendelcode says which structure a Netzanschluss
has, and lokationsbuendelObjektcode on each participant says where in it that
object sits.
rubo4e ships EDI@Energy's "Codeliste der Lokationsbündelstrukturen" (BDEW
v1.0, applicable from 1 October 2024) as static data — 15 structures, 27 object
codes — and reads a decoded bundle through it:
use ;
// An object code is a complete coordinate: type, direction, level.
let rolle = technische_ressource.objektrolle.unwrap;
assert_eq!;
assert_eq!; // a § 14a SteuVE
assert_eq!;
// A view over the Lokationszuordnung — not a new Geschäftsobjekt, and it
// serialises as nothing.
let buendel = zuordnung.buendel;
buendel.verbrauchs_ressourcen; // heat pumps, wallboxes — not PV, not a battery
buendel.objekte_auf_ebene; // everything hinterschaltet
// …checked against the structure it declares.
let report = zuordnung.audit_buendel;
report.is_conformant; // false → report.befunde says why
audit_buendel() reports unknown or malformed codes, an object filed under the
wrong type, a code the declared structure does not use, and every cardinality —
including "exactly one Marktlokation" met by zero. Like Bo4eTimeSeries::audit
it is a data-quality report, not .validate().
See Lokationsbündel.
Namespaced ZusatzAttributs
BO4E gives every BO and COM a zusatzAttribute list for what the standard has no
field for, and says nothing about how two systems writing into it stay out of each
other's way. "id" written by a market-communication layer and by a household
model is one entry, and the second write wins.
use ;
sr.set_zusatz_attribut_in;
sr.set_zusatz_attribut_in;
sr.zusatz_attribut_str_in; // Some(ski)
sr.zusatz_attribut_namespaces; // ["hems", "mako"]
sr.remove_zusatz_attribute_in; // strip before handing on
// Typed values, so a code list BO4E has not published stays a type in your crate.
tr.set_zusatz_attribut_as_in?;
let v: Steuerungsvariante = tr.zusatz_attribut_as_in.unwrap?;
The wire form is the flat BO4E name — {"name": "hems:eebus-ski", "wert": "…"} —
so any BO4E reader still sees an ordinary ZusatzAttribut, and a foreign prefix
round-trips untouched. mako, hems, edmd and mabis are registered;
Namespace::new takes any well-formed prefix. AttributKey<T> pins a key and
its value type as one const both sides import, and
zusatz_attribut::well_known holds the ones this crate registers.
ZusatzAttributeExt is on every BO4E type that declares the field —
ZusatzAttribut itself being the one that does not.
rubo4e supplies the mechanism, not the values: a Steuerungsvariante enum here
would invent a code list the market has not published. See
Beyond the Schema for
what BO4E does and does not model.
Beyond the schema: when a market rule outruns BO4E
The market rules keep moving; BO4E carries only what fits an existing
Geschäftsobjekt. rubo4e adds what reads a BO4E field — and says where BO4E
already has what you were about to add.
A generated enum is never forked. A value added to one emits a wire string
every other BO4E implementation decodes as Unknown, and which this crate's own
ensure_known_enums() then rejects. The answers, in order: BO4E already has it
elsewhere; the state is readable from the fields it does have; or it rides in a
registered ZusatzAttribut key.
use Aggregationszustaendigkeit;
use ;
// The Bilanzierungsgebiet EIC BO4E leaves as a String, checked as a Y-EIC (Area)
// — which is what tells it from a Bilanzkreis (`11X…`).
malo.bilanzierungsgebiet_checked; // Some(Ok(BilanzierungsgebietId))
// A Zählpunkt (eMob) is *not* a MeLo-ID (BK6-20-160 §1.6.2), and cannot become one.
let zp = new;
assert_eq!;
// "Ruhende" Aggregationsverantwortung: an absent field plus Modell 2, not a
// `RUHEND` value no other implementation would read.
bilanzierung.aggregation_ruht;
bilanzierung.aggregationszustaendigkeit; // Uenb | Vnb | Ruhend | Unbekannt
Every addition passes one test: does it read, type, or guard a value that arrives in a BO4E payload? A domain aggregate of another standard does not — a Bilanzierungsgebiet's Stammdaten read no BO4E field, so they are not modelled here.
Three things that look missing from BO4E, and are not:
| Looks missing | Actually |
|---|---|
Zeitreihentyp::Ngz |
Zeitreihentyp is chapter 1 of the BDEW Codeliste der Zeitreihentypen — the Summenzeitreihentypen of DE7111. NGZ is not a code there in any published version (1.1a 2012 … 1.1d 2021); it appears only inside the explanation of NZR. A Netzgangzeitreihe is an MSCONS PID 13018 payload — in BO4E a Lastgang at a Zaehlpunkt. |
Verbrauchsart::EMobilitaetsladesaeule |
BO4E models the charging point on the technische Ressource: EMobilitaetsart::EMobilitaetsladesaeule and TechnischeRessourceVerbrauchsart::EMobilitaet. Verbrauchsart is the Kraft/Licht/Wärme categorisation. Do not use a ZusatzAttribut for this. |
| mandatory fields blocking a mobile MaLo | Marktlokation has no required field in the schema, and this crate's only cross-field rule is at most one Ortsangabe — a conflict rule, not a presence rule. A Modell-2 MaLo with no address validates. |
See Beyond the Schema.
JSON Handling
use Bo4eJsonExt;
use Marktlokation;
let malo: Marktlokation = todo!;
// Serialize
let german = malo.to_json_german?; // {"marktlokationsId":"…","sparte":"…",…}
let snake_case = malo.to_json_snake_case?; // {"marktlokations_id":"…","sparte":"…",…}
let canonical = malo.to_json_canonical?; // sorted keys, stable for hashing/signing
// Deserialize
let restored = from_json_german?;
Unknown JSON fields are preserved through round-trips via the _additional
extension-data map (requires json feature). This allows forward-compatible
handling of new BO4E fields without library updates. Keys and values come back
unchanged, and the top-level ones keep their arrival order; key order inside a
nested extension object does not survive, because serde_json::Value stores an
object in a sorted map.
Decimal amounts serialize as JSON strings ("wert": "119.00"), matching
BO4E-python. Deserialization accepts JSON numbers too, the way go-bo4e writes
them — but only the string spelling is exact. A JSON number has already passed
through f64 before any Rust deserializer sees it, so 119.00 arrives as 119
(scale lost) and anything past ~15 significant digits is rounded. Nothing in the
German energy market comes near that many digits, so this is a fidelity question
rather than a correctness one; decimal_serde::decimal_from_json_number_count()
counts every decimal read from a number — integers included, since Go writes a
whole amount as 119 — so you can tell which spelling your producers use. See
Serialization.
The snake_case mapping is an exact table emitted by the generator, not a runtime
heuristic, so from_json_snake_case(to_json_snake_case(x)) == x holds for every
generated type — which a heuristic cannot: hoechstpreisHT, kundengruppeKA, and
Sigmoidparameter's A/B/C/D all invert to a different camelCase name.
BO4E metadata keys (_typ, _version, _id) pass through byte-for-byte, and so
does extension data including everything nested under it — the transform
switches off at the edge of the schema, so a vendor blob holding {"a": 3} is not
rewritten to {"A": 3}. One ambiguity it cannot resolve: a top-level extension
key that is a field's own snake spelling is indistinguishable from that field, so
prefer the German mode whenever extension data is in play. See
Serialization.
Parsing untrusted input
Preserving unknown fields is a memory-growth surface, so every deserialization path — hardened or not — caps extension fields at 128 per struct and extension keys at 256 bytes, and rejects documents nested deeper than 128 levels.
For payloads from outside your trust boundary, the _hardened entry points add
four opt-in budgets on top:
use ;
let malo = from_json_german_hardened?;
// …or narrowed, where you know your own payloads:
let strict = untrusted_defaults
.with_max_payload_bytes
.with_max_extension_field_count; // reject any unknown field
max_payload_bytes is checked before a byte is parsed; the other three are
enforced during the single parse pass, at every nesting level — extension
data buried in a nested COM is charged to the same budget as extension data on
the root. Every limit that fires bumps a process-wide counter readable via
json_limit_hit_counters(), exported to the metrics ecosystem when the
metrics feature is on.
These bound what a payload retains, not what parsing it allocates:
#[serde(flatten)] buffers a struct's unrecognised fields before the extension
map sees them, so max_payload_bytes is the cap that bounds peak memory — set it
first. Nor do any of them bound the object graph: [{},{},{}…] is three wire
bytes and a full struct per element, so size max_payload_bytes against the
expanded cost and keep a concurrency limit in front of the endpoint.
See Serialization for the exact scope of each limit.
Validation
use Validate as _;
use Validated;
use Marktlokation;
// Direct validation — returns garde::Report on failure
let malo: Marktlokation = todo!;
malo.validate?;
// Type-safe wrapper — only constructible via validation
let validated = new?; // Err(garde::Report) if invalid
let inner: &Marktlokation = &validated; // Deref to inner type
// …and it validates on the way *in*, so a request body cannot skip the check:
let malo: = from_str?;
.validate() is recursive: it checks the value's own rules and descends into
every nested BO, COM, and identifier, reporting each failure at its path
(rechnungsperiode, kostenbloecke[0].kostenpositionen[0]). One call covers the
tree.
Cross-field rules run automatically via #[garde(custom(...))] attributes on the
generated types:
| Type | Rule |
|---|---|
Marktlokation, Messlokation |
at most one of lokationsadresse(messadresse) / geoadresse / katasterinformation |
Vertrag |
vertragsbeginn strictly before vertragsende |
Bilanzierung |
bilanzierungsbeginn ≤ bilanzierungsende |
Zeitraum |
at least one temporal field; startdatum on or before enddatum (both bounds inclusive); with all four boundary fields, start instant strictly before end instant (the end is exclusive) |
Rechnung |
one currency throughout; gesamtnetto + gesamtsteuer == gesamtbrutto; steuerbetraege sum to gesamtsteuer |
Kostenposition |
einzelpreis × menge rounds to betrag_kostenposition at its own scale |
Every rule traces to a sentence in the BO4E schema, and only those do, so
.validate() answers "does this conform to BO4E" — a claim you can make about
a counterparty's document. This crate's own judgements live in
validation::current::quality and are called by name:
use quality;
rechnung.validate?; // conformance
rechnung_totals_are_complete?; // opt-in house rule
At most one Ortsangabe, not exactly one: BO4E states mutual exclusivity, not
presence. And it has no reference type, so a location referenced from a
Rechnung or a Vertrag is a full Marktlokation carrying little more than its
ID — which makes the empty case the common one.
Not asserted: presence (BO4E marks almost every field optional, so a
Validated<T> does not prove your AHB's mandatory fields are there) and
zuZahlen (its equation names a rabattBrutto field v202607 does not ship).
Import from rubo4e::validation::current, the counterpart of rubo4e::current,
so no file has to name a schema version.
See Validation.
OpenAPI / JSON Schema
// schemars — JSON Schema (requires `schemars` feature)
let schema = schema_for!;
// utoipa — OpenAPI 3.1 (requires `utoipa` feature)
let schema = schema;
Every identifier emits a pattern, a German description and a valid example, and both generators emit the same three:
They come from rubo4e::identifiers::schema, one table both derives read — and
each example is checked against the type's own constructor, so it carries a valid
check digit. See Ecosystem.
SQLx Integration
// Requires the `sqlx` feature — implements Type, Encode, Decode and
// PgHasArrayType for every identifier and every generated enum.
// No `json` feature needed: everything round-trips through &str.
// Bind directly as a typed identifier
query
.bind
.execute.await?;
// Decode directly — runs the same validation as new()
let id: MaloId = row.try_get?;
// Vec<Id> binds to a TEXT[] column
query
.bind
.fetch_all.await?;
// Works in FromRow structs too
Identifiers reject invalid values on decode. Enums decode leniently —
an out-of-schema string becomes Unknown, mirroring the serde path — so use
from_wire on a String column where that must be an error instead.
Documentation
hupe1980.github.io/rubo4e — guides and design notes. docs.rs/rubo4e — per-item API reference. CHANGELOG — release history and upgrade notes.
| Guide | Covers |
|---|---|
| Architecture | Workspace layout, module tree, feature-gate reference |
| Identifiers | Every identifier type, its validation rules, and the BDEW check-digit procedures |
| Lokationsbündel | The EDI@Energy codelist, the two BDEW codes, and audit_buendel() |
| Beyond the Schema | What happens when a market rule outruns BO4E — the test, the placement, and BK6-20-160 Modell 2 worked through |
| Serialization | JSON output modes, extension data, hardened parsing, namespaced ZusatzAttributs |
| Validation | The three validation layers and Validated<T> |
| Time Series & Units | Interval and register series, Zeitraum's instant mode, unit dimensions |
| Schema Versioning | Version modules, current, and the upgrade workflow |
| Ecosystem | sqlx, schemars, utoipa, strum integrations |
| Code Generator | How generation works and how to re-run it |
| Testing | The seven testing layers and how to run each |
The site sources live in site/ and are built with Zola.
MSRV
The minimum supported Rust version is 1.88, declared as rust-version in
Cargo.toml and verified in CI on every push. MSRV advances only when the
current floor is two stable releases behind, and a bump is a minor version
change, never a patch.
The floor is set by the dependency tree rather than by this crate's own source:
time and home (via sqlx) both require 1.88. Because Cargo's
default resolver picks the newest semver-compatible dependency without regard to
rust-version, a toolchain below the floor fails at resolution time with
rustc 1.87.0 is not supported by the following packages rather than at compile
time. On an older toolchain, either pin those dependencies back with
cargo update <crate> --precise <version> or enable Cargo's MSRV-aware resolver.
License
Licensed under either of Apache License 2.0 or MIT License, at your option.