Fare Zone Taxonomy Design
A robust fare zone taxonomy is the coordinate system every downstream pricing decision depends on, and it sits at the heart of Core Architecture & Fare Taxonomy as the spatial contract that turns a raw tap into a billable, auditable event. Within that pipeline, zone definitions dictate how tap events are priced, routed, and reconciled across multi-operator networks: get the geometry or the precedence wrong and you leak revenue through double-charging, misrouted transfers, and unresolvable inter-agency settlement disputes. For transit operators and revenue analysts, the taxonomy must balance administrative simplicity against the geometric precision that distance-based and zonal pricing require. For mobility-tech developers and Python automation builders, it is a strict, versioned data contract that governs event enrichment, pipeline validation, and revenue allocation.
Modern fare zones rarely map cleanly to municipal boundaries. They are logical pricing constructs that must be reconciled against real-time vehicle positioning from a GTFS-RT Realtime Sync feed and stop-level metadata. When designing the hierarchy, teams distinguish between primary fare districts, transfer corridors, and concession overlays. Each tier needs explicit boundary definitions to prevent double-charging or revenue leakage during peak-hour routing shifts, and the whole model must tolerate feed latency so that zone-crossing logic survives timestamp drift without raising false reconciliation flags. This component consumes normalized events from Smart Card Schema Mapping and hands enriched, zone-tagged events to the pricing stage in Fare Rule Validation & Calculation Engines.
Architecture: How Zone Data Flows Through This Component
The taxonomy is not a static GIS layer that sits to one side of the pipeline; it is an active stage with its own ingest, validation, indexing, and query paths. Zone definitions are authored as versioned GeoJSON, validated for topology, materialized as indexed PostGIS geometries, and then queried at high throughput by enrichment workers. The precedence flow below shows how a single tap intersecting several geometries resolves to one deterministic fare product.
Prerequisites & Environment
This component assumes a Python 3.11+ runtime and a PostgreSQL 15+ database with the PostGIS 3.3+ extension enabled. The reference implementation pins:
shapely2.0+ — geometry construction, validity repair, and predicate tests (intersects,touches,contains).pydantic2.5+ — schema enforcement at the zone-definition boundary (v2 validator API).asyncpg0.29+ — connection pooling and batched spatial queries against PostGIS.decimal(stdlib) — all tariff and fare amounts; floating-point money is never permitted.
AFC vendor assumptions: validators emit a coordinate (or a resolvable stop_id) and a UTC-normalized tap_timestamp per the envelope produced upstream. Zone geometries are expected in WGS84 (EPSG:4326) with an accompanying effective_from / effective_to window so any tap can be re-priced against the boundary set that was live at tap time. Where vendors supply zones in a projected CRS, reproject to EPSG:4326 at ingest so spatial predicates and stored geometries share one datum. Data schema expectations for every zone record: a stable zone_id, a tier enum (primary, corridor, overlay), an integer priority, and a valid, non-empty polygon or multipolygon.
Hierarchical Zone Modeling & Precedence
A production-grade taxonomy enforces a strict three-tier hierarchy:
- Primary districts — mutually exclusive polygons defining the base fare rate for a tap.
- Transfer corridors — linear or buffered zones where inter-operator transfers trigger fare capping or zero-rating. Corridor semantics are owned jointly with the Transfer Window Logic that decides whether a second tap falls inside an eligible transfer.
- Concession overlays — attribute-driven masks (student, senior, off-peak) that modify pricing without altering spatial topology. Overlay eligibility is authoritative in the Discount Eligibility Engines; the taxonomy only records where an overlay applies, never whether a given rider qualifies.
Precedence must be deterministic. When a tap intersects multiple geometries, the pipeline resolves the conflict through a weighted priority matrix rather than an arbitrary ORDER BY. Given the set of zones whose geometry contains the tap point, the resolved zone is the one with maximal priority:
Priorities are assigned so that transfer corridors outrank concession overlays, which outrank primary districts — a corridor zero-rating must never be overridden by a base-district charge. This precedence directly informs how fare products resolve during schema mapping and pricing, and the ordering is frozen as reference data so an auditor can replay any tap to the same answer.
| Tier | Example zone | Priority | Effect on fare | Reference owner |
|---|---|---|---|---|
| Transfer corridor | Cross-modal interchange buffer | 300 | Cap or zero-rate the second leg | Fare engine |
| Concession overlay | Off-peak student mask | 200 | Apply discount factor | Eligibility service |
| Primary district | Zone A base polygon | 100 | Set base fare | Fare engine |
Core Implementation: Topology Validation
Before zones enter production, enforce non-overlapping primary districts and explicit precedence using shapely and pydantic. The validator repairs invalid geometry, rejects empty inputs, and the overlap check surfaces conflicting primary districts before they can double-charge a rider:
from __future__ import annotations
import logging
from typing import List
from pydantic import BaseModel, field_validator
from shapely.geometry import shape
from shapely.geometry.base import BaseGeometry
from shapely.validation import make_valid
logger = logging.getLogger("afc.taxonomy")
class FareZone(BaseModel):
zone_id: str
tier: str # "primary" | "corridor" | "overlay"
geometry: dict
priority: int
@field_validator("tier")
@classmethod
def known_tier(cls, v: str) -> str:
if v not in {"primary", "corridor", "overlay"}:
raise ValueError(f"Unknown zone tier: {v!r}")
return v
@field_validator("geometry")
@classmethod
def validate_topology(cls, v: dict) -> dict:
geom: BaseGeometry = make_valid(shape(v))
if geom.is_empty:
raise ValueError("Empty or invalid geometry")
return geom.__geo_interface__
def check_primary_overlap(zones: List[FareZone]) -> List[str]:
"""Detect overlapping primary districts. Returns conflicting zone IDs.
Two primary districts may share an edge (``touches``) but must never
share interior area (``intersects`` without ``touches``), which would
make a tap on the seam ambiguous and risk double-charging.
"""
primary = [z for z in zones if z.tier == "primary"]
conflicts: set[str] = set()
for i, a in enumerate(primary):
geom_a = shape(a.geometry)
for b in primary[i + 1:]:
geom_b = shape(b.geometry)
if geom_a.intersects(geom_b) and not geom_a.touches(geom_b):
logger.error("Primary districts overlap: %s <-> %s", a.zone_id, b.zone_id)
conflicts.update({a.zone_id, b.zone_id})
return sorted(conflicts)
Schema Validation & Transit-Specific Edge Cases
Pipeline ingestion workers apply sliding-window validation to align tap timestamps with vehicle dwell events before spatial enrichment runs. Real-time feeds routinely exhibit clock skew, position jitter, and missing trip_update payloads, and a production pipeline must absorb these without halting reconciliation. The contract-level checks here reuse the same discipline documented in the Schema Validation Pipelines component; the taxonomy layer adds the spatial and temporal rules specific to zone resolution.
Key edge cases and how the taxonomy handles them:
- Null / missing coordinate. A tap with no coordinate cannot be spatially resolved. Fall back to
stop_id-to-zone mapping; if that is also absent, route to the deterministic base district with aconfidence: LOWflag rather than dropping revenue. - Boundary ambiguity. GPS drift near a jurisdictional line can place a tap in the wrong district. Apply a spatial tolerance buffer (typically 15 m) and, when two primary districts both fall inside the buffer, send the tap to a manual review queue instead of auto-settling.
- Idempotent event keys. Deduplicate with
validator_id + tap_timestamp + card_uidso retries during offline sync never enrich the same tap twice. - Timezone normalization. Match against the UTC-normalized
tap_timestamp, never the raw device clock — a skewed validator RTC otherwise pushes a tap into the wrong effective-date window and selects a stale zone version. - Timestamp drift buffer. Apply a configurable window when aligning taps to vehicle positions. A tap aligns to a vehicle position when , with typically 15–30 s.
- Dead-letter routing. Events failing spatial validation (coordinates outside the service envelope) are routed to a DLQ with structured error codes for audit, never silently discarded.
from __future__ import annotations
import time
from dataclasses import dataclass
@dataclass
class ReconciliationState:
tap_timestamp: float
vehicle_timestamp: float
max_drift_sec: float = 25.0
retry_count: int = 0
def is_aligned(self) -> bool:
"""True when the tap and vehicle position fall inside the drift window."""
return abs(self.tap_timestamp - self.vehicle_timestamp) <= self.max_drift_sec
def should_retry(self, max_retries: int = 3) -> bool:
return self.retry_count < max_retries
def backoff_delay(self) -> float:
"""Bounded exponential backoff with jitter for transient sync failures."""
base = 2 ** self.retry_count
jitter = 0.5 * (time.time() % 1)
return min(base + jitter, 10.0)
Memory-Efficient Spatial Enrichment
Ingesting zone definitions into a streaming reconciliation pipeline demands rigorous data validation and a bounded memory footprint. Raw tap events must be enriched with a spatial join before they reach the aggregation layer, and zone geometries serve as the primary spatial index for event routing and partitioning. The mechanics of materializing those boundaries — GiST indexes, ST_Contains, ST_Crosses, and multi-modal geometry unions — are covered in depth in Mapping Multi-Modal Fare Zones to PostGIS Polygons; this section focuses on the streaming worker that drives those queries.
Loading entire geometry sets into process memory is a common anti-pattern. Instead, use bounded generators and chunked spatial queries so memory stays flat under high-throughput load. The worker below acquires a pooled connection, batches taps, and resolves each to its highest-priority zone in a single round trip:
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Dict
import asyncpg
logger = logging.getLogger("afc.enrichment")
@asynccontextmanager
async def get_db_pool(dsn: str) -> AsyncIterator[asyncpg.Pool]:
pool = await asyncpg.create_pool(dsn=dsn, min_size=2, max_size=10)
try:
yield pool
finally:
await pool.close()
async def enrich_tap_stream(
tap_events: AsyncIterator[Dict[str, Any]],
dsn: str,
chunk_size: int = 500,
) -> AsyncIterator[Dict[str, Any]]:
"""Memory-efficient spatial enrichment using chunked PostGIS queries.
Each chunk resolves every tap to the highest-priority zone whose
geometry contains its point. Taps with no containing zone receive a
deterministic fallback marker for downstream audit.
"""
query = """
SELECT DISTINCT ON (t.tap_id)
t.tap_id, z.zone_id, z.tier, z.priority
FROM tap_buffer t
JOIN fare_zones z
ON ST_Contains(z.geom, ST_SetSRID(ST_Point(t.lon, t.lat), 4326))
WHERE t.tap_id = ANY($1::text[])
ORDER BY t.tap_id, z.priority DESC;
"""
async with get_db_pool(dsn) as pool:
chunk: list[Dict[str, Any]] = []
async def flush(batch: list[Dict[str, Any]]) -> AsyncIterator[Dict[str, Any]]:
if not batch:
return
ids = [c["tap_id"] for c in batch]
async with pool.acquire() as conn:
rows = await conn.fetch(query, ids)
zone_map = {
r["tap_id"]: {"zone_id": r["zone_id"], "tier": r["tier"]}
for r in rows
}
for event in batch:
enriched = event.copy()
enriched["enrichment"] = zone_map.get(
event["tap_id"], {"zone_id": "UNKNOWN", "tier": "fallback"}
)
if enriched["enrichment"]["tier"] == "fallback":
logger.warning("No containing zone for tap %s", event["tap_id"])
yield enriched
async for tap in tap_events:
chunk.append(tap)
if len(chunk) >= chunk_size:
async for out in flush(chunk):
yield out
chunk.clear()
# Flush the trailing partial chunk.
async for out in flush(chunk):
yield out
Integration Pattern: Handing Off to Adjacent Components
The taxonomy sits between media normalization and pricing, and its handoffs must be explicit. Upstream, it consumes the unified event envelope emitted by Smart Card Schema Mapping: the enrichment worker never touches raw sector data, only a normalized tap_id, coordinate, and UTC timestamp. Downstream, it attaches a resolved zone_id, tier, and precedence-selected fare context, then passes the event to the pricing stage, where capping and proration in the Fare Rule Validation & Calculation Engines act on it — corridor tags feed transfer capping, overlay tags feed concession discounting.
Two boundaries carry security and resilience weight. Zone configuration payloads are signed, versioned, and distributed to edge validators under the rules set by AFC System Security Boundaries, so a validator can trust a cached zone table without a live server. And when the live spatial path is unavailable, resolution follows the same conservative posture as the offline caching in Fallback Routing Strategies.
Deterministic Fallback
When real-time spatial joins fail or the vehicle-position feed degrades, the reconciliation engine falls back to a static, versioned zone cache so revenue allocation continues without double-charging or leakage. The decision flow below traces the fallback chain from live lookup down to the base-district default.
from __future__ import annotations
import logging
from decimal import Decimal
from typing import Dict, Optional
logger = logging.getLogger("afc.fallback")
class FallbackReconciler:
"""Resolve a zone from a signed static cache when live lookup is down."""
def __init__(self, static_zone_cache: Dict[str, Dict[str, object]]):
self.cache = static_zone_cache
self.default_zone = "BASE_DISTRICT"
self.base_fare = Decimal("2.75") # money is always Decimal, never float
def resolve(self, tap: Dict[str, object], vehicle_pos: Optional[Dict[str, float]]) -> Dict[str, object]:
# Prefer stop_id routing when a trustworthy coordinate is unavailable.
stop_id = tap.get("stop_id")
if isinstance(stop_id, str) and stop_id in self.cache:
return self.cache[stop_id]
logger.info("Falling back to base district for tap %s", tap.get("tap_id", "<unknown>"))
return {
"zone_id": self.default_zone,
"pricing_rule": "flat_base",
"base_fare": self.base_fare,
"confidence": "LOW",
}
For transit operators, this fallback is auditable through daily reconciliation reports: revenue analysts filter on the confidence flag to isolate low-certainty transactions for manual review. Developers version zone payloads with semantic versioning and embed cryptographic signatures so no unauthorized topology change can reach an edge validator.
Performance & Scale Considerations
Fare-file volumes make the spatial join the dominant cost in this stage, so scale planning targets three levers:
- Chunk sizing. Keep enrichment batches at 500 taps or fewer. Larger batches inflate the
ANY($1)parameter array and the working set of the GiST index scan, eroding the flat-memory property; smaller batches waste round trips. Benchmark per hardware, but treat 500 as the default ceiling. - Index discipline. A GiST index on
fare_zones.geomis mandatory; without it,ST_Containsdegrades to a sequential scan that collapses throughput at production volumes. Validate topology nightly withST_IsValidReasonso a silently corrupted geometry cannot poison query planning. - Connection pooling. Bound the pool (
min_size=2, max_size=10in the reference worker) so a burst of enrichment workers cannot exhaust PostGIS connections and starve the reconciliation batch running alongside them. - Parallelism caveat. Zone geometry is read-mostly reference data, so enrichment parallelizes cleanly across taps — but a mid-day zone version rollout must be atomic. Swap the effective version behind a single transaction (or a versioned table alias) so concurrent workers never straddle two boundary sets and produce split pricing for the same journey.
Operational Checklist
- Enforce
NOT NULLconstraints onzone_id,tier, andpriorityin PostGIS. - Index geometries with GiST and validate topology nightly using
ST_IsValidReason. - Implement sliding-window alignment for real-time feed latency tolerance.
- Route enrichment failures to a structured dead-letter queue with retry metadata.
- Cache signed zone payloads on edge validators for offline reconciliation.
- Monitor memory usage of spatial-join workers; enforce chunk sizes of 500 events or fewer.
- Verify concession overlays never intersect primary districts without an explicit precedence rule.
- Version zone payloads semantically and swap effective versions atomically.
By treating the taxonomy as a strict, versioned data contract rather than a static GIS layer, transit teams achieve sub-second enrichment, zero-duplicate reconciliation, and resilient revenue allocation across fragmented multi-modal networks.
Related pages
- Mapping Multi-Modal Fare Zones to PostGIS Polygons — materializing zone boundaries as indexed PostGIS geometries.
- Smart Card Schema Mapping — the normalized event envelope this stage consumes.
- Fallback Routing Strategies — offline tap caching and conservative resolution when live lookup fails.
- AFC System Security Boundaries — signing and distributing zone configuration to edge validators.
- Transfer Window Logic — how corridor tags become fare-capping and transfer decisions.
Part of Core Architecture & Fare Taxonomy · feeds pricing in Fare Rule Validation & Calculation Engines.