Tuning geofence thresholds for yard tracking

This guide shows how to size and adapt geofence boundaries for a container terminal yard so that entry, dwell, and exit events fire on real equipment moves — not on GPS multipath, AIS latency, or a tractor idling one metre outside a static radius.

Architecture Alignment

Geofence tuning is the spatial specialisation of the parent Threshold Tuning for Alerts topic: that page governs scalar boundaries (speed drift, dwell counters, sync lag) with hysteresis and dual-source fusion, while this page applies the same discipline to a two-dimensional boundary — a radius or polygon around a yard zone, crane envelope, or tidal-adjusted berth box. Both live inside the Container Tracking & AIS Event Synchronization domain, whose job is to fuse the push-based AIS Data Stream Integration feed with landside moves pulled through Terminal API Polling Strategies into one trustworthy asset state. Get the radius wrong and every downstream consumer inherits phantom ENTER/EXIT events; the transitions this layer emits are resolved by the Container Status Mapping Rules engine and, for berth-approach zones, feed the Port Call Workflow Design state machine before any milestone is committed.

Yard geofence evaluation pipeline Raw AIS or RTLS telemetry is normalised to WGS84 and UTC. A guard discards stale frames older than the TTL and out-of-order frames. Surviving positions enter a bounded sliding-window deque, then the Haversine distance to the zone centre is measured. A decision gate asks whether that distance is within the base radius plus a velocity term (SOG times k). If not, the asset is OUTSIDE and no event fires. If it is inside, the hysteresis re-arm margin is applied, a state machine emits ENTER, DWELL, or EXIT, and the transition is written to an audited, idempotent alert event. Yard geofence evaluation pipeline From raw telemetry to one audited, idempotent transition — with stale / out-of-order guards and hysteresis. INGEST GUARD BUFFER MEASURE DECIDE AIS / RTLS telemetry Normalize WGS84 · UTC Sliding-window deque Haversine distance to zone centre dist ≤ base + SOG·k ? stale / late Discard stale · out-of-order no OUTSIDE no event yes · inside Apply hysteresis margin — re-arm slack State machine ENTER · DWELL · EXIT Audit log idempotent alert event

Prerequisites & Environment Setup

  • Python 3.11+ for timezone-aware datetime handling and tomllib config loading.
  • Packages: pydantic>=2 (typed validation of positions and geofence config), structlog (structured JSON logs), pyais (decoding ITU-R M.1371 position reports off the wire), and shapely>=2 (polygon clipping for topology-aware masking). numpy is optional for vectorising historical baselines.
  • Coordinate reference: every input is normalised to WGS84 decimal degrees before evaluation. Terminal RTLS payloads that arrive in a local projection (a port-specific CRS) must be reprojected at the ingestion boundary, not inside the evaluator.
  • Environment variables: YARD_LOCODE (the UN/LOCODE of the facility), GEOFENCE_CONFIG_URL (the centralized store the versioned bands load from), and POSITION_TTL_SECONDS (staleness cutoff, default 120).
python -m pip install "pydantic>=2" structlog pyais "shapely>=2"
export YARD_LOCODE="NLRTM"
export POSITION_TTL_SECONDS=120

Container identity carried on each track is resolved back against the Container Hierarchy Data Models so a bare box number keeps its size-type and grouping, and any zone that maps to a restricted access area inherits the scopes defined in the Maritime Security Boundary Setup work.

Step-by-step Implementation

Each step is runnable in isolation and uses type annotations with structlog — a bare print() is never acceptable in a pipeline that has to be audited. The reference AIS payload structure follows ITU-R M.1371 type 1/2/3 position reports.

Step 1 — Model the position and the versioned geofence config

Bind the incoming track to a pydantic model so an out-of-range coordinate is rejected structurally rather than producing a NaN distance later. Keep the geofence configuration in a separate versioned model so radii are diffable, auditable, and hot-reloadable from the config store without a redeploy.

from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator


class PositionRecord(BaseModel):
    """A validated ITU-R M.1371 type 1/2/3 position report."""

    asset_id: str
    lat: float = Field(..., ge=-90.0, le=90.0)
    lon: float = Field(..., ge=-180.0, le=180.0)
    ts_utc: datetime
    sog_knots: Optional[float] = Field(None, ge=0.0, le=102.2)

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


class GeofenceBand(BaseModel):
    """A versioned, hysteresis-aware spatial boundary for one yard zone."""

    geofence_id: str
    center_lat: float
    center_lon: float
    base_radius_m: float = 25.0        # lane width + GPS error margin
    velocity_scale_factor: float = 2.5  # metres of lookahead per m/s of SOG
    hysteresis_margin_m: float = 5.0    # re-arm slack to stop boundary flapping
    window_size: int = 12
    version: str                        # e.g. "yard-A@2026-07-01"

The base_radius_m default of 25 m is a starting point for RTK-corrected yard assets; standard marine AIS with no RTK correction needs 50–100 m. Initialise it to terminal lane width plus GPS error margin, never to a guessed round number.

Step 2 — Compute great-circle distance with Haversine

Yard coordinates are close together, but a flat-earth approximation drifts metres at terminal scale, so use the Haversine formula on the WGS84 sphere. Bounding-box pre-checks (Step 5) keep this off the hot path for assets clearly out of range.

import math


def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    r = 6_371_000.0  # WGS84 mean earth radius, metres
    dlat = math.radians(lat2 - lat1)
    dlon = math.radians(lon2 - lon1)
    a = (
        math.sin(dlat / 2) ** 2
        + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
    )
    return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))

Step 3 — Derive the adaptive radius from velocity and dwell state

A static radius cannot serve both a stationary RTG and a tractor braking into a lane. Expand the boundary proportionally to speed-over-ground (converted from knots to m/s) so a fast approach clears the perimeter with a lookahead buffer, then add the hysteresis margin only while inside so an asset lingering on the edge does not flap between states.

def adaptive_radius_m(pos: PositionRecord, band: GeofenceBand, inside: bool) -> float:
    sog_ms = (pos.sog_knots or 0.0) * 0.514444  # knots -> m/s
    radius = band.base_radius_m + sog_ms * band.velocity_scale_factor
    return radius + band.hysteresis_margin_m if inside else radius
How the adaptive radius rejects edge flapping Three concentric bands surround the zone centre. The inner solid ring is the base radius, the ENTER boundary sized from lane width plus GPS error. The middle dashed ring is the base radius plus the hysteresis margin, the EXIT boundary that adds re-arm slack. The outer dotted ring is the velocity band, the base radius plus speed-over-ground times k, which grows with speed. An asset must cross the solid ring to ENTER but drift past the dashed ring to EXIT, so a stationary box jittering by a few metres between the two rings never flaps state. A fast approach widens the band via the velocity term so ENTER fires early with lookahead. How the adaptive radius rejects edge flapping Enter on the solid ring; leave only past the dashed ring. Jitter between them cannot flip the state. zone centre fast approach jitter ±8 m Reading the bands ENTER — base radius lane width + GPS error (~25 m) EXIT — base + hysteresis re-arm slack (~5 m); stops flapping Velocity band base + SOG·k lookahead; grows with speed Rule Cross the solid ring to ENTER; drift past the dashed ring to EXIT. Jitter between the two never flaps.

Step 4 — Evaluate against a memory-bounded sliding window

High-density yards carry 500+ concurrent tracks; storing full trajectories in RAM triggers garbage-collection pauses that miss SLA-critical alerts. Hold history in a bounded collections.deque so heap allocation is predictable, drop stale and out-of-order frames before they can trip a spurious event, and emit a typed transition.

import os
import structlog
from collections import deque
from datetime import datetime, timezone

log = structlog.get_logger()
POSITION_TTL_S = int(os.getenv("POSITION_TTL_SECONDS", "120"))


class YardGeofenceEvaluator:
    def __init__(self, band: GeofenceBand) -> None:
        self.band = band
        self.state = "OUTSIDE"
        self.history: deque[PositionRecord] = deque(maxlen=band.window_size)

    def evaluate(self, pos: PositionRecord) -> str:
        now = datetime.now(timezone.utc)
        if (now - pos.ts_utc).total_seconds() > POSITION_TTL_S:
            log.warning("geofence.stale_discarded", asset_id=pos.asset_id)
            return "NONE"
        if self.history and pos.ts_utc < self.history[-1].ts_utc:
            log.info("geofence.out_of_order_skipped", asset_id=pos.asset_id)
            return "NONE"

        self.history.append(pos)
        dist = haversine_m(self.band.center_lat, self.band.center_lon, pos.lat, pos.lon)
        threshold = adaptive_radius_m(pos, self.band, inside=self.state == "INSIDE")

        prev, self.state = self.state, ("INSIDE" if dist <= threshold else "OUTSIDE")
        if prev == "OUTSIDE" and self.state == "INSIDE":
            return "ENTER"
        if prev == "INSIDE" and self.state == "OUTSIDE":
            return "EXIT"
        return "DWELL" if self.state == "INSIDE" else "NONE"

Step 5 — Mask non-operational zones with polygon clipping

A radius is a blunt instrument: it will alert on equipment parked in a maintenance bay or crossing an administrative corridor. Clip the evaluation to the operational polygon first — a shapely contains check is cheap and eliminates both the false alert and the downstream Haversine cost for points outside the working area.

from shapely.geometry import Point, Polygon

# Operational area for yard zone A (WGS84 lon/lat pairs), excluding the maintenance bay.
OPERATIONAL_AREA = Polygon([
    (4.0521, 51.9512), (4.0539, 51.9512),
    (4.0539, 51.9498), (4.0521, 51.9498),
])


def in_operational_area(pos: PositionRecord) -> bool:
    return OPERATIONAL_AREA.contains(Point(pos.lon, pos.lat))

Step 6 — Emit one audited, idempotent transition per event

Every transition — fired or suppressed — writes one structlog JSON record with a payload hash, so an auditor can reconstruct exactly why a zone alerted. Retention aligns with port-authority mandates and the record strips positional PII beyond the asset identifier. This mirrors the append-only audit discipline of the Container Status Mapping Rules engine that consumes the event.

import hashlib


def audit_transition(pos: PositionRecord, band: GeofenceBand, transition: str,
                     dist_m: float, threshold_m: float) -> None:
    fingerprint = hashlib.sha256(
        f"{pos.asset_id}|{band.version}|{transition}|{pos.ts_utc.isoformat()}".encode()
    ).hexdigest()
    log.info(
        "geofence.transition",
        asset_id=pos.asset_id,
        geofence_id=band.geofence_id,
        transition=transition,
        dist_m=round(dist_m, 2),
        threshold_m=round(threshold_m, 2),
        band_version=band.version,
        idempotency_key=fingerprint,  # dedupes replayed frames downstream
    )

Edge Cases & Carrier Deviations

  • Multipath between stacked containers. Reflection off quay walls and 9-high stacks injects 3–15 m of positional noise, exactly the scale of a tight yard radius. The hysteresis margin (Step 3) absorbs it; without it a stationary box flaps ENTER/EXIT every few seconds.
  • Missing SOG/COG during low-maneuverability states. AIS may null the speed field when an asset is nearly stationary. adaptive_radius_m coerces a null sog_knots to 0.0, collapsing the band to the static base radius rather than raising an exception.
  • AIS MMSI collisions. Two assets sharing a mis-provisioned MMSI produce interleaved tracks that teleport across the yard. Key the evaluator per resolved asset_id (not raw MMSI) and reject a frame whose implied speed between consecutive fixes exceeds the physical maximum before it reaches the state machine.
  • Tidal-adjusted berth boxes. A berth-approach geofence must move with the tide: recompute center_lat/center_lon or the polygon vertices from the tidal datum each cycle rather than pinning them to chart datum, or approaching vessels alert late at low water.
  • Out-of-order and stale frames. Congested VHF and cellular backhaul deliver frames late. Steps 4’s monotonic-timestamp and TTL guards drop them; a late frame must never rewrite a newer state.
  • Unbounded memory on storm surges. A reconnection after an outage floods the buffer. The deque(maxlen=...) ring buffer caps history per zone; profile with tracemalloc and cap the number of live evaluators to bound total heap.

Verification & Testing

Assert three things: a fast approach widens the radius, a jittering stationary asset does not flap, and a stale frame is dropped. The fixtures use the models above with synthetic telemetry.

from datetime import datetime, timedelta, timezone


def _pos(asset: str, lat: float, lon: float, sog: float, age_s: float = 0.0) -> PositionRecord:
    return PositionRecord(
        asset_id=asset, lat=lat, lon=lon, sog_knots=sog,
        ts_utc=datetime.now(timezone.utc) - timedelta(seconds=age_s),
    )


BAND = GeofenceBand(geofence_id="yard-A", center_lat=51.9505, center_lon=4.0530,
                    base_radius_m=25.0, version="yard-A@test")


def test_enter_then_dwell_without_flapping():
    ev = YardGeofenceEvaluator(BAND)
    assert ev.evaluate(_pos("RTG7", 51.9505, 4.0530, 0.0)) == "ENTER"
    # 8 m of multipath jitter must NOT bounce the state back out
    assert ev.evaluate(_pos("RTG7", 51.95057, 4.0530, 0.0)) == "DWELL"


def test_velocity_widens_the_band():
    fast = _pos("TRK3", 51.9505, 4.0530, 12.0)  # ~6.2 m/s
    assert adaptive_radius_m(fast, BAND, inside=False) > BAND.base_radius_m


def test_stale_frame_is_discarded():
    ev = YardGeofenceEvaluator(BAND)
    assert ev.evaluate(_pos("TRK3", 51.9505, 4.0530, 0.0, age_s=999)) == "NONE"

Expected structured-log output for an ENTER followed by a discarded stale frame — each line is one JSON object your log store can index on transition and asset_id:

{"event": "geofence.transition", "asset_id": "RTG7", "geofence_id": "yard-A", "transition": "ENTER", "dist_m": 0.0, "threshold_m": 25.0, "band_version": "yard-A@test"}
{"event": "geofence.stale_discarded", "asset_id": "TRK3"}

A burst of geofence.transition records with alternating ENTER/EXIT for one asset is itself an alerting signal — wire it back into the Threshold Tuning for Alerts layer so a flapping zone pages an operator instead of drowning the bus.

Frequently Asked Questions

How do I pick the base radius for a yard zone?

Start from the physical geometry, not a round number: take the lane or block width the zone must contain and add your positioning error margin. RTK-corrected yard equipment holds 1–3 m, so 15–30 m is typical; standard marine AIS without correction needs 50–100 m. Store it as a versioned GeofenceBand, then tune it against historical yard-move ground truth during a low-traffic maintenance window before it reaches production.

Why add hysteresis instead of just a bigger radius?

A bigger radius delays every real detection and swallows adjacent zones. Hysteresis instead widens the boundary only for exit — an asset must cross the base radius to enter but drift past radius + hysteresis_margin_m to leave. That rejects GPS multipath jitter around the edge without blunting the entry detection, so a box sitting one metre inside the line stays INSIDE instead of flapping.

Does velocity scaling risk alerting too early on a fast approach?

That is the intent, and it is bounded. The band grows linearly with speed-over-ground so a tractor braking into a lane trips ENTER with enough lookahead to cover telemetry latency, but a stationary asset (SOG 0) collapses the band back to the static base radius. The dwell/exit hysteresis then prevents the widened band from immediately reversing, so the early ENTER does not produce a phantom EXIT on the next fix.

↑ Back to Threshold Tuning for Alerts.