Skip to content

Detection layer

The detection layer converts raw telemetry signals and staleness observations into lifecycle FSM events. It sits between the MQTT consumer and the run_lifecycle_event Celery task.

All detection code lives in backend/runs/domain/detection/.

Design: pure planners and impure wrappers

The detection layer is split into two tiers, defined in dispatch.py:

Pure planners (no I/O, unit-testable)
    plan_telemetry_events(lifecycle_state, leaf, data, base_payload)
    plan_scan_events(lifecycle_state, staleness_s, payload)

Impure wrappers (read Redis, enqueue Celery tasks)
    detect_from_telemetry(run_id, vehicle_id, leaf, data)
    detect_from_scan(run_id, staleness_s, raw_last_seen)

The planners take the run's current lifecycle state and a message and return a list of DetectionResult objects. They perform no I/O and hold no references to Redis or Celery — they can be unit-tested with a plain function call.

The wrappers are called by the MQTT consumer and the stale-scan task respectively. They read the current lifecycle state from Redis (hget run:<id> run_lifecycle_state), build the base payload, call the pure planner, and then fire each result as a run_lifecycle_event Celery task.

Detector registry

Defined in backend/runs/domain/detection/registry.py. Two ordered lists:

TELEMETRY_DETECTORS = [
    RunTrackingStartedDetector(),
    RunStartedDetector(),
    RunTrackingRestoredDetector(),
    RunCompletedDetector(),
]

PERIODIC_DETECTORS = [
    RunTrackingLostDetector(),
    RunTrackingExpiredDetector(),
]

At most one event per FSM per evaluation. plan_telemetry_events tracks which FSMs have already fired (fired_fsms: set[str]) and skips any detector whose FSM has already produced a result. Since all current detectors share fsm = "lifecycle", at most one lifecycle event fires per telemetry message.

Order matters within a single FSM: the first matching detector wins. The ordering [TrackingStarted, Started, TrackingRestored, Completed] means that if a Confirmed run receives its first ping (matching RunTrackingStartedDetector), the RunStartedDetector never gets to run for that message.

Adding a new detector is a one-line change to the registry list.

Telemetry detectors (lifecycle_detectors.py)

RunTrackingStartedDetector

Condition: run_state == "Confirmed", any leaf.

Fires: run_tracking_started

The arrival of any valid telemetry from a confirmed run is sufficient to start tracking. No leaf-specific check needed.

RunStartedDetector

Condition: run_state == "Tracking" AND leaf == "position" AND speed > 0.5 m/s.

Fires: run_started

Only position pings carry speed. The 0.5 m/s threshold matches MIN_MOVING_SPEED and the is_vehicle_moving guard.

RunTrackingRestoredDetector

Condition: run_state == "No Signal", any leaf.

Fires: run_tracking_restored

Symmetrical to RunTrackingStartedDetector. Any fresh ping from a no-signal run means contact is restored.

RunCompletedDetector

Condition: run_state == "In Progress" AND leaf == "progression" AND current_status == "STOPPED_AT" AND stop_id is present.

Fires: run_completed with extra_payload = {"stop_id": stop_id}

Note that the leaf == "progression" check does not mean the edge sends a progression MQTT topic. The process_position_update task feeds the server-computed vehicle_stop_status dict back into detect_from_telemetry with leaf="progression" explicitly. The actual MQTT consumer does not subscribe to a progression topic.

The terminal-stop check is not done here. It is deferred to the is_at_terminal_stop guard in the lifecycle FSM. This means run_completed fires whenever the vehicle is stopped at any stop — the guard rejects it if that stop is not the terminal stop. This separation keeps detectors simple and guards authoritative.

Periodic detectors (periodic_detectors.py)

RunTrackingLostDetector

Condition: run_state == "In Progress" AND TELEMETRY_GRACE_S < staleness_s <= TELEMETRY_EXPIRY_S

That is: staleness is between 60 s and 600 s.

Fires: run_tracking_lost with actor_role = "system"

RunTrackingExpiredDetector

Condition: run_state == "No Signal" AND staleness_s > TELEMETRY_EXPIRY_S

That is: staleness exceeds 600 s.

Fires: run_tracking_expired with actor_role = "system"

Staleness thresholds

Defined in backend/runs/domain/detection/thresholds.py — the single source of truth imported by both detectors and guards:

TELEMETRY_GRACE_S = 60    # IN_PROGRESS + silent > 60 s  → NO_SIGNAL
TELEMETRY_EXPIRY_S = 600  # NO_SIGNAL + silent > 600 s   → CANCELLED

Why a single thresholds module?

Previously the thresholds lived in two places that disagreed: realtime_engine/tasks.py used 300 s and guards.py used 600 s, so the periodic scan could fire run_tracking_expired in a window where the FSM guard would then reject it. Both now import from thresholds.py.

The tracking seed

detect_from_telemetry has a special case for run_tracking_started and run_tracking_restored. The FSM guard is_vehicle_tracked checks SISMEMBER runs:tracking <run_id>, but the very arrival of telemetry is what justifies that membership. The dispatcher seeds the set before firing these events:

_TRACKING_SEED_EVENTS = {"run_tracking_started", "run_tracking_restored"}

def _fire(result: DetectionResult, base_payload: dict) -> None:
    if result.event in _TRACKING_SEED_EVENTS:
        r.sadd("runs:tracking", base_payload["run_id"])
    run_lifecycle_event.delay(result.event, payload)

This mirrors the original inline behavior while keeping detectors pure.

DetectionResult

@dataclass(frozen=True)
class DetectionResult:
    fsm: str             # "lifecycle" — routing key for the target FSM
    event: str           # RunLifecycleEvents member value
    extra_payload: dict  # merged into base_payload before firing

Lifecycle event trigger table

From backend/realtime_engine/README.md:

Run state Trigger condition Event fired
Confirmed Any valid ping received run_tracking_started
Tracking position.speed > 0.5 m/s run_started
No Signal Any valid ping received run_tracking_restored
In Progress server-computed STOPPED_AT with stop_id run_completed
In Progress staleness > 60 s (periodic scan) run_tracking_lost
No Signal staleness > 600 s (periodic scan) run_tracking_expired

See commands-vs-detections.md for the full commands/detections split, and stale-runs.md for the scan_stale_runs periodic task that drives the last two entries.