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.
Prerequisites & Environment Setup
- Python 3.11+ for timezone-aware
datetimehandling andtomllibconfig 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), andshapely>=2(polygon clipping for topology-aware masking).numpyis 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), andPOSITION_TTL_SECONDS(staleness cutoff, default120).
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
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/EXITevery few seconds. - Missing SOG/COG during low-maneuverability states. AIS may null the speed field when an asset is nearly stationary.
adaptive_radius_mcoerces a nullsog_knotsto0.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_lonor 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 withtracemallocand 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.
Related
- Threshold Tuning for Alerts — the scalar hysteresis-and-fusion layer this spatial tuning specialises.
- AIS Data Stream Integration — parsing raw NMEA/AIS into the positions each geofence evaluates.
- Terminal API Polling Strategies — landside moves cross-validated against geofence transitions.
- Container Status Mapping Rules — the state engine that consumes yard
ENTER/EXITevents. - Container Hierarchy Data Models — the equipment topology every tracked asset resolves against.
↑ Back to Threshold Tuning for Alerts.