makotest (Python)

Python test & simulation toolkit for MaKo platforms: BDEW identifier check digits, the published answer-Frist table, AHB-validated EDIFACT, counterparties that answer in EDIFACT, and a pytest plugin — over the same Rust core the platform runs.

makotest — Python test & simulation toolkit

makotest builds regulator-conformant EDIFACT, simulates the counterparties a MaKo platform talks to — in EDIFACT, so a test can feed the answer back — and asserts on the result.

It is not mako-specific. Everything it drives is a public wire contract (EDIFACT over AS4, REST, CloudEvents), so it can exercise any MaKo implementation.

from makotest import antwort_obligation, malo_from_base, validate_edifact

malo_from_base("5123869601")          # '51238696012' — BDEW check digit applied

o = antwort_obligation(55001)         # what a Netzbetreiber owes on an Anmeldung
o.clock_time                          # '11:00' — a clock time, not n × 24 h
o.due_at("2026-03-02T09:00:00Z")      # '2026-03-03T11:00:00+01:00'

validate_edifact(utilmd_bytes, "2026-10-01").is_valid   # MIG + AHB + semantic

The binding boundary

The toolkit is a PyO3 extension over the same Rust crates the platform runs. Nothing regulated is reimplemented in Python: a second implementation drifts from the BDEW documents at the first Formatumstellung, and a harness that disagrees with production about what is valid — or about when a Frist expires — is worse than none.

The rule: anything a regulator defines in a table is Rust; anything shaped by test ergonomics is Python.

ConcernHome
EDIFACT build + MIG/AHB/semantic validationRust — edi-energy
Release per format versionRust — edi-energy::registry
Identifier check digits (MaLo, MP-ID, EIC, §8.2 resources)Rust — rubo4e::identifiers
Werktag calendar and acknowledgement clocksRust — mako-fristen
Answer Fristen per PrüfidentifikatorRust — mako-fristen::antwort
Antwortcodes per EntscheidungsbaumRust — mako-pruefung::codes
Counterparty behaviour, EPEX curves, fixturesPython
graph TB
    subgraph py["Python — test ergonomics"]
        FIX["fixtures · EPEX curves"]
        SIM["counterparty simulators"]
        PLUG["pytest plugin"]
    end
    subgraph pyo3["makotest — PyO3 abi3 extension"]
        BIND["thin binding layer"]
    end
    subgraph rust["Rust — the same crates production runs"]
        EDI["edi-energy<br/>build · MIG/AHB/semantic validate"]
        FRIST["mako-fristen<br/>Werktag calendar · answer Fristen"]
        BO["rubo4e::identifiers<br/>MaLo · MP-ID · EIC check digits"]
    end

    FIX --> BIND
    SIM --> BIND
    PLUG --> BIND
    BIND --> EDI
    BIND --> FRIST
    BIND --> BO
    EDI -.->|"same code path"| PROD["makod in production"]
    FRIST -.->|"same code path"| PROD

Because validation runs the platform's own AHB engine, makotest proves process and integration behaviour. It is not an independent check of format conformance — the BDEW reference examples remain the authority there.


Install

pip install makotest                  # identifiers, Fristen, EDIFACT, simulators
pip install 'makotest[hypothesis]'    # + property-based strategies

Wheels are abi3 (abi3-py311) — one wheel serves Python 3.11 and later. No runtime dependencies.

The wheel also installs a makotest command, so the same answers are reachable from a shell by whoever is holding a real message rather than writing a test:

$ makotest validate inbound.edi --on 2026-04-01
UNB 4012345000023:14 → 9900357000003:500  ref=REF1
#0 UTILMD S2.1 pid=55001 INVALID
    error    [SEM-UTILMD-LOKATIONS-ID] SG4/LOC[1].0: not a Messlokations-ID

INVALID on 2026-04-01

$ makotest frist 55001 --received 2026-03-02T09:00:00Z
$ makotest id 9900357000004         # → satisfies NEITHER check-digit procedure
$ makotest pids UTILMD --on 2026-04-01 --sparte STROM
$ makotest codes --pid 55001        # what a counterparty may answer with
$ makotest versions

Exit status is the contract: 0 when the answer is yes, 1 when it is no, 2 when the question was malformed. A vacuous pass — an interchange that validated because no AHB rule was applied to its Prüfidentifikator — exits 1, because a shell gate that reported success for one would be decoration. --json emits the same report machine-readably.


Identifiers

A random 11-digit string is a valid Marktlokations-ID one time in ten, and a random 16-character string is essentially never a valid EIC. A test that invents one exercises the rejection path while claiming to test the happy path, so every family has a constructor:

from makotest import (
    bilanzierungsgebiet_from_prefix, bilanzkreis_from_prefix,
    malo_from_base, mp_id_from_base, resource_id_from_base,
)

malo_from_base("5123869601")                    # '51238696012'
mp_id_from_base("990035700000", "bdew")         # '9900357000003' — §8.1
mp_id_from_base("401234500002", "gln")          # '4012345000023' — EAN-13
bilanzkreis_from_prefix("11XSWKIEL------")      # EIC Party  — a Bilanzkreis
bilanzierungsgebiet_from_prefix("11YSWKIEL------")  # EIC Area — a Gebiet
resource_id_from_base("nelo", "E000000001")     # 'E0000000019' — §8.2

Two traps the API keeps apart.

A Marktpartner-ID has two check-digit procedures. §2.3 of the BDEW Anwendungshilfe defines the Lok- und Waggon-Kennzeichnungsverfahren for BDEW- and DVGW-Codenummern and the GS1/EAN-13 procedure for a GLN. They disagree on almost every base, and the prefix does not decide it — which is why mp_id_from_base takes the scheme, and mp_id_check_digit_schemes returns a list (a code can satisfy both). An empty list means every conformant counterparty refuses it.

A Bilanzkreis is not a Bilanzierungsgebiet. The first is an ENTSO-E Party (object type X), the second an Area (Y). Both are 16 characters, both carry a valid check character, and MSCONS SG6 carries both as free text under different LOC qualifiers — so a series filed against the wrong one is a misfiling the BIKO cannot tell from a correct submission.


Fristen: four shapes, one table

Regulated processes are deadline-driven, so time is an input, never ambient. The calendar is BDEW's conservative-inclusive one: a day observed as a holiday in any German state is a non-Werktag, and 24.12. and 31.12. count as holidays (GPKE Teil 1). No Frist is ever computed shorter than the Festlegung requires for some participant.

Which date — calendar arithmetic

from makotest import add_werktage, is_werktag, next_werktag

is_werktag("2026-01-06")       # False — Heilige Drei Könige (BY, BW, ST only)
add_werktage("2026-12-24", 2)  # '2026-12-29' — 25/26 holidays, 27/28 weekend
next_werktag("2026-11-07")     # '2026-11-09' — Saturday rolls to Monday

Which moment — and there is no single formula

"A Werktage Frist expires at 17:00 Europe/Berlin" is true of the WiM MSB-Wechsel windows and of nothing else:

shapeWindowExample
werktag_ata clock time on the n-th Werktag after the ÜT55001 → 11:00 on the 1. WT
same_day_atthat clock time on the ÜT itself55013 → 15:00 am ÜT
end_of_werktagthe end of the n-th Werktag44001 → Ablauf 4. WT
werktage_at_cutoff17:00 Europe/Berlin on the n-th Werktag55039 → 3 WT

GPKE alone uses the first two, and they share a clock time: „15:00 Uhr am ÜT" and „15:00 Uhr des 1. WT nach dem ÜT" are a full day apart, so werktage is what separates them and o.window renders the pair. Sizing any of these the same is wrong in both directions, and the loose direction is silent: it reports a lapsed Frist as still running. So ask the table:

from makotest import antwort_obligation, antwort_obligations, assert_deadline_is

o = antwort_obligation(55001)
o.family, o.answered_by            # 'gpke', 'NB'
o.shape, o.clock_time, o.werktage  # 'werktag_at', '11:00', 1
o.window                           # '11:00 on the 1. WT'
o.bestaetigung_pid, o.ablehnung_pid, o.ebd   # 55002, 55003, 'E_0622'
o.source        # 'BK6-24-174 GPKE Teil 2, SD Lieferbeginn Prozessschritte 5/6'
o.due_at("2026-03-02T09:00:00Z")   # '2026-03-03T11:00:00+01:00'

antwort_obligations()              # every published obligation, four families
assert_deadline_is(response["deadline"], received=received, pid=55001)

antwort_obligation returns None when no Festlegung this codebase has read quantifies the window. That is unknown, never unbounded — GeLi Gas 44020's Frist is set per Netzbetreiber, so it is absent rather than guessed.

The acknowledgement and the business answer are separate clocks — 45 minutes versus days for Strom UTILMD. Conflating them is the classic WiM error.

FunctionWindow
antwort_deadline(pid, received)the published window for that process
deadline_at_werktage(received, n)n Werktage → 17:00 Berlin (WiM shape)
end_of_werktag_after(received, n)end of the n-th Werktag (GeLi Gas shape)
next_werktag_at(received, "11:00")clock time on the 1. WT (GPKE shape)
berlin_instant(date, "09:00")that wall clock, with that date's own offset
berlin_mtu_count(date, 15)market time units the day has — 92, 96 or 100
contrl_due_at(received)6 hours — CONTRL
aperak_strom_due_at(received)45 minutes on a weekday
aperak_gas_folgeprozess_due_at(received)next Werktag 12:00
aperak_gas_initialprozess_due_at(received)3 Werktage
add_hours(received, h)wall-clock hours — runs through weekends

The offset follows the CET/CEST transition; rendering a deadline in UTC hides the hour that makes it correct, and a fixed offset is wrong for half the year — which is why berlin_instant resolves it from the date rather than carrying one. And no calendar-day approximation is sound: one Werktag from Wednesday 30.12.2026 expires Monday 04.01.2027, five calendar days later.

assert_frist_met(pid, received=…, answered_at=…) measures an answer against the same window, and names the Fundstelle when it was late.


Building EDIFACT

The send date picks the format version, and the release follows from it. Pinning a release by hand and validating on a date where a different one is in force produces findings that describe the mismatch rather than the message.

from makotest import UtilmdTransaction, build_interchange, build_utilmd

msg = build_utilmd(
    55001,
    sender="4012345000023",
    receiver="9900357000003",
    on="2026-04-01",                     # → release S2.1, DTM+137:202604010000+00
    transactions=[
        UtilmdTransaction(
            "VORGANG-1",                  # IDE+24 — never a location ID
            locations=[("melo", "DE00014559929E00856996N5139699L01")],
            dates=[("92", "20260501")],   # SG4 DTM — Beginn zum
            references=[("Z13", "55001")],
        )
    ],
)
wire = build_interchange(
    sender="4012345000023", receiver="9900357000003",
    dar="REF1", messages=[msg], on="2026-04-01",
)
# UNB+UNOC:3+4012345000023:14+9900357000003:500+260401:0000+REF1'…UNZ+1+REF1'

A Zuordnung's Bilanzkreis is a whole segment group, so it is named once rather than assembled:

UtilmdTransaction("VORGANG-1", locations=[("melo", melo)],
                  bilanzkreis="11XBK-EEG-----1")
# → SEQ+Z79+1 · PIA+5+9991000002082:Z11 · CCI+Z66 · CAV+ZV4:::11XBK-EEG-----1

The Produkt-Code and the ZV4 qualifier are fixed by the AHB, so a test writing them out would be transcribing constants it cannot check. The EIC is a Party code — draw one with bilanzkreise().

The group is Muss on 55001, 55077, 55600, 55601, 55014 and 55608 (UTILMD AHB Strom 2.1 §5.3): without a Bilanzkreis the NB cannot assign the Marktlokation to the LF, so an Anmeldung that omits it is refused rather than sent.

An Ablehnung that reports a third party's refusal carries a second status: antwort_dritter="A32" writes SG4 STS+Z35++A32:E_0624, the Altlieferant's own ground from its own tree. The AHB makes it Muss on A50 and A57.

build_utilmd and build_mscons return a message (UNHUNT); the wire unit a market partner receives over AS4 is an interchange. The UNB qualifier after each party ID is derived from the ID — 14 for a GLN, 500 for a BDEW code — so it cannot contradict it.

BuilderProduces
build_utilmd / build_msconsthe request or the meter data
build_aperak / build_contrlan acknowledgement from scratch
build_aperak_for(received) / build_contrl_for(received)the acknowledgement, parties mirrored and the reference echoed
build_answer(received, answer_pid)the Bestätigung or Ablehnung, mirroring the request's SG4 object and references
message_index= on eitherwhich message of a multi-message interchange is answered
build_remadvthe answer to an invoice — a Zahlungsavis, or a Rückmeldung with its AJT
build_ordersthe WiM / ESA / Sperrung request
build_ordrspthe WiM / ESA answer to an ORDERS, with its SG2 AJT
build_iftstathe WiM status message — SG15 STS is a (category, reason) pair
build_quotesthe ESA Angebot — bindungsfrist is a duration, never a date
build_interchangethe UNB/UNZ envelope

release_for(message_type, on, sparte), releases(message_type) and format_versions() expose what the build can validate against.

Building and validation are deliberately separate steps: a test must be able to construct a knowingly-invalid message and assert that the right rule rejects it.


Validation

from makotest import assert_edifact_valid, assert_rule_fires, validate_edifact

report = validate_edifact(wire, "2026-04-01")
report.envelope.sender_qualifier      # '14' — derived from the party ID
report.envelope.is_structurally_valid # UNZ count and control refs agree
report.messages[0].rules_applied      # were AHB rules really applied?
report.errors[0].position             # 'IDE' — plus [element].component when known
report.errors[0].rule_origin          # 'semantic' — the layer that fired

assert_edifact_valid(wire, on="2026-04-01")
assert_rule_fires(bad, "SEM-UTILMD-LOKATIONS-ID", on="2026-04-01")

The report covers the whole interchange: the envelope's structural integrity and every message inside it. Validating only the first is how a broken second one gets shipped. The single-message accessors (report.pruefidentifikator, …) raise on a multi-message interchange rather than answering for one of them.

rules_applied is the guard against vacuous validation. A Prüfidentifikator the profile set has no rules for validates having checked nothing — is_valid comes back true — so assert_edifact_valid refuses such a pass instead of reporting success. assert_rules_applied is the same check for a message you expect to be invalid.

rule_origin separates a syntax failure (parse, directory) from an application one (mig, ahb, semantic, custom). That is the distinction between an interchange a counterparty answers with a CONTRL and one it answers with an APERAK.

Two failures, two exceptions

AssertionError means the system under test is wrong. ValueError means the test is — a Prüfidentifikator with no published Frist, an event pattern the catalog cannot satisfy, two mutually exclusive arguments. An assertion that cannot fail is this toolkit's central failure mode, and it should not look like a system defect.


CloudEvents

EDIFACT is one wire contract a MaKo platform exposes; the event stream is the other. Asserting on it carries the mirror image of vacuous validation: a test naming a type the platform does not declare — a typo, or one retired by a rename (de.edmd.* became de.messwert.*) — passes forever as "no such event was emitted", and that is precisely what a missing-event assertion expects to find.

So the catalog is bound rather than copied, and so is the glob matcher every subscription mechanism in the platform uses.

from makotest import assert_event_emitted, assert_no_event_emitted, find_events

found = assert_event_emitted(webhook_bodies, "de.mako.process.*", subject=malo)
found["data"]["status"]

assert_no_event_emitted(webhook_bodies, "de.mako.aperak.timeout")
find_events(webhook_bodies, "de.*.rechnung.*")     # `*` any run, `?` one char

A pattern the catalog cannot satisfy raises rather than filtering to nothing — otherwise assert_no_event_emitted passes on a typo, forever.

assert_cloudevent checks the envelope against CloudEvents 1.0:

CheckWhy
the four required context attributes present, specversion == "1.0"a receiver rejects the event otherwise
time present and RFC 3339optional in the spec, demanded here: an event that cannot be placed on the clock cannot be reconciled against a Frist
type is in the platform's catalogan invented or retired type matches nothing, silently
extension keys are §3.3-legallowercase alphanumeric, and never a core attribute — a collision serialises the key twice
data and data_base64 are not both present§3.1 makes them exclusive: two payloads, no rule for which one wins

data_base64 is the one JSON-format member that is neither a context attribute nor a legal extension name, and it is accepted — an envelope check that knew only the other members would reject a conformant binary event.

FunctionAnswers
event_types()every declared type, sorted
event_type_exists(t)is this a real type, or a typo/rename?
event_matches(pattern, t)would this subscription deliver that event?
event_types_matching(pattern)everything it would deliver — empty means a dead subscription

Counterparty simulators

Each simulator models what a counterparty does — including what it does not do. Silence is the mode worth having: a platform that never sees it is never tested against its own Fristen, and that is where regulated processes fail.

def test_nb_bestaetigt(nb_sim, anmeldung):
    nb_sim.on(55001).bestaetigung(antwort_code="A51", ebd="E_0623")
    reply = nb_sim.receive(anmeldung, received_at="2026-03-02T09:00:00Z")

    assert reply.pid == 55002
    assert reply.due_at == "2026-03-03T11:00:00+01:00"
    platform.ingest(reply.business)      # a real interchange, not a dict

def test_frist_faellt(nb_sim, anmeldung):
    nb_sim.on(55001).timeout()           # no answer, not even an acknowledgement
    assert not nb_sim.receive(anmeldung)

def test_verspaetete_antwort(nb_sim, anmeldung):
    nb_sim.on(55001).bestaetigung(delay_werktage=3)   # right message, wrong day
    reply = nb_sim.receive(anmeldung, received_at="2026-03-02T09:00:00Z")
    assert reply.answered_at > reply.due_at

The reply is a rendered interchange, built by the platform's own builders with the parties mirrored and the request's SG4 IDE object and RFF references echoed. An unconfigured partner acknowledges but sends no business answer, so a forgotten binding exercises the deadline path rather than quietly passing.

It remembers what it accepted

A Netzbetreiber holding an open Vorgang for a Marktlokation does not answer a second Anmeldung for it the way it answered the first — E_0622 publishes A06 „Andere Anmeldung in Bearbeitung" for exactly that. Without that memory, a platform that re-sends a request it has already had confirmed is never contradicted and the test still passes.

nb_sim.on(55001).bestaetigung(antwort_code="A51", ebd="E_0623")
nb_sim.on(55001).bei_offenem_vorgang().ablehnung(
    antwort_code="A06", process_dates=[("Z07", "20260501")]
)

nb_sim.receive(anmeldung).pid          # 55002 — confirmed, Vorgang opened
nb_sim.receive(anmeldung).pid          # 55003 — A06, the repeat meets it
nb_sim.vorgaenge.schliessen(melo)      # Storno; the next request is a first one

Only a Bestätigung opens a Vorgang: an Ablehnung leaves the Lokation free, because the refusal is the reason to resend and a counterparty still holding it would refuse the correction too. The register is keyed on the Lokation, never the Vorgangsnummer — that is the sender's reference and a duplicate carries a new one, so keying on it would make every duplicate look like a first request. A repeat binding falls back to the unconditional one when the Lokation is free, so binding only the repeat case cannot silently answer nothing. sim.vorgaenge.offene is the assertion surface.

Reading an answer back

MessageReport.vorgaenge exposes the SG4 Vorgänge of a parsed message, so a test asserts on content rather than matching raw bytes — where LOC+Z16 and LOC+Z17 differ by one character and a substring check passes on the wrong one.

v = validate_edifact(reply.business, on="2026-04-01").messages[0].vorgaenge[0]
v.vorgangsnummer          # echoed from the request
v.location("melo")        # asked by type, never by position
v.iso_date("92")          # '2026-05-01'
v.antwort_code, v.antwort_codeliste     # 'A06', 'E_0622'

dates and date() stay raw: UTILMD SG4 dates are DE 2379 format 303, so the wire carries CCYYMMDDHHMMZZZ and a zone-less 303 is malformed — a normalised view would hide that. iso_date() answers which day the Vorgang names.

Misbehaving on purpose

Three ways for a partner to misbehave, because a platform that only ever sees a punctual conformant one has never had its Fristüberwachung exercised: .timeout() says nothing at all, .antwort(pid=…) answers with a PID the AHB does not assign, and delay_werktage= sends a conformant answer after the window has closed.

An interchange carries several messages, each a separate Vorgang. The reply answers all of them, in one interchange, and every accessor that could only describe one — reply.pid, reply.antwort_code, reply.due_at — raises rather than speaking for the rest; read reply.pids, reply.antwort_codes, reply.due_ats. The deadline is per Vorgang because the window is a property of the request: a 55001 and a 55004 in one interchange run on two clocks. Each outbound interchange gets its own Datenaustauschreferenz from a per-simulator counter: UNB DE0020 identifies the interchange to the receiver, so a reused one is a duplicate every conformant receiver may discard.

Answers bind to request PIDs. .on(55002) raises — 55002 answers 55001 and is not something a partner can be asked — and the refusal sits on .on() because .timeout() and .antwort() never consult the answer table.

The answer PIDs are not guessed

MarktpartnerSim resolves its answer from the AHB table in edi-energy — the same table mako-gpke and mako-geli-gas derive their outbound response PID from, pinned by conformance tests on both sides. A simulator computing Anfrage + 1 would be wrong twice over:

AnfrageBestätigungAblehnungWhy
550015500255003the regular pattern
55077550785508055079 is unassigned
4402044021noneconfirmable, never rejectable
44019nonenoneneither answer exists

.antwort(pid=...) bypasses the table when the point of the test is an adversarial answer — a counterparty replying with the wrong PID is a thing that happens, and a platform should reject it. assert_answer_pid is the assertion for the conformant case.

An Ablehnung states its Antwortcode on the wire, in SG4 STS+E01 — see Antwortcodes below.

Antwortcodes

The answer PID says a counterparty refused; the Antwortcode says why, and it rides SG4 STS+E01 — DE 9013 the code, DE 1131 the Codeliste it came from. The AHB marks the segment Muss on every Antwortnachricht.

from makotest import antwort_code, antwort_codes, antwort_codes_for_pid

c = antwort_code("E_0622", "A06")
c.bedeutung           # 'Andere Anmeldung in Bearbeitung (Prüfschritt 70)'
c.cluster             # 'ABLEHNUNG'
c.ist_zustimmung      # False
c.wire_codeliste      # 'E_0622' — what DE 1131 carries
c.braucht_bemerkung   # does the BDEW demand an FTX+ACB alongside?

antwort_codes("E_0623")        # the tree's whole outcome space
antwort_codes_for_pid(55001)   # via the tree the answer-Frist table names

Three properties are why the catalogue is bound rather than written down as strings in a test.

The catalogue serves three wires: SG4 STS+E01 on a UTILMD, AJT on a REMADV, SG2 AJT on an ORDRSP. One antwort_code(tree, code) for all of them.

Which matters because an obligation whose answer message type cannot be built is one no test can answer: 55 of the 58 published obligations are answerable — UTILMD 30, ORDRSP 11, IFTSTA 7, QUOTES 5, ORDERS 2. The three that are not carry answer PIDs absent from the compiled profiles.

A code has no meaning without its tree. A02 is „Vorlauffrist nicht eingehalten" in E_0607 and „Marktlokation nimmt nicht an der Marktkommunikation teil" in E_0622. Every lookup names the tree, and so does the assertion:

assert_antwort_code(reply.antwort_code, ebd="E_0622", accepted=False)

The Cluster decides which PID carries the answer — a property of the code, not a boolean the test supplies. ist_zustimmung is None off the agreement axis: E_0595 states whether a Stammdatenänderung follows, and reading that as a refusal inverts the answer.

DE 1131 is not always the EBD number. Every WiM MSB-Wechsel, Weiterverpflichtung, Gerätewechselabsicht and Geräteübernahme answer publishes through a separately numbered Codeliste, and the cluster picks which one — E_0200 names S_0090 on a Zustimmung and S_0054 on an Ablehnung. Writing the EBD number instead is a rejected message. A GeLi Gas answer names nothing at all: the Gas MIG does not require DE 1131, so wire_codeliste is None there and the segment carries the code alone.

A code brings its conditional segments with it, and the AHB layer checks them: A06 obliges a SG4 DTM+Z07 on the 55003 carrying it, and a code flagged braucht_bemerkung is incomplete without an FTX+ACB. The simulator refuses to bind an answer that would omit either, so the omission surfaces where the test is written rather than at the receiver.

Not every code that names a segment names the FTX: Gas Z35 obliges the second SG4 STS — the Altlieferant's own refusal — and A50 does the same on Strom.

nb_sim.on(55001).ablehnung(antwort_code="A06", process_dates=[("Z07", "20260501")])
nb_sim.on(55001).ablehnung(antwort_code="A05", bemerkung="Abweichung: …")

One PID can be decided by a chain of trees. The answer-Frist table names one, and antwort_codes_for_pid returns that one's codes. A GPKE Anmeldung runs the Vorprüfung E_0622 — refusals only — before E_0623 decides the Lieferbeginn, so confirming a 55001 means naming ebd="E_0623". Inferring the rest of a chain would mean writing down a mapping no document states.

Every root-to-leaf path through an EBD ends at a published code, so a tree's Codeliste is the outcome space a platform has to handle — which makes "which answers should be tested" derivable rather than a judgement call. makotest.strategies.antwort_codes(ebd=…) draws from it, and makotest codes prints it.


BIKO and iMSys

BikoSim receives MaBiS Summenzeitreihen and answers with an APERAK — an acceptance, or a Klärfall queued rather than sticky, so the re-submission after Clearing can be asserted. An interchange carries several series, each its own settlement, and each is assessed and acknowledged separately: RFF+ACW names one UNH, so one APERAK could only ever speak for one of them. It refuses anything not addressed to a Bilanzkoordinator: a UTILMD, or an MSCONS outside 13003 / 13010–13012, is a Messwesen message for a Netzbetreiber, and a simulator that accepted one would make every assertion downstream of that acceptance meaningless.

ImsysSim models the SMGW compliance surface a platform has to react to: TAF profile, CLS channel state, certificate expiry and revocation, and Zählerstandsgang gaps and qualities. It does not reimplement BSI TR-03109 crypto.

The TAF names are the official ones from BSI TR-03109-1 (TAF 16 from the separate Implementierungshinweis; there is no TAF 15), because the number alone is ambiguous where it matters. Steering needs TAF-11 („Steuerung von unterbrechbaren Verbrauchseinrichtungen und Erzeugungsanlagen"); TAF-14 is „Hochfrequente Messwertbereitstellung für Mehrwertdienste" — a fast read-out, not a control path — so a gateway ordered under it opens no CLS channel.

Zaehlerstandsgang.as_direct_push() renders a delivery as the request body a platform's SMGW ingest endpoint accepts, over the Europe/Berlin local day, with one value per market time unit that day really has. A 96-value series on the 23-hour March day is refused rather than laid out past midnight.

Zaehlerstandsgang.as_mscons() renders the same delivery on the wire instead — one QTY per interval, each carrying its own measurement period:

gang = smgw.deliver("2026-10-25", werte=lastgang.day("2026-10-25"))
msg = gang.as_mscons(pruefidentifikator=13025, sender_mp_id=MSB,
                     receiver_mp_id=LF, on="2026-04-01", malo_id=malo)
assert_edifact_valid(build_interchange(..., messages=[msg], ...), on="2026-04-01")

Interval data needs intervals=, never quantities=. A bare QTY states a magnitude with no time reference, so a receiver cannot place it on the settlement grid — and the AHB does not reject it, so a Lastgang built from flat quantities validates while being unusable. Gaps stay absent here too, so the Ersatzwert path is what a receiver has to take.

Three states, three obligations, and a platform has to tell them apart:

On the wireWhat the platform owes
a Lückethe interval is absentform an Ersatzwert; a zero would be settled against
quality="SUBSTITUTED"present, stampedbill it — it is the Ersatzwert (§ 60 Abs. 2 MsbG)
quality="FAULTY"present, stampeddo not bill it, and substitute

Values are decimal strings: energy is a decimal quantity, and a JSON float carries a binary rounding error into whatever the platform settles against.

A counterparty is modelled once it has a consumer. One written ahead of its consumer encodes guesses about an interface nobody has implemented, and to the next reader those guesses are indistinguishable from requirements.


pytest plugin

The plugin registers through the standard pytest11 entry point — no conftest.py wiring.

def test_frist_is_met(frozen_clock, nb_sim, makotest_on):
    frozen_clock.advance_werktage(4)   # BDEW calendar, not naive +4 days
    reply = nb_sim.receive(anmeldung, received_at=frozen_clock.instant)

frozen_clock is a Berlin local time and resolves its offset on every move. A German wall clock is +01:00 for part of the year and +02:00 for the rest, so a clock carrying the offset it was constructed with reports 09:00+01:00 after advancing into summer time — an instant an hour off the wall clock it claims, in the same direction for every deadline asserted against it. Every dated fixture is anchored to --makotest-on, so one test is never about two days.

Fixturesepex, lastgang, nb_sim, biko_sim, imsys_sim, frozen_clock, makotest_seed, makotest_on, mako_endpoint
Markers@pytest.mark.regulatory("GPKE Teil 2"), @pytest.mark.requires_docker
Options--makotest-on ISO_DATE, --makotest-seed N, --mako-endpoint URL

--makotest-on pins the BDEW format version for the whole session, so re-running a suite on a future date shows what the next Formatumstellung breaks. It refuses a date no compiled profile covers for every message type the fixtures build rather than validating nothing — a gap between two format versions is otherwise discovered as a build failure in whichever test happens to need that type.

--hypothesis-profile=makotest selects a registered profile with deadline=None and derandomize=True. A strategy here draws through the Rust core, and Hypothesis' 200 ms per-example deadline is written for pure functions: it reports a loaded machine as a defect in the system under test.

Only makotest.plugin imports pytest. The generators and simulators are plain objects usable from a script or notebook, so a demo and a CI test drive the same code path.


Property-based testing

from hypothesis import given
from makotest.strategies import malo_ids, pruefidentifikatoren

@given(malo=malo_ids(), pid=pruefidentifikatoren(message_type="UTILMD"))
def test_every_utilmd_roundtrips(malo, pid):
    ...

Needs the hypothesis extra. Every strategy constructs its values through the Rust core, which is the point: a drawn value is one the platform accepts.

StrategyDraws
malo_ids()check-digit-valid 11-digit Marktlokations-IDs
melo_ids(country=…)33-character Messlokations-IDs
marktpartner_ids(kind=…)BDEW (99…), DVGW (98…) or GLN codes, each with its own check digit
bilanzkreise()EIC Party codes (11X…) with a real check character
bilanzierungsgebiete()EIC Area codes (11Y…)
resource_ids(kind=…)NeLo, NeBe and the Redispatch resources (BDEW §8.2)
pruefidentifikatoren(message_type=…, sparte=…, on=…)PIDs with real AHB rules
antwort_pids()inbound PIDs with a published answer Frist
antwort_codes(ebd=…, accepted=…)Antwortcodes that Entscheidungsbaum publishes
werktage()dates that are Werktage under the BDEW calendar
zeitreihen(on=… | periods=…)kWh series, one value per MTU of that Berlin day

pruefidentifikatoren() yields only PIDs the compiled profiles carry AHB rules for; passing on= narrows it to the profile active on that date, which is what a message sent then is really validated against. Without message_type= the pool spans every type the BDEW assigns Prüfidentifikatoren to — asked of pid_carrying_message_types() rather than listed, because a hand-kept subset would quietly stop a property from ever drawing an IFTSTA or QUOTES PID while still claiming to cover what the build validates.

message_types_of(pid) returns a list: a Prüfidentifikator does not identify one message type — APERAK and COMDIS both declare 29001 and 29002.


EPEX curves

Deterministic day-ahead curves in the MTU-keyed shape a platform ingests, with negative prices supported — load-bearing for §51 EEG Vergütungsausfall and §41a EnWG dynamic-tariff caps.

from makotest.generators import EpexGenerator

sim = EpexGenerator(seed=42)
sim.mtu_count("2026-10-25")                     # 100 — a 25-hour local day
list(sim.day("2026-11-01", profile="winter_peak"))
list(sim.day("2026-06-21", profile="solar_glut", negative_hours=6))

A delivery day is a Europe/Berlin calendar day, and two a year are not 24 hours long: the last Sunday in March has 23 and the last in October 25 — 92 and 100 quarter-hourly MTUs. Assuming 96 invents four MTUs in March and drops four in October, mid-day, where a curve still looks plausible and a settlement quietly comes out wrong. The day boundary comes from the platform's own timezone resolution, so a curve and the Fristen it is asserted against cannot disagree about when a day starts.

Profiles: flat, winter_peak, solar_glut, volatile. The same seed reproduces a curve exactly, and day order does not affect output.

Curves are synthetic test fixtures — never present them as market data.

Lastgang curves

LastgangGenerator produces consumption and feed-in in the same MTU-keyed shape, so a gateway delivery or an MSCONS carries a plausible day rather than a flat line.

from makotest.generators import LastgangGenerator

gen = LastgangGenerator(seed=42)
werte = gen.day("2026-10-25", profile="haushalt", jahresmenge_kwh=3500)
len(werte)                                    # 100 — a 25-hour local day
smgw.deliver("2026-10-25", werte=werte)       # feeds the gateway unchanged
ProfileShapeSeason
haushaltmorning and a dominant evening peakheavier in winter
gewerbeflat through business hoursmildly winter
waermepumpenight window, small afternoon shouldersteeply winter
pv_einspeisungone midday bell, dark after sunsetstrongly summer

These are not Standardlastprofile. The BDEW SLPs (H0, G0–G6, L0–L2) are published coefficient tables and this build does not carry them, so the profiles are named for their shape rather than for an SLP class — a generator calling itself H0 while inventing the coefficients would make every settlement asserted against it look authoritative and be wrong. Use these for ingest, Ersatzwertbildung and settlement plumbing; for a figure that has to match a published profile, take the profile.

The day's total is the annual quantity spread over 365 days and weighted by a seasonal factor that averages to 1.0 across the year, and the scaling happens after the noise — so streuung changes the shape and never the stated total. Values are non-negative, pv_einspeisung included: it is generation at its own register, and which sign a platform settles it under is the platform's convention.


Business-object assertions

from makotest import assert_bo4e_generation_matches, assert_invoice_reconciles

assert_bo4e_generation_matches(platform.bo4e_version)
assert_invoice_reconciles(rechnung)

assert_invoice_reconciles checks four identities, each only when both of its sides are stated — a partial invoice is asserted for what it states rather than failing on what it omits:

IdentityReads
Σ teilsummeNetto = gesamtnettothe positions add up
Σ teilsummeSteuer.steuerwert = gesamtsteuerthe VAT lines add up
gesamtnetto + gesamtsteuer = gesamtbruttonet and gross agree
gesamtbrutto − vorausgezahlt − rabattBrutto = zuZahlenthe amount demanded

Checking only the first is the trap worth naming: an invoice whose positions add up and whose zuZahlen is wrong is the defect that reaches a customer, because the positions are what a reviewer reads and zuZahlen is what gets collected.

The expected BO4E generation is asked of the linked rubo4e rather than written down, so it cannot drift from the crates the wheel bundles. Testing one generation's objects against a platform on another produces passes that mean nothing.

An invoice stating none of the four raises ValueError: with nothing to reconcile there is nothing to fail on, and a silent pass over an empty or misspelled mapping is the vacuous assertion this toolkit exists to prevent.

Money is compared as Decimal, never float. A cent is not representable in binary floating point, and an invoice assertion that drifts in the last place is worse than no assertion. The default tolerance is one cent, because each total is independently rounded; pass tolerance_eur="0" to demand exact agreement.


Development

makotest is a member of the mako Cargo workspace and builds with maturin:

just test-makotest      # maturin develop + pytest
just lint-makotest      # ruff check + format check
just build-makotest     # release wheel

pyo3/extension-module is deliberately not a Cargo feature — maturin enables it at build time. Declaring it would make cargo test --workspace --all-features link the Rust test harness against it and fail on undefined Python symbols. CI exercises both paths.

py.typed ships with the wheel, so _native.pyi is the only thing a consumer's type checker sees. A test pins it against the compiled module in both directions.


Scope

makotest covers the Rust core (identifiers, the Werktag calendar and Berlin-day arithmetic, the published answer-Frist table, Prüfidentifikator and release introspection, the AHB answer table, the Entscheidungsbaum Codelisten, EDIFACT build + interchange + validation, the CloudEvents catalog), the EPEX and Lastgang generators, the Marktpartner / BIKO / iMSys simulators, hypothesis strategies, domain assertions, the pytest plugin and the makotest CLI.

The Marktpartner simulator is a plain object with receive() and carries no AS4 transport of its own: a transport layers on top of it, so it is never a dependency of the many tests that do not need one.

The package version tracks workspace.package.version through Cargo.toml, so the wheel and the crates it binds can never report different versions.

Edit this page ↗