Container Status Mapping Rules

Container status mapping rules are the deterministic translation layer that converts raw vessel telemetry, terminal gate codes, and EDI milestones into a single, standardized container lifecycle state within the Container Tracking & AIS Event Synchronization domain. These rules bridge heterogeneous maritime data sources and unified fleet-management platforms so that every physical movement corresponds to a verified digital state. Applied correctly, status mapping eliminates manual reconciliation, reduces yard congestion, and gives shipping operations teams a single source of truth for where each box is and what may legally happen to it next.

Container status mapping lifecycle state machine A deterministic state machine. An initial state feeds AT_SEA, which transitions on 'await berth' to AT_ANCHOR, on 'berth assigned' to BERTHED, and on 'crane discharge' to DISCHARGED. A 'yard move' carries the box to the central YARD_PLACED hub. From the hub, an 'inspection flag' moves it to CUSTOMS_HOLD and 'cleared' returns it; 'low confidence' moves it to PENDING_VERIFICATION and 'resolved' returns it. The hub exits on 'export load' to LOADED or on 'import pickup' to GATE_OUT, and both reach the final state. AT_SEA AT_ANCHOR BERTHED DISCHARGED YARD_PLACED CUSTOMS_HOLD PENDING_VERIFICATION LOADED GATE_OUT await berth berth assigned crane discharge yard move inspection flag cleared low confidence resolved export load import pickup

Ingestion Boundary & Protocol Handling

Production mapping pipelines sit downstream of two very different feeds, and the ingestion boundary exists to make them uniform before any state logic runs. Vessel positioning arrives through the AIS Data Stream Integration layer as fragmented NMEA 0183 sentences carrying ITU-R M.1371 payloads — position reports and voyage data — but that feed is vessel-level and carries no container identity. Landside container events arrive as UN/EDIFACT CODECO (gate in/out), COARRI (load/discharge), and COPRAR (loading order) messages over AS2 or SFTP, or as their REST/JSON equivalents pulled through adaptive Terminal API Polling Strategies. Raw inputs disagree on almost everything that matters: timestamp formats (UTC versus local port time), coordinate projections (WGS84 versus a local CRS), and conflicting status nomenclature.

The boundary normalizes each protocol before transformation. AIS sentences are reassembled from multi-fragment frames and deduplicated on (mmsi, timestamp); EDIFACT interchanges are stripped of their UNB/UNH envelopes and segmented on deterministic delimiters; REST snapshots are coerced from inconsistent casing into typed fields. Every message is stamped in UTC and given a monotonic sequence number so that the correlation core can reason about ordering. When container events arrive as EDI rather than JSON, they pass through the same normalisation discipline documented in the Document Ingestion & EDI Parsing Workflows domain before joining the tracking stream. Only after this uniform boundary does the mapping engine correlate vessel berthing windows, crane operation cycles, and terminal gate events to infer deterministic states such as DISCHARGED, YARD_PLACED, CUSTOMS_HOLD, or GATE_OUT.

The correlation itself is modelled as a directed acyclic graph of state transitions. Each node applies deterministic business rules, validates source provenance, and emits standardized payloads to downstream orchestration queues. This guarantees state progression never regresses without explicit override authorization, preserving temporal monotonicity across distributed port zones and across the redelivery storms that follow any VAN outage.

Python Data Structure Mapping

Maritime standards (SMDG, UN/LOCODE, ISO 6346, EDIFACT CODECO/COARRI) must map cleanly to typed, memory-efficient Python structures. Production systems avoid loose dictionaries in favour of Pydantic models with strict type hints, so that schema validation occurs at the serialization boundary rather than three layers deep in business logic. A ContainerState enum defines the closed vocabulary; a StatusEvent model carries provenance, confidence, and an audit hash alongside the resolved state.

from __future__ import annotations

from datetime import datetime
from decimal import Decimal
from enum import Enum

import structlog
from pydantic import BaseModel, Field, field_validator

log = structlog.get_logger("status_mapping")


class ContainerState(str, Enum):
    AT_SEA = "AT_SEA"
    AT_ANCHOR = "AT_ANCHOR"
    BERTHED = "BERTHED"
    DISCHARGED = "DISCHARGED"
    YARD_PLACED = "YARD_PLACED"
    CUSTOMS_HOLD = "CUSTOMS_HOLD"
    LOADED = "LOADED"
    GATE_OUT = "GATE_OUT"
    PENDING_VERIFICATION = "PENDING_VERIFICATION"


class StatusEvent(BaseModel, frozen=True):
    container_id: str
    iso_code: str
    state: ContainerState
    source_system: str
    confidence_score: float = Field(ge=0.0, le=1.0)
    event_ts: datetime
    lat: Decimal
    lon: Decimal
    terminal_zone: str | None = None
    audit_hash: str | None = None

    @field_validator("event_ts")
    @classmethod
    def _must_be_utc(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("event_ts must be timezone-aware UTC")
        return v

The mapping engine translates external codes to the ContainerState enum via lookup tables that are version-controlled and hot-reloadable. Detailed translation matrices — the exact string-matching and qualifier-precedence logic for SMDG/EDIFACT parity — are documented in Mapping ISO container status codes to internal states. Where a gate movement and a release order describe the same box from different messages, Reconciling CODECO and COREOR status events resolves which qualifier wins before a transition is committed. Declaring the model frozen=True prevents accidental mutation in concurrent worker threads, a hard requirement for data integrity during high-throughput yard operations, and constraining confidence_score to the [0.0, 1.0] interval at the model boundary means a malformed score can never reach the fallback logic that depends on it.

Validation, Quarantine & Compliance Auditing

Uptime depends on rejecting malformed payloads before they corrupt the state store. Validation executes as a multi-tier chain — structural, then semantic, then regulatory — with a distinct disposition for each failure class:

  1. ISO 6346 checksum verification (structural). The modulo-11 algorithm validates the owner-plus-serial portion of every equipment identifier. Invalid check digits are quarantined, not dropped: a failing box is flagged and still tracked by best-effort AIS correlation. The authoritative rules are published in ISO 6346.
  2. Temporal monotonicity (semantic). event_ts may never precede the last committed state for the same container. Out-of-order events are buffered in a Redis sorted set until the gap resolves, so a gate-out that appears to precede its gate-in is corrected rather than committed.
  3. Geospatial bounds (semantic). Coordinates are validated against port-authority geofences. Events outside authorized zones are flagged for manual review before they can advance a state.
  4. Regulatory attribution (compliance). Verified Gross Mass figures are checked against SOLAS Chapter VI thresholds and security-relevant transitions are tagged for the audit trail.

The distinction between a quarantine topic and a dead-letter queue matters. Semantic failures — a check-digit mismatch, an unknown LOCODE — route to a QUARANTINE topic that preserves operational continuity while ops teams triage exceptions. Structurally broken input that cannot be parsed at all routes to the dead-letter queue. Every validation event writes an append-only entry recording record_id, rule_applied, original_value, transformed_value, and compliance_status. State transitions are logged as structured JSON via structlog, with a SHA-256 digest of the serialized payload appended so forensic auditing never requires storing raw PII in plaintext. This immutable chain satisfies customs audit requirements and supports rapid root-cause analysis.

Downstream Integration

A resolved StatusEvent is only useful when it drives the systems that act on it. Mapped states propagate to terminal operating systems for yard planning, to customs APIs for milestone declarations, and to stowage planners for load sequencing. Because a single milestone can trigger irreversible operational actions — a chassis dispatch, a customs release — the mapping engine emits idempotent events keyed on a deterministic hash of the identifying fields, so a redelivered COARRI never double-counts a load.

State transitions feed the broader Port Call Workflow Design state machine, keeping berth allocation and pre-clearance aligned with what the containers are actually doing. Parent-child equipment relationships — a bill of lading referencing multiple boxes, each with distinct seals and hazardous-material codes — are resolved against the Container Hierarchy Data Models specification so that a single CUSTOMS_HOLD can cascade correctly across grouped equipment. Where a status change originates from a commercial document rather than a physical move, the values reconcile against the Bill of Lading Schema Mapping layer, and drift between AIS-derived and terminal-confirmed states is watched by the Threshold Tuning for Alerts layer, which pages an engineer only when the lag exceeds what is normal for that vessel class and terminal congestion level.

Regulatory routing is enforced at the emission layer. Customs agencies, terminal operators, and shipping lines consume different subsets of the mapped state, each with distinct retention and privacy requirements. The service applies attribute-based access control (ABAC), stripping sensitive fields before routing to external endpoints, so a feed authorized only for berth-approach telemetry can never receive customs-hold detail.

Fallback Chains & Uptime Guarantees

Status mismatches are inevitable in live port environments. A TOS may report LOADED while AIS shows the vessel still at anchor because of crane-telemetry latency or EDI batching delays. Rather than crash or silently guess, the pipeline always returns a typed, confidence-scored answer by degrading through a deterministic, version-controlled cascade:

  • Primary — TOS/EDI gate logs (confidence ≥ 0.95).
  • Secondary — AIS geofence ingress/egress fused with crane PLC signals (confidence 0.70–0.94).
  • Tertiary — yard RFID/OCR scans (confidence 0.50–0.69).
  • FallbackPENDING_VERIFICATION (confidence < 0.50 or conflicting sources).

When confidence drops below the operational baseline, the engine emits PENDING_VERIFICATION rather than propagating an unverified milestone to planning systems. A circuit breaker fronts each terminal endpoint: after a configurable run of failures it trips open, the pipeline stops hammering the dead dependency, and resolution drops immediately to the cached-plus-AIS tier until a half-open probe succeeds. Retries use exponential backoff with jitter (base=2s, max=60s, jitter=0.1) to prevent thundering-herd effects during terminal API outages. Anomalous events that exceed configurable discrepancy thresholds route to the dead-letter queue with full payload snapshots, enabling automated replay once upstream systems stabilize. To hold sub-50ms p99 latency during peak discharge windows, engineers bound asyncio.Queue depth to cap in-flight events, pool PostgreSQL/TimescaleDB connections for upserts, and replace recursive state evaluators with iterative stack-based traversals.

Step-by-step Implementation Guide

The following procedure resolves a container’s state from a terminal snapshot and an AIS fix, degrading through the fallback tiers and emitting an audited, idempotent StatusEvent. Each step is runnable in isolation against Python 3.11+ with pydantic>=2 and structlog installed.

Step 1 — Validate the ISO 6346 identifier. Reject or quarantine any equipment ID whose modulo-11 check digit fails before it can advance a state.

import string

_VALUES: dict[str, int] = {str(d): d for d in range(10)}
_v = 10
for _ch in string.ascii_uppercase:
    while _v % 11 == 0:            # 11, 22, 33 are skipped by the standard
        _v += 1
    _VALUES[_ch] = _v
    _v += 1


def is_valid_iso6346(container_id: str) -> bool:
    cid = container_id.strip().upper()
    if len(cid) != 11 or not cid[:4].isalpha() or not cid[4:].isdigit():
        return False
    total = sum(_VALUES[c] * (2 ** i) for i, c in enumerate(cid[:10]))
    check = total % 11
    return (0 if check == 10 else check) == int(cid[10])

Step 2 — Load the version-controlled code map. Translate the external SMDG/EDIFACT status qualifier into the internal ContainerState, defaulting unknown qualifiers to PENDING_VERIFICATION rather than a plausible guess.

CODE_MAP: dict[str, ContainerState] = {
    "45": ContainerState.DISCHARGED,   # COARRI discharge
    "AE": ContainerState.YARD_PLACED,  # yard move confirmed
    "CH": ContainerState.CUSTOMS_HOLD,
    "44": ContainerState.LOADED,       # COARRI load
    "GO": ContainerState.GATE_OUT,
}


def map_code(qualifier: str) -> ContainerState:
    state = CODE_MAP.get(qualifier.upper())
    if state is None:
        log.warning("unknown_qualifier", qualifier=qualifier)
        return ContainerState.PENDING_VERIFICATION
    return state

Step 3 — Enforce temporal monotonicity. Compare the incoming timestamp against the last committed state and buffer out-of-order events instead of regressing.

def accept_transition(prev_ts: datetime | None, event_ts: datetime) -> bool:
    if prev_ts is not None and event_ts < prev_ts:
        log.info("buffered_out_of_order", prev=prev_ts.isoformat(), incoming=event_ts.isoformat())
        return False
    return True

Step 4 — Resolve through the fallback chain. Take the terminal status at 0.95 when present; otherwise fuse AIS with a cached snapshot at 0.75; otherwise emit PENDING_VERIFICATION.

def resolve_confidence(has_tos: bool, has_ais_join: bool) -> tuple[ContainerState | None, float]:
    if has_tos:
        return None, 0.95           # caller supplies the mapped TOS state
    if has_ais_join:
        return None, 0.75
    return ContainerState.PENDING_VERIFICATION, 0.40

Step 5 — Emit an audited, idempotent event. Construct the frozen StatusEvent, attach a SHA-256 audit hash, and write the structured log line that forms the audit trail.

import hashlib


def emit_event(**fields) -> StatusEvent:
    digest = hashlib.sha256(
        f"{fields['container_id']}|{fields['state']}|{fields['event_ts'].isoformat()}".encode()
    ).hexdigest()
    event = StatusEvent(audit_hash=digest, **fields)
    log.info(
        "state_mapped",
        container=event.container_id,
        state=event.state.value,
        source=event.source_system,
        confidence=event.confidence_score,
        audit_hash=event.audit_hash,
    )
    return event

Troubleshooting Common Failures

Symptom Root cause Fix
Container marked LOADED while AIS shows the vessel at anchor Crane-telemetry latency or EDI batching lag; TOS trusted in isolation Require a dual-source guard — a COARRI load plus a plausible AIS state (moored at the expected berth) — before committing LOADED.
Identifier fails ISO 6346 check digit Transcription error at a manual gate, or a leased box re-marked out of sequence Quarantine (do not drop); flag the box, keep tracking it by AIS correlation, and surface for correction.
Unknown status qualifier crashes the parser Carrier extends CODECO/COARRI with a proprietary qualifier not in the base directory Map unknown qualifiers to PENDING_VERIFICATION, log them, and raise a rules-update ticket — never default to a plausible state.
Gate-out timestamp precedes gate-in Terminal timestamp in local port time versus AIS in UTC Normalize every timestamp to UTC at the ingestion boundary and buffer out-of-order events in a sorted set.
Duplicate moves after a VAN outage EDI partner replays hours of buffered messages at once Deduplicate on the deterministic idempotency key; treat a repeated key as a no-op that refreshes the last-seen watermark.
Unresolvable LOCODE on a new facility Terminal message references a port before it lands in the UN/LOCODE registry Maintain a local override table keyed on (carrier, terminal_code) so the event degrades to a named override, not a DLQ entry.

Frequently Asked Questions

Why default an unknown status code to PENDING_VERIFICATION instead of the nearest match?

Because a plausible-looking wrong state actuates real operations — a chassis dispatch or a customs release — whereas PENDING_VERIFICATION merely withholds the milestone until a human or a rules update resolves the qualifier. Guessing optimistically is the single most expensive failure mode in status mapping, so the engine is deliberately conservative and logs every unknown qualifier for review.

When should a mismatch route to quarantine versus the dead-letter queue?

Semantic failures that are still parseable — a check-digit mismatch, an unknown LOCODE, a monotonicity violation — go to the quarantine topic so operations continue while the exception is triaged. Structurally broken input that cannot be parsed at all, such as a malformed EDIFACT segment, goes to the dead-letter queue for out-of-band remediation. The quarantine path preserves throughput; the DLQ isolates input a rules update must fix.

How is idempotency preserved when a terminal API redelivers the same move?

Every emitted event carries a deterministic key derived from its identifying fields — container ID, state, and UTC timestamp — hashed with SHA-256. The state machine treats a repeated key as a no-op that refreshes the last-seen watermark but does not re-run the transition, so redelivery storms after an outage are absorbed without double-counting moves or polluting the audit log.

↑ Back to Container Tracking & AIS Event Synchronization.