Validating BAPLIE against ISO 9735 in Python

A BAPLIE — the SMDG bayplan message that tells a terminal exactly which stowage cell every container on a vessel occupies — arrives as a UN/EDIFACT interchange, and one malformed envelope can hand a stowage planner a phantom slot or a mis-weighted box on a live sailing. Validating BAPLIE against ISO 9735 in Python means enforcing the interchange syntax rules deterministically before any cell is trusted: the service-string advice, the UNB/UNZ envelope balance, the UNH/UNT message counters, and the mandatory LOC/EQD/MEA/DIM segment groups that describe each container. This page builds a typed, two-tier validator — structural syntax first, container semantics second — so a bad stow position is quarantined at the gate instead of surfacing on the bay plan mid-loadout.

Architecture Alignment

This task is a concrete instance of the rule engine described by Schema Validation Frameworks, the topic area inside the Document Ingestion & EDI Parsing Workflows domain that decides whether a payload is structurally sound before it consumes downstream compute. BAPLIE sits at the same ingestion boundary as the aggregated manifests handled by IFCSUM EDI Message Parsing — both are UN/EDIFACT interchanges governed by the ISO 9735 syntax rules — but BAPLIE’s payload is spatial rather than commercial: every message body is a list of occupied and empty stow cells, and each EQD equipment identifier must resolve into the Container Hierarchy Data Models topology the rest of the platform tracks against. The validator here owns one gate: prove the interchange is syntactically legal per ISO 9735, then prove each container reference is semantically valid, and route anything that fails to quarantine rather than the bay plan.

BAPLIE validation pipeline from envelope syntax to accept or quarantine A BAPLIE interchange enters a four-stage pipeline. Stage one reads the UNA service string and opens the UNB envelope with its delimiters. Stage two reconciles the UNZ interchange control count and the UNT segment counts against the UNH message references. Stage three validates the mandatory per-container segment group of LOC stow position, EQD equipment, MEA verified gross mass, and DIM dimensions. Stage four runs the semantic tier: the ISO 6346 container check digit and a legal bay-row-tier stow position. A verdict diamond then accepts a clean interchange to the terminal operating system and stowage planner, while any structural or semantic failure at any stage routes to the quarantine topic. pass fail → quarantine UNA + UNB service string · delimiters UNZ / UNT count reconcile Per-container group LOC · EQD · MEA · DIM Semantic tier ISO 6346 · stow cell verdict Accept → TOS · stowage Quarantine topic structural / semantic fail
The BAPLIE gate: UNA/UNB delimiter and envelope checks, UNZ/UNT counter reconciliation, and per-container LOC/EQD/MEA/DIM group validation feed a verdict that either accepts the interchange to the TOS or diverts it to quarantine.

Prerequisites & Environment Setup

The validator targets Python 3.11+ for its improved dataclass and typing ergonomics and exception groups. It has no heavy dependencies — parsing an EDIFACT interchange is string work — so the install footprint is just the structured-logging stack plus pytest for the test suite:

pip install structlog==24.* pytest

Structured JSON logging is non-negotiable here: every rejection must be replayable during a terminal dispute or a port-state-control audit, so a UNT counter mismatch has to be queryable by control reference, not buried in a formatted string. Configure structlog once, at process start, to emit JSON:

import structlog

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ],
)
log = structlog.get_logger("maritime.baplie")

The validator reads a few environment variables so the same code runs strict in production and tolerant during carrier onboarding:

Variable Purpose Example
BAPLIE_STRICT_UNA Reject an interchange that omits the UNA service string true
BAPLIE_MAX_TIER Highest legal stow tier (above-deck plausibility ceiling) 98
VGM_MIN_KG SOLAS Verified Gross Mass plausibility floor for MEA 1500
BAPLIE_QUARANTINE_TOPIC Topic for recoverable syntax/semantic failures edi.baplie.quarantine
CONTAINER_ID_PATTERN ISO 6346 owner+category+serial regex guard ^[A-Z]{4}\d{7}$

Step-by-step Implementation

Step 1 — Read the UNA service string and tokenize the interchange

ISO 9735 lets an interchange redefine its own delimiters through the optional UNA service string advice — six characters after the UNA tag that set the component separator, element separator, decimal notation, release character, a reserved position, and the segment terminator. A validator that hardcodes the UNOA defaults (:, +, ., ?, space, ') will mis-split any carrier that ships a non-standard UNA, fusing the whole message into one segment. The first stage extracts the delimiters, then splits the raw bytes into segments and elements against them.

from __future__ import annotations

from dataclasses import dataclass

import structlog

log = structlog.get_logger("maritime.baplie.syntax")


@dataclass(frozen=True, slots=True)
class Delimiters:
    component: str = ":"
    element: str = "+"
    decimal: str = "."
    release: str = "?"
    segment: str = "'"


def read_service_string(raw: str) -> Delimiters:
    """Parse the optional UNA service string advice per ISO 9735."""
    if raw.startswith("UNA") and len(raw) >= 9:
        s = raw[3:9]
        log.info("una_detected", advice=s)
        return Delimiters(
            component=s[0], element=s[1], decimal=s[2], release=s[3], segment=s[5]
        )
    log.info("una_absent", note="defaulting to UNOA delimiters")
    return Delimiters()


def tokenize(raw: str, delim: Delimiters) -> list[list[str]]:
    body = raw[9:] if raw.startswith("UNA") else raw
    segments = [s.strip() for s in body.split(delim.segment) if s.strip()]
    return [seg.split(delim.element) for seg in segments]

Step 2 — Reconcile the UNB/UNZ envelope and UNH/UNT counters

The load-bearing ISO 9735 rule is envelope balance. The interchange trailer UNZ carries an interchange control count (UNZ0401) that must equal the number of messages inside, and an interchange control reference (UNZ0402) that must match the one opened in UNB0405. Inside each message, the UNT trailer carries a segment count (UNT0401) covering every segment from UNH through UNT inclusive, and a message reference (UNT0402) that must match UNH0101. A drifting counter is the single most common corruption in transmitted BAPLIE, and it is fatal: it means segments were dropped or duplicated in transit, so the stow cell list cannot be trusted.

from dataclasses import dataclass, field


@dataclass(slots=True)
class SyntaxError_:
    code: str
    detail: str


def reconcile_envelope(segments: list[list[str]]) -> list[SyntaxError_]:
    errors: list[SyntaxError_] = []
    tags = [seg[0] for seg in segments]

    unb = next((s for s in segments if s[0] == "UNB"), None)
    unz = next((s for s in segments if s[0] == "UNZ"), None)
    if unb is None or unz is None:
        return [SyntaxError_("ENVELOPE_MISSING", "UNB or UNZ segment absent")]

    # Control reference: UNB element 5, UNZ element 2.
    unb_ref = unb[5] if len(unb) > 5 else ""
    unz_ref = unz[2] if len(unz) > 2 else ""
    if unb_ref != unz_ref:
        errors.append(SyntaxError_("CONTROL_REF_MISMATCH", f"{unb_ref!r} != {unz_ref!r}"))

    # Interchange control count vs. number of UNH messages present.
    declared = int(unz[1]) if len(unz) > 1 and unz[1].isdigit() else -1
    actual = tags.count("UNH")
    if declared != actual:
        errors.append(SyntaxError_("MSG_COUNT_MISMATCH", f"UNZ={declared} UNH={actual}"))

    errors.extend(_reconcile_messages(segments))
    if errors:
        log.error("envelope_reject", control_ref=unb_ref, errors=[e.code for e in errors])
    return errors


def _reconcile_messages(segments: list[list[str]]) -> list[SyntaxError_]:
    """Check each UNH..UNT block: segment count and message reference match."""
    errors: list[SyntaxError_] = []
    start: int | None = None
    for i, seg in enumerate(segments):
        if seg[0] == "UNH":
            start = i
        elif seg[0] == "UNT" and start is not None:
            counted = i - start + 1  # inclusive of UNH and UNT
            declared = int(seg[1]) if len(seg) > 1 and seg[1].isdigit() else -1
            if declared != counted:
                errors.append(SyntaxError_("UNT_COUNT_MISMATCH", f"declared={declared} counted={counted}"))
            unh_ref = segments[start][1] if len(segments[start]) > 1 else ""
            unt_ref = seg[2] if len(seg) > 2 else ""
            if unh_ref != unt_ref:
                errors.append(SyntaxError_("MSG_REF_MISMATCH", f"{unh_ref!r} != {unt_ref!r}"))
            start = None
    return errors

Step 3 — Validate the mandatory per-container segment group

A BAPLIE message body is a repeating group anchored on a LOC+147 segment carrying the stowage position, followed by the equipment (EQD), the SOLAS Verified Gross Mass (MEA), and the container dimensions (DIM). The stow cell in LOC+147 is a seven-digit ISO bay/row/tier code: three digits of bay, two of row, two of tier — for example 0100882 is bay 010, row 08, tier 82. This stage walks each group, requires the mandatory members to be present, and parses the stow cell into a typed structure so an impossible position (tier above the deck ceiling, missing weight) is caught here rather than by the planner.

from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class StowCell:
    bay: int
    row: int
    tier: int


def parse_stow_cell(code: str) -> StowCell:
    """Parse an ISO bay/row/tier stow cell from a LOC+147 element."""
    if len(code) != 7 or not code.isdigit():
        raise ValueError(f"malformed stow cell {code!r}")
    return StowCell(bay=int(code[:3]), row=int(code[3:5]), tier=int(code[5:7]))


@dataclass(slots=True)
class ContainerSlot:
    stow: StowCell
    equipment_id: str
    vgm_kg: float | None
    dim_present: bool


def extract_slots(segments: list[list[str]], *, max_tier: int, vgm_min: float) -> tuple[list[ContainerSlot], list[str]]:
    slots: list[ContainerSlot] = []
    problems: list[str] = []
    i = 0
    while i < len(segments):
        seg = segments[i]
        if seg[0] == "LOC" and len(seg) > 2 and seg[1] == "147":
            cell = parse_stow_cell(seg[2].split(":")[0])
            if not 0 < cell.tier <= max_tier:
                problems.append(f"tier_out_of_range:{cell.tier}")
            eqd = _find_after(segments, i, "EQD")
            mea = _find_after(segments, i, "MEA")
            dim = _find_after(segments, i, "DIM")
            if eqd is None:
                problems.append(f"missing_EQD:bay{cell.bay}")
                i += 1
                continue
            equipment_id = eqd[2].split(":")[0] if len(eqd) > 2 else ""
            vgm = _read_vgm(mea, floor=vgm_min, problems=problems) if mea else None
            slots.append(ContainerSlot(cell, equipment_id, vgm, dim is not None))
        i += 1
    log.info("slots_extracted", count=len(slots), problems=len(problems))
    return slots, problems


def _find_after(segments: list[list[str]], start: int, tag: str) -> list[str] | None:
    for seg in segments[start + 1 : start + 6]:
        if seg[0] == "LOC":
            break  # next container group begins
        if seg[0] == tag:
            return seg
    return None


def _read_vgm(mea: list[str], *, floor: float, problems: list[str]) -> float | None:
    # MEA+AAE+VGM+KGM:24500 -> qualifier VGM, unit KGM, value 24500
    if len(mea) < 4 or mea[2] != "VGM":
        return None
    try:
        value = float(mea[3].split(":")[-1])
    except ValueError:
        problems.append("vgm_unparseable")
        return None
    if value < floor:
        problems.append(f"vgm_below_floor:{value}")
    return value

Step 4 — Run the semantic tier: ISO 6346 check digit and stow legality

Structural validity per ISO 9735 does not make a container reference real. The semantic tier proves each EQD equipment identifier is a legal ISO 6346 container number by recomputing its weighted modulo-11 check digit — four owner/category letters and six serial digits weighted by ascending powers of two, summed, taken modulo 11, with a remainder of 10 folded to 0. A drifting check digit almost always means a transposition upstream, and it is a quarantine reason, not a hard drop: the box exists, someone just fat-fingered the serial. The check-digit routine is the same one the semantic tier of the parent Schema Validation Frameworks applies across the platform.

_ISO6346_LETTERS: dict[str, int] = {
    "A": 10, "B": 12, "C": 13, "D": 14, "E": 15, "F": 16, "G": 17,
    "H": 18, "I": 19, "J": 20, "K": 21, "L": 23, "M": 24, "N": 25,
    "O": 26, "P": 27, "Q": 28, "R": 29, "S": 30, "T": 31, "U": 32,
    "V": 34, "W": 35, "X": 36, "Y": 37, "Z": 38,
}


def iso6346_check_digit(container_id: str) -> int:
    """Compute the ISO 6346 check digit from the 10-character body."""
    body = container_id[:10]
    total = sum(
        (_ISO6346_LETTERS[ch] if ch.isalpha() else int(ch)) * (2 ** i)
        for i, ch in enumerate(body)
    )
    remainder = total % 11
    return 0 if remainder == 10 else remainder


def check_container_id(container_id: str) -> bool:
    if len(container_id) != 11:
        log.warning("iso6346_length", container_id=container_id)
        return False
    expected = iso6346_check_digit(container_id)
    actual = int(container_id[10])
    if expected != actual:
        log.warning("iso6346_drift", container_id=container_id, expected=expected, actual=actual)
        return False
    return True

Chaining the four stages gives the two-tier verdict: Steps 1–2 assert ISO 9735 syntax, Steps 3–4 assert container semantics, and any failure at any stage routes the whole interchange to quarantine with the failing codes attached. Version tokens and directory versions in the UNH (for example BAPLIE:D:95B:UN:SMDG20) follow the UN/EDIFACT directories the parent domain tracks.

Edge Cases & Carrier Deviations

Symptom Root cause Fix
Whole interchange parses as one segment Missing or malformed UNA; parser assumed ' terminator Read delimiters from UNA; only default when it is genuinely absent
UNT_COUNT_MISMATCH on a valid-looking file AS2 gateway stripped or duplicated a RFF/DTM in transit Reject to quarantine — the segment count is authoritative; request retransmission
iso6346_drift on a real box Transposed serial digits from an upstream keying error Quarantine, not drop; the equipment exists and ops can reconcile the serial
Stow cell parses but tier is 96+ Above-deck lashing tier beyond the plausibility ceiling Compare against BAPLIE_MAX_TIER; flag rather than trust the planner input
MEA+VGM present but value 0 Shipper omitted SOLAS VGM; carrier zero-filled the element Reject below VGM_MIN_KG; never forward a zero-mass box to stowage
CONTROL_REF_MISMATCH across two files Carrier reused a UNB control reference on a resend Key the audit record on (sender, UNB0405) so a true replay no-ops

A specific deviation worth pre-empting: some carriers emit the stowage LOC under qualifier 147 while others historically used ZZZ or an unqualified LOC for the same cell. Treat an unexpected LOC qualifier inside a container group as a quarantine reason, not a hard reject — the position is legally expressed, and a human can map the qualifier before replay. The same tolerance applies to a UNA that redefines the decimal notation to a comma: honour it for MEA value parsing rather than misreading a European-formatted weight.

Verification & Testing

Assert both tiers with pytest: a balanced interchange passes, a doctored counter fails structurally, and a transposed serial fails semantically. Use raw-string fixtures so the tokenizer and UNA handling are genuinely exercised.

import pytest

from maritime_baplie.validator import (
    Delimiters, check_container_id, iso6346_check_digit,
    parse_stow_cell, read_service_string, reconcile_envelope, tokenize,
)


@pytest.fixture
def interchange() -> str:
    return (
        "UNB+UNOA:2+MAEU+TERMINAL+240617:0900+IC001'"
        "UNH+1+BAPLIE:D:95B:UN:SMDG20'"
        "LOC+147+0100882::5'EQD+CN+MSKU0000481+22G1'MEA+AAE+VGM+KGM:24500'"
        "UNT+5+1'UNZ+1+IC001'"
    )


def test_una_defaults_when_absent(interchange: str) -> None:
    assert read_service_string(interchange) == Delimiters()


def test_balanced_envelope_passes(interchange: str) -> None:
    segments = tokenize(interchange, Delimiters())
    assert reconcile_envelope(segments) == []


def test_counter_drift_rejects(interchange: str) -> None:
    broken = interchange.replace("UNT+5+1", "UNT+9+1")
    segments = tokenize(broken, Delimiters())
    codes = {e.code for e in reconcile_envelope(segments)}
    assert "UNT_COUNT_MISMATCH" in codes


def test_stow_cell_and_check_digit() -> None:
    assert parse_stow_cell("0100882").tier == 82
    assert iso6346_check_digit("CSQU305438") == 3
    assert check_container_id("CSQU3054383") is True
    assert check_container_id("CSQU3054384") is False

A passing run emits one JSON line per decision; a clean interchange logs {"event": "slots_extracted", "count": 1, "problems": 0, "log_level": "info", ...}, and a rejection logs envelope_reject with the control_ref and a list of failing codes. Assert on those structured keys, never on a rendered message string.

Frequently Asked Questions

Why validate ISO 9735 syntax separately from container semantics?

Because they fail for different reasons and route to different places. An ISO 9735 syntax failure — a UNZ count mismatch, a broken UNB/UNH reference — means the transmission itself is corrupt and segments were lost or duplicated, so the whole interchange is untrustworthy and needs retransmission. A semantic failure — a drifting ISO 6346 check digit, an implausible tier — means the envelope was delivered intact but one field is wrong, which an ops team can reconcile without a resend. Collapsing the two tiers either demands a full retransmission for a single fat-fingered serial or lets a corrupt envelope leak container data downstream.

What exactly must the UNT segment count include?

The UNT segment count (UNT0401) covers every segment of the message from the UNH through the UNT itself, inclusive of both. It does not count the interchange-level UNB/UNZ envelope, which is reconciled separately by the UNZ interchange control count against the number of UNH messages present. If your count is off by exactly one, you are almost certainly excluding the UNH or the UNT — both are inside the message and must be counted.

Should a failed ISO 6346 check digit drop the container?

No. A check-digit failure almost always signals a transposition in the serial, not a fabricated box — the container physically exists on the vessel. Route the interchange to quarantine with the expected and actual check digits logged, let an operator reconcile the serial against the equipment interchange receipt, then replay. Hard-dropping the record loses a real stow position from the bay plan, which is far more dangerous than holding it for a moment of human review.

Up: Schema Validation Frameworks — the topic area governing structural, semantic, and regulatory validation at the ingestion boundary.