Modeling berth-window conflicts as state transitions

A berth is a scarce, physical resource that two vessels cannot share at the same moment, yet the requests that compete for it arrive asynchronously from liner-service schedules, agent nominations, and shifting AIS-derived arrival estimates. Modeling berth-window conflicts as state transitions turns that contention into something auditable: every allocation window is an explicit finite-state machine advancing REQUESTED → TENTATIVE → CONFIRMED → OCCUPIED → RELEASED, and every clash on the same (berth, time-interval) diverts the window into a CONFLICT branch that either wins on priority or is BUMPED back to re-request. This page specifies the state model, the interval-overlap detector that respects vessel LOA and draft, the priority rules that break ties, and the idempotent, optimistically locked transitions that keep two planners from corrupting the same slot.

Architecture Alignment

This state model is a component of Port Call Workflow Design, the orchestration layer within Core Maritime Architecture & Taxonomy that governs a vessel’s milestones from pre-arrival to departure. The port-call milestone machine answers where is this call in its lifecycle; the berth-window machine answers the narrower question of does this call’s requested slot physically and contractually clear against every other window on the same berth. The two compose: a CONFIRMED berth window is the precondition the parent machine’s confirm_berth trigger depends on, and a BUMPED window forces the call back before BERTH_ALLOCATED. The temporal ordering rules that guarantee a window cannot be confirmed before its ETA is validated live in Automating port call sequence validation, and the live arrival signal that drives the OCCUPIED and RELEASED transitions is supplied by AIS Data Stream Integration — a vessel going all-fast inside the berth geofence is what moves a confirmed window into occupancy.

Berth-window lifecycle with conflict and bumped branch A berth allocation window advances left to right along a spine: REQUESTED to TENTATIVE on propose_window, TENTATIVE to CONFIRMED when the overlap test is clear, CONFIRMED to OCCUPIED on the AIS all-fast arrival at ETB, and OCCUPIED to RELEASED on the AIS departure at ETD. During the TENTATIVE stage an overlap test on the same berth and time interval, combined with LOA and draft fit, can detect a clash and divert the window down into a CONFLICT state. From CONFLICT the window either wins on priority and returns up to CONFIRMED, or is BUMPED because a stronger line-service or tidal window holds the slot, and the bumped window loops back to REQUESTED to re-request a new window. A dashed annotation on the left states the overlap predicate: same berth, half-open interval intersection of ETB to ETD, and LOA plus draft fit. Berth-window lifecycle & conflict resolution Each window advances by one guarded transition; an overlap on (berth, time-interval) diverts it to CONFLICT. propose_window no overlap ATB · AIS all-fast ATD · departed REQUESTED window nominated TENTATIVE overlap tested CONFIRMED slot held OCCUPIED alongside RELEASED berth free overlap detected priority wins lower priority re-request window CONFLICT clash on berth+interval BUMPED stronger window holds overlap predicate berth_i = berth_j [ETB,ETD) intervals intersect and LOA + draft fit
The berth-window state machine: a window advances along the spine only when the (berth, [ETB, ETD)) overlap test is clear; a clash diverts it to CONFLICT, where it either wins on priority back to CONFIRMED or is BUMPED and re-requests.

Prerequisites & Environment Setup

The engine targets Python 3.11+ for datetime.UTC, exhaustive enum handling, and precise dataclasses.replace semantics. Install the typed-validation and structured-logging stack:

pip install "structlog>=24.1" "pydantic>=2.6"

Structured JSON logging is mandatory: a berth conflict is a commercial event that a terminal planner, a line agent, and a port state control auditor may all reconstruct after the fact, so every transition and every emitted conflict must be a queryable record rather than a formatted string. Configure structlog once at boot:

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("berth.window")

The allocator reads its safety margins and topics from the environment so that a berth’s physical envelope is never hard-coded into a parser:

Variable Purpose Example
BERTH_LOA_CLEARANCE_M Bow-to-stern clearance added to vessel LOA before length fit 15
BERTH_DRAFT_SAFETY_M Under-keel clearance added to declared draft before depth fit 0.6
BERTH_CONFLICT_TOPIC Broker topic conflict events are published to for planners berth.conflict
WINDOW_LOCK_RETRIES Optimistic-lock retry budget on a stale-version write 3
TIDAL_WINDOW_SOURCE Service supplying high-water windows for depth-at-time checks tide.api/v2

Step-by-step Implementation

Step 1 — Declare the window states, triggers, and transition table

The lifecycle is an explicit source → trigger → destination table, so a trigger with no legal row from the current state is a violation by construction rather than a silent fall-through. The seven states cover the happy path (REQUESTED through RELEASED) plus the two contention states, CONFLICT and BUMPED. The ATB/ATD triggers are named for the actual-time-of-berthing and departure signals; in production those fire off an AIS all-fast or off-berth event reconciled against the declared ETB/ETD.

from __future__ import annotations

from enum import Enum

import structlog

log = structlog.get_logger("berth.window")


class WindowState(str, Enum):
    REQUESTED = "REQUESTED"
    TENTATIVE = "TENTATIVE"
    CONFIRMED = "CONFIRMED"
    OCCUPIED = "OCCUPIED"
    RELEASED = "RELEASED"
    CONFLICT = "CONFLICT"
    BUMPED = "BUMPED"


class Trigger(str, Enum):
    PROPOSE = "propose_window"
    CLEAR = "clear_no_overlap"
    FLAG_CONFLICT = "flag_conflict"
    WIN_PRIORITY = "win_priority"
    BUMP = "bump"
    REREQUEST = "rerequest"
    BERTHED = "ais_all_fast"      # ATB — vessel all-fast at the berth
    DEPART = "ais_departed"       # ATD — vessel off-berth


TRANSITIONS: dict[tuple[WindowState, Trigger], WindowState] = {
    (WindowState.REQUESTED, Trigger.PROPOSE): WindowState.TENTATIVE,
    (WindowState.TENTATIVE, Trigger.CLEAR): WindowState.CONFIRMED,
    (WindowState.TENTATIVE, Trigger.FLAG_CONFLICT): WindowState.CONFLICT,
    (WindowState.CONFLICT, Trigger.WIN_PRIORITY): WindowState.CONFIRMED,
    (WindowState.CONFLICT, Trigger.BUMP): WindowState.BUMPED,
    (WindowState.BUMPED, Trigger.REREQUEST): WindowState.REQUESTED,
    (WindowState.CONFIRMED, Trigger.BERTHED): WindowState.OCCUPIED,
    (WindowState.OCCUPIED, Trigger.DEPART): WindowState.RELEASED,
}

Step 2 — Detect overlap on (berth, time-interval) with LOA and draft fit

A berth window is a half-open interval [ETB, ETD) bound to a berth_id. Two windows clash only when they share a berth and their intervals intersect — using half-open intervals means a departure at 14:00 and the next arrival at 14:00 do not collide, which is the behaviour you want for back-to-back berthing. Before a window can even compete for the slot it must physically fit: the vessel’s length overall plus a mooring clearance must be within the berth’s usable quay length, and the declared draft plus under-keel clearance must be within the available depth at the relevant tidal window. A window that cannot fit is not a scheduling conflict — it is a hard rejection the planner must see immediately.

from dataclasses import dataclass
from datetime import datetime


@dataclass(frozen=True)
class BerthWindow:
    window_id: str
    berth_id: str
    vessel_imo: int
    etb: datetime            # estimated time of berthing (UTC)
    etd: datetime            # estimated time of departure (UTC)
    loa_m: float             # length overall
    draft_m: float
    priority: int            # lower value = stronger claim
    state: WindowState = WindowState.REQUESTED
    version: int = 0


@dataclass(frozen=True)
class BerthConstraint:
    berth_id: str
    usable_length_m: float
    available_depth_m: float     # charted depth at the applicable tidal window
    loa_clearance_m: float = 15.0
    draft_safety_m: float = 0.6


def intervals_overlap(a: BerthWindow, b: BerthWindow) -> bool:
    # Half-open [ETB, ETD): touching boundaries do not overlap.
    return a.etb < b.etd and b.etb < a.etd


def physically_fits(w: BerthWindow, c: BerthConstraint) -> bool:
    length_ok = w.loa_m + c.loa_clearance_m <= c.usable_length_m
    depth_ok = w.draft_m + c.draft_safety_m <= c.available_depth_m
    return length_ok and depth_ok


def detect_conflicts(
    candidate: BerthWindow,
    existing: list[BerthWindow],
    constraint: BerthConstraint,
) -> list[BerthWindow]:
    if not physically_fits(candidate, constraint):
        log.error(
            "window_does_not_fit_berth",
            window_id=candidate.window_id,
            loa_m=candidate.loa_m,
            draft_m=candidate.draft_m,
            berth_id=candidate.berth_id,
        )
        raise ValueError("BERTH_ENVELOPE_EXCEEDED")

    clashes = [
        other
        for other in existing
        if other.berth_id == candidate.berth_id
        and other.window_id != candidate.window_id
        and other.state not in (WindowState.RELEASED, WindowState.BUMPED)
        and intervals_overlap(candidate, other)
    ]
    log.info(
        "overlap_scan_complete",
        window_id=candidate.window_id,
        berth_id=candidate.berth_id,
        clash_count=len(clashes),
    )
    return clashes

Step 3 — Resolve conflicts with line-service and tidal priority rules

When a candidate window clashes, the machine does not fail — it decides who holds the slot. Priority is a single integer where a lower value is a stronger claim, computed from two dominant rules. A contractual line-service window (a liner that holds a guaranteed weekly berthing window under its terminal agreement) is strong. A tidal window is stronger still when it is a hard physical constraint: a deep-draft vessel that can only enter or sail on the top of the tide has no schedule flexibility, so it out-ranks a shallow-draft feeder that can shift by hours. The candidate wins only if its priority is strictly stronger than every incumbent it clashes with; otherwise it is the one that must move.

def score_priority(w: BerthWindow, *, line_service: bool, tidal_locked: bool) -> int:
    score = 100
    if line_service:
        score -= 40      # contractual berthing window
    if tidal_locked:
        score -= 50      # deep-draft vessel constrained to a high-water window
    return score


def resolve_conflict(candidate: BerthWindow, clashes: list[BerthWindow]) -> Trigger:
    strongest_incumbent = min(c.priority for c in clashes)
    if candidate.priority < strongest_incumbent:
        log.warning(
            "candidate_wins_priority",
            window_id=candidate.window_id,
            candidate_priority=candidate.priority,
            strongest_incumbent=strongest_incumbent,
            bumped=[c.window_id for c in clashes if c.priority >= candidate.priority],
        )
        return Trigger.WIN_PRIORITY
    log.warning(
        "candidate_bumped",
        window_id=candidate.window_id,
        candidate_priority=candidate.priority,
        strongest_incumbent=strongest_incumbent,
    )
    return Trigger.BUMP

Step 4 — Apply transitions idempotently under optimistic locking

Two planners editing the same berth board, an AS2 retransmission, and a retried API call will all deliver the same logical transition more than once. Every transition therefore carries the window version it expects to act on; if the stored version has moved, the write is stale and must be retried against the fresh state rather than clobbering a concurrent decision. Idempotency is layered on top: a dedup key of (window_id, trigger, version) collapses a genuine replay to a no-op so a re-sent confirm never advances the machine twice.

from dataclasses import replace


class IllegalTransition(Exception):
    """Trigger has no legal row from the window's current state."""


class StaleWindowError(Exception):
    """Expected version does not match the stored window; caller must reload."""


def apply_transition(
    window: BerthWindow,
    trigger: Trigger,
    expected_version: int,
    seen: set[tuple[str, str, int]],
) -> BerthWindow:
    if window.version != expected_version:
        raise StaleWindowError(
            f"stale write on {window.window_id}: "
            f"expected v{expected_version}, stored v{window.version}"
        )

    dest = TRANSITIONS.get((window.state, trigger))
    if dest is None:
        raise IllegalTransition(
            f"{trigger.value} illegal from {window.state.value}"
        )

    dedup_key = (window.window_id, trigger.value, window.version)
    if dedup_key in seen:
        log.info("transition_replay_ignored", window_id=window.window_id, trigger=trigger.value)
        return window

    seen.add(dedup_key)
    advanced = replace(window, state=dest, version=window.version + 1)
    log.info(
        "window_transition_applied",
        window_id=window.window_id,
        from_state=window.state.value,
        to_state=dest.value,
        trigger=trigger.value,
        version=advanced.version,
    )
    return advanced

Step 5 — Emit conflict events and drive transitions from AIS arrivals

A CONFLICT state is only useful if a human planner learns about it in time to act. Each conflict emits an immutable event carrying the candidate, its clashing windows, the interval, and the decision, published on BERTH_CONFLICT_TOPIC for the terminal’s berth-planning board. Separately, the OCCUPIED and RELEASED transitions are not scheduled — they are observed. An AIS all-fast event inside the berth geofence, reconciled against the declared ETB, fires Trigger.BERTHED; an off-berth event at ETD fires Trigger.DEPART. Critically, an AIS-derived ETA update that slips a still-CONFIRMED window’s ETB can shrink the gap to its neighbour and re-open the overlap test, pushing the window back through conflict resolution instead of silently double-booking the quay.

import os


def emit_conflict_event(
    candidate: BerthWindow,
    clashes: list[BerthWindow],
    decision: Trigger,
) -> dict[str, object]:
    event: dict[str, object] = {
        "type": "berth.window.conflict",
        "berth_id": candidate.berth_id,
        "candidate_window": candidate.window_id,
        "candidate_imo": candidate.vessel_imo,
        "etb": candidate.etb.isoformat(),
        "etd": candidate.etd.isoformat(),
        "clashing_windows": [c.window_id for c in clashes],
        "decision": decision.value,
        "topic": os.environ.get("BERTH_CONFLICT_TOPIC", "berth.conflict"),
    }
    log.warning("berth_conflict_emitted", **event)
    return event

Edge Cases & Carrier Deviations

Symptom Root cause Fix
Back-to-back windows flagged as clashing Closed-interval overlap test treated etd == etb as intersecting Use half-open [ETB, ETD); touching boundaries must not overlap
Confirmed window suddenly conflicts AIS ETA slip moved ETB into a neighbour’s interval Re-run detect_conflicts on any ETB/ETD change; re-enter TENTATIVE
Deep-draft vessel bumped by a feeder Tidal constraint not scored into priority Add the tidal-lock penalty so a high-water-only vessel out-ranks a flexible one
Two planners overwrite one slot Concurrent writes with no version check Optimistic lock on version; retry the loser against fresh state
Valid window rejected as too long Mega-vessel double-banking or indented berth not modelled Route BERTH_ENVELOPE_EXCEEDED to a manual-override queue, never auto-drop
Zero-length window never conflicts ETB == ETD from a bad agent nomination Reject etd <= etb at construction as a data defect

A recurring deviation is the missing turnaround buffer. A pure [ETB, ETD) model treats a departure and the next arrival at the same instant as non-conflicting, but a real berth needs shifting time for mooring gangs, linesmen, and a pilot. Encode that buffer explicitly by padding the incumbent’s etd (or the candidate’s etb) by the berth’s turnaround minutes before the overlap test, rather than pretending the physical changeover is instantaneous — otherwise the machine confirms a window that operations cannot actually service.

Verification & Testing

Assert the two behaviours most likely to corrupt a berth board — the half-open overlap boundary and idempotent transition replay — plus the priority and stale-lock paths, with pytest.

from datetime import datetime, timedelta, UTC

import pytest


def _w(wid: str, start: int, end: int, prio: int = 100) -> BerthWindow:
    base = datetime(2026, 7, 17, tzinfo=UTC)
    return BerthWindow(
        window_id=wid, berth_id="NLRTM-EMX-07", vessel_imo=9_811_000,
        etb=base + timedelta(hours=start), etd=base + timedelta(hours=end),
        loa_m=300.0, draft_m=13.5, priority=prio,
    )


def test_touching_windows_do_not_overlap() -> None:
    assert intervals_overlap(_w("A", 0, 6), _w("B", 6, 12)) is False


def test_overlapping_windows_clash() -> None:
    assert intervals_overlap(_w("A", 0, 6), _w("B", 4, 10)) is True


def test_stronger_priority_wins() -> None:
    candidate = _w("A", 0, 6, prio=20)
    assert resolve_conflict(candidate, [_w("B", 4, 10, prio=80)]) is Trigger.WIN_PRIORITY


def test_replay_is_a_no_op() -> None:
    seen: set[tuple[str, str, int]] = set()
    w = _w("A", 0, 6)
    once = apply_transition(w, Trigger.PROPOSE, 0, seen)
    twice = apply_transition(w, Trigger.PROPOSE, 0, seen)  # same version → replay
    assert once.state is WindowState.TENTATIVE
    assert twice.state is WindowState.REQUESTED  # replay ignored, original returned


def test_stale_version_rejected() -> None:
    seen: set[tuple[str, str, int]] = set()
    with pytest.raises(StaleWindowError):
        apply_transition(_w("A", 0, 6), Trigger.PROPOSE, expected_version=5, seen=seen)

A passing run emits one JSON line per transition and one warning line per emitted conflict — for example {"event": "berth_conflict_emitted", "berth_id": "NLRTM-EMX-07", "decision": "bump", "log_level": "warning", "timestamp": "..."}. Assert on the structured keys, never on a formatted message string.

Frequently Asked Questions

Why use half-open [ETB, ETD) intervals instead of closed intervals?

Because berths are serviced back-to-back and the common case is one vessel sailing exactly as the next arrives. With a closed interval, a departure at 14:00 and an arrival at 14:00 register as an overlap and trigger a false conflict on every changeover. A half-open interval treats the departure instant as belonging to neither window, so touching boundaries are clean while any genuine intersection is still caught. If the berth needs physical turnaround time, model that as an explicit buffer added to the interval, not as a side effect of interval arithmetic.

Should an AIS ETA update mutate a confirmed window or raise a new event?

Raise a new event and let the machine re-evaluate. An AIS-derived ETA is an assertion about arrival, not an authority to silently rewrite a confirmed slot. Treat the update as a fresh trigger that recomputes the window’s [ETB, ETD) and re-runs overlap detection: if the shifted interval now clashes with a neighbour, the window re-enters conflict resolution rather than double-booking the quay. This keeps the transition auditable — the audit trail shows the ETA slip, the recomputed interval, and the resulting decision.

Why optimistic locking rather than a pessimistic row lock on the berth?

A berth board is read constantly and written rarely, and holding a pessimistic lock across a planner’s interactive edit would serialise the whole quay behind one slow decision. Optimistic locking lets every planner work against a snapshot and only detects a collision at write time, when a stale version reveals that someone else already moved the window. The loser reloads and retries against fresh state, which is cheap because conflicts are rare — and it composes with the idempotency key so a retried write is never mistaken for a second distinct transition.

Up: Port Call Workflow Design — the parent discipline governing vessel-milestone orchestration.