Modeling SOLAS VGM declarations as Pydantic schemas

A packed container cannot be loaded aboard a vessel until its Verified Gross Mass is declared, and a mistyped or physically impossible weight is a stability hazard, not a paperwork nuisance. Modeling SOLAS VGM declarations as Pydantic schemas turns the SOLAS Chapter VI, Regulation 2 obligation into a typed, self-validating contract: one VgmDeclaration model that pins the container identity to its ISO 6346 check digit, records which weighing method produced the figure, normalizes the reported unit to kilograms, and rejects any mass that falls below tare or above the CSC-plate maximum before the number ever reaches a stowage planner. This page specifies that model — the field validators, the cross-field consistency rules, and the mapping back to the EDIFACT VERMAS message the terminal actually receives.

Architecture Alignment

This model lives in the Regulatory Compliance Reference area of the Core Maritime Architecture & Taxonomy domain, where regulatory obligations are expressed as enforceable data contracts rather than prose checklists. A VGM figure is meaningless without the equipment it describes, so the container identity here reuses the same ISO 6346 rules that govern Container Hierarchy Data Models; a VGM that fails to resolve to a known box is as invalid as one that fails a plausibility bound. The declaration is also a payload that arrives over EDI, so the structural and semantic gating it performs is the same discipline documented in Schema Validation Frameworks — the difference is that VGM adds a hard regulatory gate: no verified mass, no load. Keeping the rule inside a Pydantic model rather than scattered if statements is what makes the obligation testable and the rejection auditable.

VGM declaration validation flow from shipper submission to stow or reject A shipper VGM submission carried as an EDIFACT VERMAS message with a MEA+VGM segment enters a weighing-method decision. Method 1 weighs the packed container on a weighbridge; Method 2 weighs the cargo and packaging and sums it with the container tare. Both branches converge on the VgmDeclaration Pydantic v2 model, which validates the fields, then a plausibility check requires the verified mass to sit between the container tare and the CSC-plate maximum gross. A passing declaration is accepted and written to the audit trail and load list; a failing declaration is rejected before stow and routed to quarantine for resubmission. Method 1 Method 2 pass fail Shipper VGM VERMAS · MEA+VGM Weighing method? Weigh packed box weighbridge · full unit Contents + tare weigh each · sum VgmDeclaration pydantic v2 validate Plausibility tare ≤ VGM ≤ CSC max Accept & audit → stow / load list Reject before stow quarantine · resubmit
The VGM path: a VERMAS/MEA+VGM submission branches on weighing method, both branches converge on the VgmDeclaration model, and a plausibility bound between tare and CSC-plate maximum decides accept-and-audit versus reject-before-stow.

Prerequisites & Environment Setup

The model targets Python 3.11+ and Pydantic v2, whose Rust-backed core and model_validator API make cross-field regulatory rules both fast and declarative. Install the typed-validation and structured-logging stack:

pip install "pydantic>=2.6" structlog

Structured JSON logging is not optional in this niche: a VGM rejection can hold a container off a sailing, so every accept and every reject must be a queryable, timestamped event that a port-state control inspector or a carrier dispute can replay. Configure structlog once, at import time, to emit JSON:

import structlog

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

The validator reads a few operational bounds from the environment so plausibility floors can be tuned per trade lane without a redeploy:

Variable Purpose Example
VGM_ABS_MIN_KG Absolute floor below which any VGM is implausible for a loaded box 1500
VGM_SUBMISSION_LEAD_MIN Minutes before the load-list cutoff a VGM must be received 120
VGM_METHOD2_TOLERANCE_KG Allowed drift between a Method 2 sum and the declared VGM 50
LBR_TO_KG Pounds-to-kilogram factor for unit normalization 0.45359237

SOLAS Chapter VI, Regulation 2 makes the shipper — the party named on the bill of lading — legally responsible for the figure, so the model treats the responsible-party signature as a required field, never a default. See the IMO summary of the SOLAS VGM requirement for the regulatory text these rules encode.

Step-by-step Implementation

Step 1 — Validate the container identity against its ISO 6346 check digit

Every VGM is anchored to one piece of equipment, and a transposed digit in the container number will silently attach a real weight to the wrong box. The ISO 6346 BIC container code embeds a self-check: three owner letters, an equipment category (U, J, or Z), a six-digit serial, and a trailing check digit computed from a weighted modulo-11 sum. A field_validator recomputes that digit and rejects a mismatch before any weight logic runs.

from __future__ import annotations

import re

import structlog
from pydantic import field_validator

log = structlog.get_logger("maritime.vgm")

_ISO6346_RE = re.compile(r"^[A-Z]{3}[UJZ]\d{6}\d$")
# ISO 6346 letter values skip every multiple of 11.
_LETTER_VALUES: dict[str, int] = {
    ch: v for ch, v in zip(
        "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
        [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
         25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38],
    )
}


def iso6346_check_digit(container_id: str) -> int:
    total = 0
    for position, char in enumerate(container_id[:10]):
        value = _LETTER_VALUES[char] if char.isalpha() else int(char)
        total += value * (2 ** position)
    remainder = total % 11
    return 0 if remainder == 10 else remainder


class _ContainerIdMixin:
    @field_validator("container_id", mode="before")
    @classmethod
    def _normalize_and_check(cls, raw: str) -> str:
        candidate = raw.strip().upper().replace(" ", "")
        if not _ISO6346_RE.match(candidate):
            raise ValueError(f"container_id {candidate!r} is not ISO 6346 shaped")
        expected = iso6346_check_digit(candidate)
        if int(candidate[-1]) != expected:
            log.warning("iso6346_check_digit_mismatch", container_id=candidate, expected=expected)
            raise ValueError(f"container_id {candidate!r} fails ISO 6346 check digit")
        return candidate

Step 2 — Declare the fields and normalize the reported unit to kilograms

A VERMAS message can carry the mass in kilograms (KGM) or pounds (LBR), and stowage software works in a single canonical unit. The model captures the raw value and unit exactly as submitted for audit, then derives a normalized vgm_kg so no downstream consumer ever divides by the wrong factor. Enumerating the weighing method and unit turns two of the most common data-entry mistakes — a blank method flag, a free-text unit — into validation errors at the boundary.

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

from pydantic import BaseModel, Field, computed_field

LBR_TO_KG = Decimal(os.environ.get("LBR_TO_KG", "0.45359237"))


class WeighingMethod(str, Enum):
    METHOD_1 = "1"  # weigh the packed, sealed container as one unit
    METHOD_2 = "2"  # weigh contents and packaging, add the certified tare


class MassUnit(str, Enum):
    KGM = "KGM"
    LBR = "LBR"


class VgmDeclaration(_ContainerIdMixin, BaseModel):
    container_id: str
    vgm_value: Decimal = Field(gt=0, description="mass as declared, in vgm_unit")
    vgm_unit: MassUnit
    weighing_method: WeighingMethod
    weighing_party: str = Field(min_length=2)
    authorization_ref: str = Field(min_length=1, description="weighing-party certificate id")
    weighbridge_id: str | None = None
    weighed_at: datetime
    responsible_party_signature: str = Field(min_length=2)
    tare_kg: Decimal = Field(gt=0, description="CSC-plate tare weight")
    max_gross_kg: Decimal = Field(gt=0, description="CSC-plate maximum gross mass")
    cargo_kg: Decimal | None = Field(default=None, description="Method 2 contents + packaging")

    @computed_field  # type: ignore[prop-decorator]
    @property
    def vgm_kg(self) -> Decimal:
        if self.vgm_unit is MassUnit.LBR:
            return (self.vgm_value * LBR_TO_KG).quantize(Decimal("0.1"))
        return self.vgm_value.quantize(Decimal("0.1"))

Step 3 — Enforce method consistency and CSC-plate plausibility bounds

The hard regulatory logic is cross-field, so it belongs in a model_validator that runs after every field is individually valid. Method 1 requires a weighbridge identifier because the whole container was physically weighed; Method 2 requires the cargo weight because the VGM is a sum, and that sum must reconcile with the declared figure inside a tolerance. Regardless of method, a verified mass below the container’s own tare is physically impossible, and one above the CSC-plate maximum gross means an overloaded box that must never be stowed — the CSC safety-approval plate fixes that ceiling.

from pydantic import model_validator

VGM_ABS_MIN_KG = Decimal(os.environ.get("VGM_ABS_MIN_KG", "1500"))
METHOD2_TOLERANCE_KG = Decimal(os.environ.get("VGM_METHOD2_TOLERANCE_KG", "50"))


class VgmDeclaration(VgmDeclaration):  # extend the Step 2 model
    @model_validator(mode="after")
    def _check_method_and_bounds(self) -> "VgmDeclaration":
        bound = structlog.get_logger("maritime.vgm").bind(
            container_id=self.container_id, method=self.weighing_method.value
        )

        if self.weighing_method is WeighingMethod.METHOD_1 and not self.weighbridge_id:
            raise ValueError("Method 1 requires a weighbridge_id (the whole box was weighed)")

        if self.weighing_method is WeighingMethod.METHOD_2:
            if self.cargo_kg is None:
                raise ValueError("Method 2 requires cargo_kg to sum against the tare")
            summed = (self.cargo_kg + self.tare_kg).quantize(Decimal("0.1"))
            if abs(summed - self.vgm_kg) > METHOD2_TOLERANCE_KG:
                raise ValueError(
                    f"Method 2 sum {summed} disagrees with declared VGM {self.vgm_kg}"
                )

        if self.vgm_kg < self.tare_kg:
            raise ValueError(f"VGM {self.vgm_kg} kg is below tare {self.tare_kg} kg")
        if self.vgm_kg < VGM_ABS_MIN_KG:
            raise ValueError(f"VGM {self.vgm_kg} kg is below the absolute plausibility floor")
        if self.vgm_kg > self.max_gross_kg:
            raise ValueError(f"VGM {self.vgm_kg} kg exceeds CSC max gross {self.max_gross_kg} kg")

        bound.info("vgm_validated", vgm_kg=float(self.vgm_kg), unit=self.vgm_unit.value)
        return self

Step 4 — Map the validated model to the EDIFACT VERMAS / MEA+VGM segment

Terminals and carriers exchange VGM over the UN/EDIFACT VERMAS (Verified Gross Mass) message, where the figure rides in a MEA measurement segment qualified VGM. Once the model has accepted a declaration, serializing it back to that segment is a deterministic projection — and doing it from the validated object, never from raw input, guarantees the wire message can only carry a plausibility-checked mass. The submission-cutoff rule lives here too: a VGM received after the terminal’s load-list cutoff is late by definition and must be flagged, because a container without a timely verified mass cannot be assigned a stow position.

from datetime import timedelta

VGM_SUBMISSION_LEAD_MIN = int(os.environ.get("VGM_SUBMISSION_LEAD_MIN", "120"))


def to_mea_vgm_segment(decl: VgmDeclaration) -> str:
    """Project a validated declaration to a MEA+VGM EDIFACT segment (always in KGM)."""
    kg = decl.vgm_kg.quantize(Decimal("1"))
    return f"MEA+AAE+VGM+KGM:{kg}"


def is_before_cutoff(decl: VgmDeclaration, load_list_cutoff: datetime) -> bool:
    deadline = load_list_cutoff - timedelta(minutes=VGM_SUBMISSION_LEAD_MIN)
    on_time = decl.weighed_at <= deadline
    log.info(
        "vgm_cutoff_check",
        container_id=decl.container_id,
        weighed_at=decl.weighed_at.isoformat(),
        deadline=deadline.isoformat(),
        on_time=on_time,
    )
    return on_time

Edge Cases & Carrier Deviations

Symptom Root cause Fix
Valid box rejected on check digit Container number transcribed with letter O for digit 0 Normalize in the before validator; only then recompute the ISO 6346 digit
Method 2 sum disagrees with VGM Shipper summed contents but omitted dunnage/packaging weight Widen VGM_METHOD2_TOLERANCE_KG only within reason; otherwise reject and query
VGM accepted but box overloaded max_gross_kg populated from a stale equipment record, not the CSC plate Source the ceiling from the plate/equipment master, revalidate on change
Weight looks 2.2× too heavy Unit sent as LBR but consumer assumed KGM Trust vgm_kg, never vgm_value; the unit enum forces the conversion
VGM arrives, container still can’t load Received after the load-list cutoff Run is_before_cutoff and route late arrivals to exception handling

A recurring carrier deviation is the missing method flag: some EDI gateways emit a VERMAS with the mass but leave the method qualifier blank, defaulting silently to Method 1. That is not a safe default — a Method 1 figure asserts the whole container crossed a calibrated weighbridge, a claim a blank flag cannot support. The WeighingMethod enum makes the omission a hard validation error, so the declaration bounces back to the shipper rather than entering the stow plan on an unverified assumption.

Verification & Testing

Assert the two behaviours that carry the most regulatory weight — the ISO 6346 gate and the tare/CSC plausibility bounds — with pytest fixtures, and confirm the accept event fires as structured JSON.

import pytest
from decimal import Decimal
from datetime import datetime

from maritime.vgm import VgmDeclaration, WeighingMethod, MassUnit


def _base(**overrides) -> dict:
    data = dict(
        container_id="MSKU1234565",  # valid ISO 6346 check digit
        vgm_value=Decimal("21000"), vgm_unit=MassUnit.KGM,
        weighing_method=WeighingMethod.METHOD_1, weighing_party="ACME Terminals",
        authorization_ref="WB-CERT-88", weighbridge_id="WB-07",
        weighed_at=datetime(2026, 7, 17, 6, 0), responsible_party_signature="J. Shipper",
        tare_kg=Decimal("2200"), max_gross_kg=Decimal("30480"),
    )
    data.update(overrides)
    return data


def test_valid_method1_declaration_normalizes_to_kg() -> None:
    decl = VgmDeclaration(**_base())
    assert decl.vgm_kg == Decimal("21000.0")


def test_bad_check_digit_rejected() -> None:
    with pytest.raises(ValueError, match="check digit"):
        VgmDeclaration(**_base(container_id="MSKU1234567"))


def test_vgm_above_csc_max_rejected() -> None:
    with pytest.raises(ValueError, match="exceeds CSC max"):
        VgmDeclaration(**_base(vgm_value=Decimal("31000")))


def test_method2_sum_must_reconcile() -> None:
    with pytest.raises(ValueError, match="disagrees"):
        VgmDeclaration(**_base(
            weighing_method=WeighingMethod.METHOD_2, weighbridge_id=None,
            cargo_kg=Decimal("15000"),  # 15000 + 2200 = 17200, not 21000
        ))

A passing accept emits one JSON line — {"event": "vgm_validated", "container_id": "MSKU1234565", "method": "1", "vgm_kg": 21000.0, "unit": "KGM", "level": "info", "timestamp": "..."} — so assert on the structured keys, never on a formatted string.

Frequently Asked Questions

What is the difference between VGM Method 1 and Method 2?

Method 1 weighs the entire packed, sealed container as a single unit on a calibrated weighbridge, so the reading is the verified gross mass directly. Method 2 weighs each item of cargo, plus pallets, dunnage, and packaging, and adds the container’s certified tare from the CSC plate to reach the total. Both are permitted under SOLAS Chapter VI, Regulation 2, but they demand different evidence: Method 1 needs the weighbridge identity, Method 2 needs a component breakdown whose sum reconciles with the declared figure. The model enforces exactly those method-specific requirements.

Can a container be loaded without a submitted VGM?

No. SOLAS makes a verified gross mass a precondition of loading a packed container aboard a ship, and the terminal enforces it at the load-list cutoff. A declaration that arrives after the cutoff, or not at all, cannot be assigned a stow position and must route to exception handling rather than the stow plan. Modeling the cutoff as an explicit check — comparing the weighing timestamp against a lead-time-adjusted deadline — keeps a late or absent VGM from silently slipping into a bay.

Why validate the ISO 6346 check digit inside the VGM model?

Because a VGM attached to the wrong container is worse than a missing one: it puts a plausible but incorrect weight into a real stow slot. The ISO 6346 trailing check digit is a cheap, deterministic guard against the most common data-entry error — a transposed or substituted character. Recomputing it in a field validator means a corrupted container number is rejected before any weight, method, or plausibility logic runs, so the declaration can never bind a verified mass to an identity that does not check out.

Up: Regulatory Compliance Reference — the area governing how maritime regulations become enforceable schemas.