ChronoCore Technical Reference Guide

This document provides a comprehensive reference to the design, architecture, and operation of the ChronoCore Race Software.
Lightweight summaries are included for quick readers, while detailed flows and appendices support deeper study.


1. System Overview

ChronoCore is a real‑time race timing and classification system designed for club and maker events. It ingests timing passes from multiple decoder types, scores laps with duplicate/min‑lap filtering, tracks flags and the race clock, and exposes live state to operator and spectator UIs. The engine is authoritative in RAM with an optional SQLite event journal (replayable via checkpoints). Results can be exported as per‑lap and raw‑event CSVs.


2. Architecture

The system comprises:


3. Decoder Subsystems

All hardware decoders present timing passes into the engine via a uniform interface:

ENGINE.ingest_pass(tag=<str>, device_id=<optional str>, source="track")

The backend implements several decoder classes in backend/decoder.py. Each runs in its own thread, parses hardware output, and emits standardized passes.

3.1 Supported Decoder Types (2025)

Mode Class Transport Notes
ilap_serial ILapSerialDecoder USB/serial Default PRS decoder. SOH-framed lines beginning with @. Optional init_7digit command resets decoder clock.
ambrc_serial AMBRcSerialDecoder USB/serial AMB/MyLaps legacy protocol. Accepts CSV, key=val, or regex-defined formats.
trackmate_serial TrackmateSerialDecoder USB/serial Trackmate IR system. Typically <tag> or <decoder>,<tag>.
cano_tcp CANOTcpDecoder TCP line Covers CANO protocol (used by some DIY and Impinj Speedway bridges). One line per pass.
tcp_line TCPLineDecoder TCP line Alias for CANO TCP, kept for backward compatibility.
mock MockDecoder internal Emits a fake tag on a timer; useful for testing without hardware.

3.2 Configuration (config/config.yaml)

Example:

app:
  hardware:
    decoders:
      ilap_serial:
        port: COM3
        baudrate: 9600
        init_7digit: true

scanner:
  source: ilap.serial
  decoder: ilap_serial
  role: track
  min_tag_len: 7
  duplicate_window_sec: 0.5
  rate_limit_per_sec: 20
  
  serial:
    port: COM3
    baud: 9600
  
  udp:
    host: 0.0.0.0
    port: 5000

publisher:
  mode: http
  http:
    base_url: http://127.0.0.1:8000
    timeout_ms: 500

4. Race Engine

4.1 Logical Flow

The following describes the authoritative race loop and how it processes events in real time.

ChronoCore Race Engine - Logical Flow

  1. Initialization (engine.load)
    The operator creates a new race with a unique race_id and roster of entrants.
    Each entrant has: entrant_id, enabled/disabled flag, status (ACTIVE, DISABLED, DNF, DQ),
    a car number, a name, and optionally a tag UID.
    Only enabled entrants are eligible to score laps. The engine builds a race-local tag → entrant map.

  2. Flags and Clock (engine.set_flag)
    Race control sets flags (pre, green, yellow, red, white, checkered).
  3. Pass Ingestion (engine.ingest_pass)
    A pass arrives with { tag, ts_ns?, source, device_id? }.

    Lap Crediting Logic (2025-10-31 race weekend fix):

  4. Standings Calculation (engine.snapshot)
    Called for /race/state endpoint.
  5. Event Logging (Journal)
    If persistence is enabled, every pass, flag change, and roster change is appended to SQLite as an event row.
  6. Roster Management
    Entrants can be enabled/disabled mid-race (enabled = false → passes ignored).
  7. Post-Race Analysis
    /race/state gives the live, authoritative view.
    The SQLite journal provides historical detail for exports, replay, and audits.
    Operator can merge or reassign provisionals to real entrants without losing data.

Summary
The RaceEngine keeps live scoring fast and authoritative in RAM, while optional persistence ensures data durability.
Standings are always consistent, flag changes are logged, and provisional entrants capture surprises.
This design balances speed, safety, and flexibility for real-world race control.


5. Persistence and Recovery

ChronoCore uses an SQLite event journal to ensure data durability and enable crash recovery. Every significant event (pass, flag change, roster modification) is logged as an event row in the race_events table.

5.1 Journal Tables

race_events: Append-only log of all race events

race_checkpoints: Periodic full snapshots

5.2 Checkpoint Strategy

5.3 Recovery Process

On restart after a crash:

  1. Load the most recent checkpoint from race_checkpoints
  2. Replay all events from race_events that occurred after the checkpoint timestamp
  3. Reconstruct exact race state as if the crash never happened

This ensures zero data loss as long as SQLite WAL (Write-Ahead Logging) is enabled.

5.4 Batch Writing

To minimize I/O overhead, events are batched:

Configuration:

app:
  engine:
    persistence:
      batch_ms: 200
      batch_max: 50
      fsync: true  # force filesystem sync on each batch

This persistence layer balances speed and reliability, providing a durable record for audits and post-race analysis.


6. Results Semantics

6.1 Qualifying Workflow and Grid Freezing (2025-11-03)

ChronoCore supports a complete qualifying workflow where grid position is determined by best lap time and persisted for subsequent races in the same event.

Workflow:

  1. Set up a race with race_type: "qualifying" in Race Setup
  2. Run the qualifying session normally (drivers post laps, best times are tracked)
  3. Optionally set brake test flags for each entrant during/after qualifying
  4. When checkered flag is thrown, a “Freeze Grid Standings” button appears on Race Control
  5. Operator clicks “Freeze Grid” and chooses a brake test policy
  6. Auto-adoption phase (if enabled): System detects provisional entrants and creates permanent records
  7. Grid order is frozen and saved to events.config_json

Auto-Adopt Provisional Entrants (New Feature):

During qualifying, unrecognized transponder tags create “provisional” entrants with negative IDs (e.g., -1, -2) that exist only in RaceEngine memory and results tables. When freezing the grid, the system can automatically convert these into permanent entrant records.

Problem Solved:

Auto-Adopt Behavior (controlled by qualifying.auto_adopt_unknowns config, default: true):

  1. Detection: During freeze, check result_standings for entrants with negative entrant_id
  2. Creation: For each provisional:
  3. ID Mapping: Build map of old negative ID → new positive ID (e.g., -1 → 18)
  4. Update References:
  5. Grid Storage: Frozen grid uses new positive IDs
  6. Return Metadata: API response includes adopted_count and adopted_entrants[] array

Frontend Notification:

When entrants are auto-adopted, a detailed alert dialog shows:

Database Transaction:

All auto-adopt operations (INSERT entrants, UPDATE standings/laps, UPDATE event config) occur in a single transaction. If any step fails, the entire adoption rolls back.

Configuration:

app:
  engine:
    qualifying:
      brake_test_policy: demote           # demote | use_next_valid | exclude
      auto_adopt_unknowns: true           # Auto-create entrants for provisionals
      auto_number_start: 901              # Starting car number for auto-adopted entrants

Settings UI:

Two new fields in Settings → Qualifying section:

Data Model:

Grid data is stored in two places:

  1. Event Config (events.config_json):
    {
      "qualifying": {
     "grid": [
       {
         "entrant_id": 23,
         "order": 1,
         "best_ms": 4210,
         "brake_ok": true
       },
       ...
     ]
      }
    }
    
  2. Result Tables (result_standings, result_laps):

Grid Application:

When loading a subsequent race in the same event:

  1. Backend reads qualifying grid from events.config_json
  2. For each entrant in the roster:
  3. Applies brake test policy and sorts entrants:
  4. Sorted entrant list is passed to ENGINE.load()
  5. Engine stores grid_index and brake_valid on each Entrant object

UI Display:

Scratch Pass (Lap Invalidation):

Operators can invalidate an entrant’s current best qualifying lap during a session:

Backend Implementation:

API Endpoint:

POST /qual/heat/{heat_id}/scratch
Body: {entrant_id: int}
Returns: {entrant_id, scratched_best_s, previous_best_s, brake_ok}

Brake Test Logic:

Frontend:

Data Persistence:

Grid Persistence Flow:

Qualifying Race
   ↓
Checkered Flag
   ↓
Freeze Grid (operator action)
   ↓
Save to result_standings (grid_index, brake_valid)
   ↓
Save to events.config_json (qualifying.grid array)
   ↓
Load Next Race
   ↓
Read grid from config_json
   ↓
Apply grid_index + brake_valid to entrants
   ↓
Sort by policy
   ↓
ENGINE.load(sorted_entrants)
   ↓
Engine.snapshot() includes grid_index/brake_valid
   ↓
UI displays in grid order

Grid Reset:

Three ways to clear a frozen grid:

  1. Run another qualifying session and freeze new results (overwrites)
  2. Delete the qualifying race from Results page (auto-clears grid)
  3. Manual edit of events.config_json (advanced)

Important Implementation Details:

Configuration:

app:
  engine:
    qualifying:
      brake_test_policy: demote  # demote | warn

7. Database Schema

The core race data is stored in an SQLite database managed by backend/db_schema.py. The schema supports both real-time race execution and post-race analysis.

7.1 Core Roster Table

entrants: Central roster table

Unique Constraint: Partial UNIQUE index ensures tags are unique among enabled entrants only:

CREATE UNIQUE INDEX idx_entrants_tag_enabled_unique
ON entrants(tag)
WHERE enabled = 1 AND tag IS NOT NULL;

This allows disabled entrants to retain historical tags while preventing conflicts among active roster.

7.2 Timing Infrastructure

locations: Logical timing points

sources: Physical decoder bindings

passes: Raw detection journal (when journaling enabled)

7.3 Race Model

events: Event/weekend container

heats: Individual race sessions

lap_events: Authoritative lap records

flags: Race control state log

7.4 Frozen Results

result_standings: Final classification

result_laps: Lap-by-lap history

result_meta: Results metadata

Schema Evolution:

7.5 Convenience Views

v_passes_enriched: Passes with location labels v_lap_events_enriched: Laps with team names and locations
v_heats_summary: Heats with aggregated counts and timing

These views simplify UI queries by pre-joining common lookups.

7.6 Schema Management

Schema is created/validated at startup via backend/db_schema.ensure_schema():

Important: The schema is forward-only. Legacy multi-file configs are not supported.


8. API Endpoints

ChronoCore exposes a set of REST endpoints via FastAPI. Below is a detailed reference of each endpoint, including methods, parameters, responses, and key notes.

8.1 API Reference Table

Endpoint Method Params / Body Response Notes
/race/state GET None { race_id, race_type, flag, running, clock_ms, ... } Returns the authoritative snapshot of current race state.
      standings: [ { entrant_id, tag, number, ... } ] UIs poll this at ~3 Hz for live updates.
      last_update_utc, features  
/engine/flag POST { "flag": "pre" | "green" | "yellow" ... } { "ok": true } Sets the current race flag. green starts the clock; checkered freezes it. blue is informational only.
/engine/pass POST { tag, ts_ns?, source, device_id? } { ok, entrant_id, lap_added, lap_time_s, reason } Ingests a timing pass. Adds a lap if Δt ≥ min_lap_s (default ~5.0s).
/engine/load POST { race_id, entrants: [ ... ] } { "ok": true } Initializes a new race session with a given roster.
/engine/snapshot GET None Same as /race/state response Alias for /race/state.

8.2 Spectator UI Contract

8.3 Static Pathing


Enabled-Only Tag Uniqueness & API Contracts (2025-10-04)

Summary of changes


Database - schema and constraints

Partial UNIQUE index (enabled‑only)

CREATE UNIQUE INDEX IF NOT EXISTS idx_entrants_tag_enabled_unique
ON entrants(tag)
WHERE enabled = 1 AND tag IS NOT NULL;

Bootstrap behavior

ensure_schema(db_path) (idempotent):

  1. Creates tables if missing.
  2. Drops any old idx_entrants_tag_enabled_unique.
  3. Recreates the UNIQUE partial index above.
  4. Updates PRAGMA user_version for light migrations.

DB path resolution

Order of precedence:

  1. config/app.yamlapp.engine.persistence.db_path (absolute or relative).
  2. Legacy default: backend/db/laps.sqlite.

Relative paths resolve against repo root unless app.paths.root_base is provided.


API contracts

POST /engine/load

Purpose: Load the runtime session roster (engine mirror).
Request:

{
  "race_id": 1,
  "entrants": [
    { "id": 34, "name": "Circuit Breakers", "number": "7", "tag": "1234567", "enabled": true },
    { "id": 12, "name": "Thunder Lizards",  "number": "42", "tag": null,      "enabled": false }
  ]
}

Validation & normalization:

Responses:


POST /engine/entrant/assign_tag

Purpose: Assign or clear a tag for a single entrant (DB write‑through + engine mirror).
Request: { "entrant_id": 34, "tag": "1234567" } (use "" to clear).
Semantics:

Responses:


GET /admin/entrants

List authoritative DB entrants.
Response: array of { id, number, name, tag, enabled, status }.

POST /admin/entrants

Upsert entrants into the DB with conflict enforcement.
Request:

{
  "entrants": [
    { "id": 34, "number": "7", "name": "Circuit Breakers", "tag": "1234567", "enabled": true,  "status": "ACTIVE" },
    { "id": 12, "number": "42","name": "Thunder Lizards",  "tag": null,      "enabled": false, "status": "ACTIVE" }
  ]
}

Rules:

Responses:


Conflict detection (reference SQL)

When assigning :tag to entrant :id:

SELECT 1
FROM entrants
WHERE enabled = 1
  AND tag = :tag
  AND entrant_id != :id
LIMIT 1;
-- Any row → conflict

Test matrix (must‑pass)

  1. Idempotence: Same tag to same entrant twice → 200 / 200.
  2. Conflict (enabled‑only): Enable a different entrant with same tag → 409.
  3. Loader robustness: Missing id or non‑int id400 with explicit message.
  4. Seatbelt: Two enabled entrants with same tag at SQL level → UNIQUE partial index rejects.

Notes for UI integration

8.4 Flag State Machine & Countdown (2025-10-21)

The race controller maintains both a phase (coarse lifecycle) and a flag (operator-visible color). API consumers and UIs must respect the state machine below to avoid illegal transitions and to keep the race clock aligned with race control decisions.

Phase overview

8.4.1 Soft-End Mode (2026-01-24)

Soft-end mode decouples the visual finish (CHECKERED flag) from race completion (final freeze). This allows drivers to complete their current lap after the time/lap limit is reached, creating more natural race finishes and accurate lap counts.

Key Concepts:

Behavior Comparison:

Aspect Hard-End (soft_end: false) Soft-End (soft_end: true)
WHITE flag timing T-60s / lap N-1 T-60s / lap N-1 (same)
CHECKERED flag timing T=0 / lap N T=0 / lap N (same)
Lap counting after CHECKERED Stops immediately Continues for timeout period
Race freeze Immediate on CHECKERED After soft_end_timeout_s expires
Finish order tracking Not used Sequential crossing order
Standings sort Laps → best → last Laps → finish_order → best → last

Configuration (config/race_modes.yaml):

sprint_10_laps:
  label: "10 Lap Sprint"
  min_lap_s: 5.0
  limit:
    type: laps
    value: 10
    soft_end: true              # Enable soft-end mode
    soft_end_timeout_s: 30      # Timeout in seconds (default: 30)
  scoring:
    method: position

endurance_30min:
  label: "30 Minute Endurance"
  min_lap_s: 8.0
  limit:
    type: time
    value_s: 1800               # 30 minutes
    soft_end: true
    soft_end_timeout_s: 45      # Longer timeout for endurance
  scoring:
    method: laps_then_time

Engine State Tracking:

# Added to RaceEngine.reset():
self.soft_end: bool = False                          # Mode flag from config
self.soft_end_timeout_s: int = 30                    # Configurable timeout
self._checkered_flag_start_ms: Optional[int] = None  # Track CHECKERED start

# Added to Entrant:
self.finish_order: Optional[int] = None              # Crossing position after limit
self.soft_end_completed: bool = False                # Has entrant finished final lap?

Automatic Flag Behavior:

  1. WHITE Flag (Warning):
  2. CHECKERED Flag (Finish):

Lap Counting Logic After CHECKERED:

# In ingest_pass():
if self.flag == "checkered" and not self.soft_end:
    return {"ok": True, "reason": "checkered_freeze"}  # Hard-end: stop immediately

if self.soft_end and self.flag == "checkered" and ent.soft_end_completed:
    return {"ok": True, "reason": "soft_end_completed"}  # Entrant already finished

# Count lap and track finish order:
ent.laps += 1
if self.flag == "checkered" and ent.finish_order is None:
    self._finish_order_counter += 1
    ent.finish_order = self._finish_order_counter
    if self.soft_end:
        ent.soft_end_completed = True  # Block future laps for this entrant

Timeout Enforcement:

# In _update_clock():
if (self.flag == "checkered"
    and self.soft_end
    and self._checkered_flag_start_ms is not None
    and self.soft_end_timeout_s > 0):
    
    checkered_duration_ms = self.clock_ms - self._checkered_flag_start_ms
    timeout_ms = self.soft_end_timeout_s * 1000
    
    if checkered_duration_ms >= timeout_ms:
        # Freeze race after timeout expires
        self.running = False
        self.clock_start_monotonic = None
        self.clock_ms_frozen = self.clock_ms

Standings Sort Order:

def sort_key(e: Entrant):
    best = e.best_s if e.best_s is not None else 9e9
    last = e.last_s if e.last_s is not None else 9e9
    finish = e.finish_order if e.finish_order is not None else 9e9
    
    if self.soft_end:
        # Soft-end: use finish_order as primary tiebreaker after laps
        return (-e.laps, finish, best, last, e.entrant_id)
    else:
        # Hard-end: traditional sort (no finish order)
        return (-e.laps, best, last, e.entrant_id)

Race Timeline Example (10-lap race, 30s timeout):

Lap 9:  WHITE flag thrown (leader reaches lap 9)
Lap 10: Leader crosses → CHECKERED flag thrown
        - Leader assigned finish_order = 1
        - Timer starts: _checkered_flag_start_ms = 600000
        - Race continues running (running = True)
+5s:    P2 crosses at lap 10 → finish_order = 2, soft_end_completed = True
+8s:    P3 crosses at lap 9 → finish_order = 3, soft_end_completed = True
+12s:   P4 crosses at lap 9 → finish_order = 4, soft_end_completed = True
+30s:   Timeout expires → Race freezes (running = False)
        - Final standings: sorted by (laps desc, finish_order asc)

Persistence:

Session Config Override:

# In /engine/load endpoint:
session_config = {
    "limit": {
        "type": "time",
        "value_s": 1200,       # 20 minutes
        "soft_end": False      # Override mode's soft_end setting
    }
}
# If soft_end key present in session_config, it overrides race mode config
# Otherwise, race mode config value is preserved

UI Implications:

Allowed flag transitions

Current phase Accepted flags Notes
pre pre, green Countdown may be scheduled from pre; repeated pre calls are no-ops.
countdown pre UI uses this to abort a start. Timer fires green automatically; manual green calls are acknowledged but ignored.
green green, yellow, red, blue, white, checkered Returning to green is always legal so marshals can clear incidents quickly.
white green, yellow, red, blue, white, checkered Identical to green apart from banner styling.
checkered checkered Engine refuses other colors until the session resets; repeated checkered is idempotent.

Duplicate submissions return 200 OK with flag unchanged so callers can treat /engine/flag as idempotent.

API contract (POST /engine/flag)

{ "flag": "green" }

Countdown semantics

UI contract

Error semantics & observability

Verification checklist

  1. From pre, request green with a countdown; confirm phase=countdown, then green fires automatically.
  2. Abort the countdown via pre; verify the timer cancels and green_at_utc disappears from /race/state.
  3. From green, send yellowgreen; ensure standings continue updating and responses stay 200.
  4. After checkered, attempt green; expect 409 with phase="checkered".
  5. Restart the backend mid-countdown; confirm phase resets to pre and no stale countdown remains.

8.5 Qualifying and Grid Freezing (2025-11-02)

ChronoCore supports qualifying sessions where best lap times determine starting grid order for subsequent races. The frozen grid is persisted in the event’s config JSON and applies to all heats in that event.

Endpoints:

Endpoint Method Body Response Notes
/event/{event_id}/qual/freeze POST { source_heat_id: int, policy: "demote"\|"use_next_valid"\|"exclude" } { event_id, qualifying: { source_heat_id, policy, grid: [...] } } Freezes grid from qualifying heat results
/event/{event_id}/qual GET None { event_id, qualifying: {...} } or { event_id, qualifying: null } Retrieves frozen grid for an event
/results/{race_id} DELETE ?confirm=heat-{race_id} { race_id, laps_deleted, standings_deleted, meta_deleted, cleared_qualifying_grid?: bool } Deletes frozen results; auto-clears qualifying grid if this was the source

Freeze Grid Logic:

  1. Collect lap durations - Extract all lap times from lap_events for the qualifying heat
  2. Load brake test verdicts - Manual pass/fail flags stored in heat config JSON
  3. Calculate best lap per entrant:
  4. Rank entrants - Sort by (excluded, demoted, best_ms)
  5. Assign 1-based order - Grid positions for each entrant
  6. Persist to event config - Stored in events.config_json under qualifying key

Event Config Structure:

{
  "qualifying": {
    "source_heat_id": 42,
    "policy": "demote",
    "grid": [
      {
        "entrant_id": 12,
        "order": 1,
        "best_ms": 23456,
        "brake_ok": true
      },
      {
        "entrant_id": 7,
        "order": 2,
        "best_ms": 23789,
        "brake_ok": true
      }
    ]
  }
}

Grid Application:

When loading a race (/engine/load), if heats.event_id has a frozen qualifying grid:

Auto-Clear on Delete:

When deleting frozen results via DELETE /results/{race_id}:

  1. Backend checks if race_id matches any event’s qualifying.source_heat_id
  2. If match found, sets qualifying: null in event config
  3. Response includes "cleared_qualifying_grid": true
  4. Prevents orphaned qualifying data from deleted heats

UI Integration:

Notes:


10. Frontend Clients

10.1 Polling Strategy

The Operator and Spectator UIs are static HTML/CSS/JS clients that poll the /race/state endpoint for live updates.

Standard polling rate: ~3 Hz (every ~333ms) during normal operation.

Adaptive polling: After a flag change, the Operator UI polls at ~250ms for 2 seconds to ensure the banner updates appear immediately.

Connection status: The UI footer displays connection health:

10.2 State Synchronization

Both UIs consume the same /race/state snapshot which includes:

10.3 Flag Banner Logic

The flag banner uses CSS classes derived from the snapshot:

.flag.flag--{color}        /* base color (green, yellow, red, white, checkered, blue) */
.flag.is-pulsing          /* animated attention state */
.flag.flag--green.flash   /* one-shot flash when entering green */

Pulsing states: YELLOW, RED, BLUE, CHECKERED (continuous attention) Flash state: GREEN (single animation on green entry, then stable)

Accessibility: Banner includes aria-label with format: "{Color} - {Meaning}" (e.g., “White - Final Lap”, “Blue - Driver Swap”)

10.4 Leaderboard Updates

Standings are rendered directly from the /race/state response:

Each standing row includes:

Viewport: Operator UI shows ~16 rows before scrolling begins (configurable via app.ui.visible_rows)


9. OSC Lighting Integration

The system provides bidirectional Open Sound Control (OSC) integration with QLC+ lighting software, enabling synchronized race flag lighting and operator-assisted flag controls.

9.1 Architecture Overview

Protocol: UDP-based OSC (Open Sound Control)
Direction: Bidirectional (CCRS ↔ QLC+)
Threading Model: OSC receiver runs on dedicated thread, callbacks marshaled to FastAPI event loop via asyncio.call_soon_threadsafe
Safety Mechanism: Phase-based guards prevent lighting operator from controlling critical timing events (race start/end)

Dependencies:

Modules:

9.2 OSC Output (osc_out.py)

Purpose: Send real-time lighting commands from CCRS to QLC+ based on race state changes.

Key Components:

class OscLightingOut:
    def __init__(self, cfg: dict)
    def send_flag(self, flag: str)          # GREEN, YELLOW, RED, etc.
    def send_blackout(self, active: bool)   # True=off, False=on
    def cleanup()

Message Format:

UDP Reliability Strategy:

Configuration Reference:

integrations:
  lighting:
    osc_out:
      enabled: true
      host: "192.168.1.101"    # QLC+ listening IP
      port: 9000               # QLC+ listening port
      send_repeat: 3           # UDP redundancy count
      addresses:
        flags: "/ccrs/flag"    # Base path for flag messages
        blackout: "/ccrs/blackout"

Trigger Points:

9.3 OSC Input (osc_in.py)

Purpose: Receive flag and blackout commands from QLC+ operator buttons, enabling lighting operator to assist with flag changes without controlling race timing.

Key Components:

class OscInbound:
    def __init__(self, cfg: dict, on_flag: Callable, on_blackout: Callable)
    def start()                                    # Spawns listener thread
    def stop()                                     # Graceful shutdown
    def _handle_default(self, addr, *values)       # Processes flag messages
    def _handle_blackout(self, addr, *values)      # Processes blackout messages

Threading Model:

Message Processing:

Configuration Reference:

integrations:
  lighting:
    osc_in:
      enabled: true
      host: "0.0.0.0"          # Bind to all interfaces
      port: 9010               # CCRS listening port
      paths:
        flags: "/ccrs/flag/*"  # Wildcard pattern for flag messages
        blackout: "/ccrs/blackout"
      threshold_on: 0.7        # Minimum value to trigger
      debounce_off_ms: 500     # Blackout-off debounce delay

9.4 Server Integration Points

Lifecycle Management (server.py):

# Startup handler (line ~3410)
@app.on_event("startup")
async def start_osc_lighting():
    # Initializes _osc_out and _osc_in globals
    # Starts OSC receiver thread via _osc_in.start()
    # Registers async callbacks for flag/blackout handling

# Shutdown handler (line ~3528)
@app.on_event("shutdown")
async def stop_osc_lighting():
    # Gracefully stops receiver thread
    # Cleans up UDP sockets

Helper Functions:

Integration Points:

Frontend Integration:

9.5 Safety Mechanisms

Phase-Based Guards (in _handle_flag_from_qlc):

# Prevent lighting operator from starting race
if flag == "GREEN" and engine.phase in ("pre", "countdown"):
    logger.warning("Lighting operator cannot start race (phase=%s)", engine.phase)
    return  # Silently reject

# Prevent lighting operator from ending race
if flag == "CHECKERED" and engine.phase not in ("green", "white"):
    logger.warning("Lighting operator cannot end race during phase=%s", engine.phase)
    return  # Silently reject

Rationale:

Non-Critical Failure Handling:

9.6 Configuration Reference

Complete YAML Block:

integrations:
  lighting:
    # === OSC OUTPUT (CCRS → QLC+) ===
    osc_out:
      enabled: true
      host: "192.168.1.101"         # QLC+ OSC input IP
      port: 9000                    # QLC+ OSC input port
      send_repeat: 3                # Send each message N times (UDP reliability)
      addresses:
        flags: "/ccrs/flag"         # Base OSC path for flag messages
        blackout: "/ccrs/blackout"  # OSC path for blackout control
    
    # === OSC INPUT (QLC+ → CCRS) ===
    osc_in:
      enabled: true
      host: "0.0.0.0"               # Bind address (0.0.0.0 = all interfaces)
      port: 9010                    # CCRS listening port
      paths:
        flags: "/ccrs/flag/*"       # Wildcard pattern for incoming flags
        blackout: "/ccrs/blackout"  # Path for incoming blackout
      threshold_on: 0.7             # Minimum value to trigger (0.0-1.0)
      debounce_off_ms: 500          # Debounce blackout-off messages

Network Requirements:

QLC+ Configuration:

9.7 Message Timing and Performance

Latency Characteristics:

Impact on Race Timing:

Diagnostic Tools:


10. Moxie Board Scoring Integration (2025-11-13)

ChronoCore includes integration support for wireless Moxie Board scoring systems used at Power Racing Series events. Moxie scoring is purely button-press based - it tracks crowd votes via wireless button presses on the physical moxie board, independent of race performance metrics.

10.1 Overview

The Moxie Board is a physical display showing entrant positions and scores based on button presses from spectators and officials. The scoring is a simple count - each button press adds to an entrant’s moxie score. The system distributes a fixed total number of points (typically 300) among all active entrants based on their button press counts.

Key Characteristics:

10.2 Configuration

Moxie Board integration is controlled via config/config.yaml:

app:
  engine:
    scoring:
      break_ties_by_best_lap: true
      include_pit_time_in_total: true
      
      # Moxie Board integration
      moxie:
        enabled: true                   # Enable Moxie Board scoring integration
        auto_update: true               # Automatically update moxie scores on button press
        total_points: 300               # Total points available for distribution (typically 300)
        board_positions: 20             # Number of positions on the moxie board (18, 20, or 24 typically)

Configuration Parameters:

10.3 API Endpoint

The backend exposes moxie configuration to frontends via:

GET /config/ui_features

Response:

{
  "moxie_board": {
    "enabled": true,
    "auto_update": true,
    "total_points": 300,
    "board_positions": 20
  }
}

This endpoint is polled by the operator UI on startup to determine whether to show the moxie board navigation button.

10.4 UI Integration

When moxie.enabled = true:

The moxie board page (currently in development) will provide:

10.5 Scoring Algorithm

The moxie score for each entrant is calculated as:

entrant_moxie_points = (entrant_button_presses / total_button_presses) * total_points

Where:

Example: With total_points: 300 and 3 entrants:

10.6 Physical Board Display

The board_positions parameter determines how many entrants can be shown on the physical moxie board hardware. Common values:

Only the top N entrants (by moxie score) are sent to the physical display hardware. The operator UI shows all entrants with their scores and indicates which ones are currently displayed on the board.

10.7 Implementation Status

Currently Available (v0.1.1):

Planned Features:


11. Configuration (YAML keys of interest)

The system uses a single unified configuration file: config/config.yaml

11.1 Core Structure

app:
  name: ChronoCore Race Software
  version: 0.9.0-dev
  environment: development              # development | production
  
  engine:
    event:
      name: Event Name                  # displayed in UI headers
      date: '2025-11-08'               # ISO date
      location: City, State
    
    default_min_lap_s: 10              # global minimum lap time threshold
    
    persistence:
      enabled: true
      sqlite_path: backend/db/laps.sqlite  # REQUIRED: database location
      journal_passes: true              # enable raw pass journaling
      snapshot_on_checkered: true       # freeze results on CHECKERED
      checkpoint_s: 15                  # engine snapshot interval
      batch_ms: 200                     # journal batch window
      batch_max: 50                     # journal batch size
      fsync: true                       # force sync on writes
      recreate_on_boot: false           # WARNING: destroys existing data
    
    unknown_tags:
      allow: true                       # create provisional entrants
      auto_create_name: Unknown         # prefix for provisional names
    
    diagnostics:
      enabled: true
      buffer_size: 500                  # max events in diagnostics stream
      beep:
        max_per_sec: 5                  # rate limit for beep feature
  
  client:
    engine:
      mode: localhost                   # localhost | fixed | auto
      fixed_host: 127.0.0.1:8000        # used when mode=fixed
      prefer_same_origin: false         # prefer browser's origin
      allow_client_override: true       # allow UI host override
  
  hardware:
    decoders:
      ilap_serial:
        port: COM3                      # Windows: COMx, Linux: /dev/ttyUSBx
        baudrate: 9600
        init_7digit: true               # reset decoder on startup
        min_lap_s: 10
      ambrc_serial:
        port: COM4
        baudrate: 19200
      trackmate_serial:
        port: COM5
        baudrate: 9600
    
    pits:
      enabled: false
      receivers:
        pit_in: []                      # device_id list for pit entry
        pit_out: []                     # device_id list for pit exit

scanner:
  source: ilap.serial                   # ilap.serial | ilap.udp | mock
  decoder: ilap_serial                  # references app.hardware.decoders key
  role: track                           # track | pit_in | pit_out
  min_tag_len: 7                        # reject tags shorter than this
  duplicate_window_sec: 0.5             # suppress duplicate reads within window
  rate_limit_per_sec: 20                # global pass throughput limit
  
  serial:
    port: COM3                          # override decoder port if needed
    baud: 9600
  
  udp:
    host: 0.0.0.0                       # bind address for UDP listener
    port: 5000

publisher:
  mode: http                            # http | inprocess
  http:
    base_url: http://127.0.0.1:8000
    timeout_ms: 500                     # request timeout

log:
  level: info                           # debug | info | warning | error

sounds:
  volume:
    master: 1.0                         # 0.0 - 1.0
    horns: 1.0
    beeps: 1.0
  files:
    lap_indication: lap_beep.wav
    countdown: countdown_beep.wav
    start: start_horn.wav
    white_flag: white_flag.wav
    checkered: checkered_flag.wav

journaling:
  enabled: false                        # legacy passes table journaling
  table: passes_journal                 # table name for raw pass log

11.2 Path Resolution

11.3 Important Defaults


12. Simulation Tools

12.1 Mock Decoder

The system includes a built-in mock decoder for testing without hardware:

scanner:
  source: mock
  mock_tag: "3000999"
  mock_period_s: 6.0

The mock decoder emits a fixed tag at regular intervals, useful for:

11.2 Startup Scripts

ChronoCore provides production-ready startup scripts for different deployment scenarios:

Run-Server.ps1 (Windows browser-based deployment):

Run-Operator.ps1 (Windows desktop application):

Run-Spectator.ps1 (Windows remote display):

Run-Spectator.sh (Linux remote display):

Venv Path Workaround: All scripts use python.exe -m <module> pattern instead of wrapper executables to handle broken venv shebang paths. This allows using existing venvs without recreation.

11.3 Simulator Scripts

Sprint Simulator (scripts/Run-SimSprint.ps1):

Endurance Simulator (scripts/Run-SimEndurance.ps1):

11.4 Dummy Data Loader

backend/tools/load_dummy_from_xlsx.py:

Typical workflow:

python backend/tools/load_dummy_from_xlsx.py roster.xlsx

11.5 Feed Simulator

backend/tools/sim_feed.py:

Usage:

python backend/tools/sim_feed.py --entrants 10 --duration 300 --mean-lap 45

11.5 Testing Strategy

Unit Tests: Test individual components (engine, decoders, parsers)

Integration Tests: Test full flow from decoder → engine → persistence

UI Tests: Use mock decoder with known sequences to verify UI behavior

Performance Tests: Use sim_feed to generate high-volume pass streams


13. Engine Host Discovery

Background

Older builds assumed the Operator UI always talked to localhost:8000. This created confusion when running the UI remotely or in the field.

The system now defines engine host resolution policy in app.yaml as the single source of truth. UIs follow the same precedence rules everywhere.

Location

Defaults live in:

Precedence Order

  1. Same-origin (if prefer_same_origin: true and UI is served by the engine)
  2. Device override (if allow_client_override: true and set in localStorage cc.engine_host)
  3. Policy fallback (from YAML):
  4. Last resort: same-origin again if available.

Example Configurations

Development laptop

app:
  client:
    engine:
      mode: localhost
      allow_client_override: true

Field deployment (fixed IP)

app:
  client:
    engine:
      mode: fixed
      fixed_host: "10.77.0.10:8000"
      allow_client_override: false

Mixed environment

app:
  client:
    engine:
      mode: auto
      fixed_host: "10.77.0.10:8000"
      prefer_same_origin: true
      allow_client_override: true

Operator UI Behavior

Notes for Developers


14. Requirements & Runtime


16. Troubleshooting Lap Crediting Issues (2025-10-31)

During race weekend testing, several scenarios were identified where transponder reads appeared in diagnostics but laps weren’t being credited. This section documents common gating conditions and diagnostic procedures.

16.1 Common Gating Conditions

Laps will NOT be credited if:

  1. Phase is not GREEN/WHITE
  2. Minimum lap time not met (min_lap_s)
  3. Duplicate window filter (min_lap_dup)
  4. Source role mismatch
  5. Entrant not enabled or not ACTIVE
  6. First crossing after GREEN

16.2 Diagnostic Procedures

When laps aren’t counting but diagnostics shows passes:

  1. Check race phase
    GET /race/state
    Verify: "flag": "green" and "running": true
    
  2. Verify minimum lap time
    Check config.yaml → app.engine.default_min_lap_s
    Typical racing: 10-30 seconds
    Bench testing: reduce to 2-5 seconds
    
  3. Review detection vs lap count
  4. Enable detailed logging
    log:
      level: debug  # Shows per-detection decision reasons
    
  5. Check persistence path

16.3 Race Weekend Fix (October 31, 2025)

Problem identified:

Solution implemented:

Code location: backend/race_engine.py line ~430

if f_lower == "green":
    if not self.running:
        # Clear any pre-race crossing timestamps to prevent short first laps
        for ent in self.entrants.values():
            ent._last_hit_ms = None

16.4 Bench Testing Recommendations

When testing timing hardware without actual racing:

  1. Reduce minimum lap time
    app:
      engine:
        default_min_lap_s: 2  # Allow fast manual tag presentations
    
  2. Watch diagnostics stream
  3. Use mock decoder for UI testing
    scanner:
      source: mock
      mock_tag: "3000999"
      mock_period_s: 6.0
    
  4. Check effective configuration
    GET /race/state
    Verify min_lap_s matches your expectations
    

17. Appendices


Last updated: 2025-11-13