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.
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.
The system comprises:
ingest_pass() calls.checkpoint snapshots for crash recovery./race/state; operator screens include control surfaces and exports./diagnostics/stream publishes raw pass events for live debugging.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.
| 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. |
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
scanner.source to the desired type and set scanner.decoder to reference the appropriate app.hardware.decoders key.port is correct for the host system (Windows: COM3, Linux: /dev/ttyUSB0).host and port under scanner.udp or publisher.http sections.line_regex under the decoder configuration with named groups (?P<tag>...) and optionally (?P<decoder>...).app.hardware.pits.receivers.pit_in_receivers and pit_out_receivers with device_id lists to auto-classify passes.The following describes the authoritative race loop and how it processes events in real time.
ChronoCore Race Engine - Logical Flow
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.
engine.set_flag)_last_hit_ms = None) to prevent artificially short first laps caused by pre-race parade lap crossings.engine.ingest_pass){ tag, ts_ns?, source, device_id? }.
auto_provisional = true, a new provisional entrant is created as "Unknown ####".min_lap_s, min_lap_dup), update laps/last/best/pace_5.Lap Crediting Logic (2025-10-31 race weekend fix):
_last_hit_ms). No lap credited yet - this is the “arming” pass.min_lap_dup (default 1.0s): Rejected as duplicate, no lap creditedmin_lap_s (default 5.0s): Rejected as too fast, no lap creditedmin_lap_s: Lap credited, increment lap counter, update last/best/pace timesbest_sGreen flag reset: When transitioning to GREEN, all _last_hit_ms timestamps are cleared to ensure first racing lap has accurate timing
pit_in starts a pit window; pit_out closes the window, computes pit time, increments pit_count.engine.snapshot)/race/state endpoint.
laps desc → best asc → last asc → entrant_id asc.laps, last, best, pace_5, gap_s, lap_deficit, pit_count, last_pit_s.flag, race_id, clock, running, last_update_utc).checkpoint_s), a full snapshot is written as a checkpoint.enabled = false → passes ignored).
ACTIVE, DISABLED, DNF, DQ) can be updated mid-race for classification./race/state gives the live, authoritative view.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.
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.
race_events: Append-only log of all race events
race_id - associates event with a specific race sessionts_utc - UTC epoch milliseconds when event occurredclock_ms - race clock position (milliseconds since GREEN)type - event type (pass, flag_change, entrant_enable, assign_tag)payload_json - event-specific data (tag, flag value, etc.)race_checkpoints: Periodic full snapshots
race_id - race session identifierts_utc - when checkpoint was writtenclock_ms - race clock at checkpoint timesnapshot_json - complete engine state (entrants, standings, flags, etc.)app.engine.persistence.checkpoint_s)On restart after a crash:
race_checkpointsrace_events that occurred after the checkpoint timestampThis ensures zero data loss as long as SQLite WAL (Write-Ahead Logging) is enabled.
To minimize I/O overhead, events are batched:
batch_ms)batch_max)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.
/results/{id} and /results/{id}/laps; live view normalizes /race/state to match the frozen format for consistent rendering.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:
race_type: "qualifying" in Race Setupevents.config_jsonAuto-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:
entrant_id → if provisionals remain negative, grid positions won’t applyAuto-Adopt Behavior (controlled by qualifying.auto_adopt_unknowns config, default: true):
result_standings for entrants with negative entrant_idresult_standings (e.g., “Unknown 3000123”)qualifying.auto_number_start, default: 901)entrants table with enabled=1result_standings.entrant_id (negative → positive)result_laps.entrant_id (negative → positive)adopted_count and adopted_entrants[] arrayFrontend 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:
events.config_json):
{
"qualifying": {
"grid": [
{
"entrant_id": 23,
"order": 1,
"best_ms": 4210,
"brake_ok": true
},
...
]
}
}
result_standings, result_laps):
result_standings.grid_index (INTEGER) - qualifying position (1, 2, 3…)result_standings.brake_valid (INTEGER) - brake test result (1=pass, 0=fail, NULL=no test)Grid Application:
When loading a subsequent race in the same event:
events.config_jsongrid_index field with their qualifying positionbrake_valid field with their brake test resultENGINE.load()grid_index and brake_valid on each Entrant objectUI Display:
Scratch Pass (Lap Invalidation):
Operators can invalidate an entrant’s current best qualifying lap during a session:
Backend Implementation:
RaceEngine._scratched_best_times - Dict[entrant_id, scratched_time] tracks invalidated lapsRaceEngine.scratch_entrant_best(entrant_id) - Scratches current best, recalculates from lap history{ok, scratched_best_s, previous_best_s}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:
previous_best_s exists → brake_ok = True (valid fallback)previous_best_s is None → brake_ok = False (no valid time)set_brake_flag()Frontend:
Data Persistence:
_scratched_best_times)_lap_history remains intact for auditheats.config_json.qual_brake_flagsGrid 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:
events.config_json (advanced)Important Implementation Details:
Entrant.__slots__ includes grid_index and brake_valid to allow storageEntrant.__init__() accepts these fields as optional parametersEntrant.as_snapshot() includes these fields in the returned dict_state_seen_block() includes grid metadata in seen.rows for frontend sortingConfiguration:
app:
engine:
qualifying:
brake_test_policy: demote # demote | warn
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.
entrants: Central roster table
entrant_id (PK) - unique identifiernumber (TEXT) - car number (supports formats like “004”, “A12”)name (TEXT, NOT NULL) - team/driver nametag (TEXT) - transponder UID (nullable when unassigned)enabled (INTEGER, DEFAULT 1) - 1=active, 0=disabledstatus (TEXT, DEFAULT ‘ACTIVE’) - ACTIVE |
DISABLED | DNF | DQ |
organization, spoken_name, color, logo - optional metadataupdated_at (INTEGER) - last modification timestampUnique 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.
locations: Logical timing points
location_id (PK, TEXT) - short ID (‘SF’, ‘PIT_IN’, ‘X1’)label (TEXT) - human-readable label (‘Start/Finish’, ‘Pit In’)sources: Physical decoder bindings
source_id (PK) - auto-increment IDcomputer_id (TEXT) - host identifierdecoder_id (TEXT) - reader device IDport (TEXT) - communication port (‘COM7’, ‘udp://0.0.0.0:5001’)location_id (FK) - references locations(location_id)passes: Raw detection journal (when journaling enabled)
pass_id (PK) - auto-incrementts_ms (INTEGER) - host epoch millisecondstag (TEXT) - transponder IDt_secs (REAL) - optional decoder timestampsource_id (FK) - references sources(source_id)raw (TEXT) - original packet for forensicsmeta_json (TEXT) - optional metadata (channel, RSSI, etc.)events: Event/weekend container
event_id (PK)name, date_utc - event identityconfig_json (TEXT) - event-wide settings (qualifying rules, etc.)heats: Individual race sessions
heat_id (PK)event_id (FK) - parent eventname - heat identifier (“Heat 1”, “Feature”, etc.)order_index - display orderingconfig_json (TEXT) - heat-specific rules (duration, min_lap_ms)lap_events: Authoritative lap records
lap_id (PK)heat_id (FK) - which raceentrant_id (FK) - which driverlap_num (INTEGER) - 1-based lap numberts_ms (INTEGER) - when lap was creditedsource_id (FK) - where it was detectedinferred (INTEGER) - 0=real, 1=predicted/inferredmeta_json (TEXT) - inference metadataflags: Race control state log
flag_id (PK)heat_id (FK)state (TEXT) - GREEN |
YELLOW | RED | WHITE | CHECKERED | BLUE |
ts_ms (INTEGER) - when flag changedactor, note - who/whyresult_standings: Final classification
race_id, position (PK composite)entrant_id, number, name, taglaps, last_ms, best_ms, gap_ms, lap_deficitpit_count, statusgrid_index (INTEGER, nullable) - qualifying position (1, 2, 3…) when applicablebrake_valid (INTEGER, nullable) - brake test result (1=pass, 0=fail, NULL=no test)result_laps: Lap-by-lap history
race_id, entrant_id, lap_no (PK composite)lap_ms - lap duration in millisecondspass_ts_ns - optional original pass timestampresult_meta: Results metadata
race_id (PK)race_type - sprint |
endurance | qualifying |
frozen_utc - ISO8601 timestamp when results frozeduration_ms - total race durationclock_ms_frozen, event_label, session_label, race_mode - extended metadataSchema Evolution:
grid_index and brake_valid columns added via migration (2025-11-03)backend/migrations/add_qualifying_columns.pyv_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.
Schema is created/validated at startup via backend/db_schema.ensure_schema():
PRAGMA foreign_keys=ON at runtime)PRAGMA user_version)Important: The schema is forward-only. Legacy multi-file configs are not supported.
ChronoCore exposes a set of REST endpoints via FastAPI. Below is a detailed reference of each endpoint, including methods, parameters, responses, and key notes.
| 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. |
/race/state only..flag.flag--{lowercase_color} plus modifiers:
.flag.is-pulsing for attention states (yellow, red, blue, checkered)..flag.flag--green.flash one-shot on entering green."Color - Meaning" (e.g., “White - Final Lap”, “Blue - Driver Swap”)./ui.ui/ with operator pages at /ui/operator/*.html.enabled=1 AND tag IS NOT NULL), so disabled entrants can keep historical tags.ensure_schema() drops any legacy non‑unique tag index and recreates the correct UNIQUE partial index./engine/entrant/assign_tag returns 200 if the value is unchanged; otherwise writes through to SQLite after conflict checks./engine/load rejects malformed payloads with 400 and explanatory messages.backend/db/laps.sqlite, overridable in config/app.yaml./healthz (liveness) and /readyz (DB readiness).CREATE UNIQUE INDEX IF NOT EXISTS idx_entrants_tag_enabled_unique
ON entrants(tag)
WHERE enabled = 1 AND tag IS NOT NULL;
ensure_schema(db_path) (idempotent):
idx_entrants_tag_enabled_unique.PRAGMA user_version for light migrations.Order of precedence:
config/app.yaml → app.engine.persistence.db_path (absolute or relative).backend/db/laps.sqlite.Relative paths resolve against repo root unless app.paths.root_base is provided.
POST /engine/loadPurpose: 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:
race_id required and int‑able.entrants must be a list of objects with int‑able id.null.Responses:
200 { "ok": true, "entrants": [ids...] }400 on malformed payload (e.g., missing/invalid id).POST /engine/entrant/assign_tagPurpose: Assign or clear a tag for a single entrant (DB write‑through + engine mirror).
Request: { "entrant_id": 34, "tag": "1234567" } (use "" to clear).
Semantics:
200, engine kept in sync.Responses:
200 on success or no‑op.404 if entrant id not present in DB.412 if runtime session not loaded with this entrant.409 if another enabled entrant holds the tag.GET /admin/entrantsList authoritative DB entrants.
Response: array of { id, number, name, tag, enabled, status }.
POST /admin/entrantsUpsert 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:
id).Responses:
200 { "ok": true, "count": N }400 on shape/validation issues.409 on tag conflict (another enabled entrant has the tag).500 on transactional failure.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
200 / 200.409.id or non‑int id → 400 with explicit message.409 to a clear user message that identifies the conflicting entrant when possible.412 to “Load roster first” guidance with a one-click reload action./readyz in an “About / Diagnostics” panel to speed up support.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.
pre: grid-up / warm-up. Clock stopped. Operator may jump directly to green.countdown: optional arming period. Clock stopped, UI shows an arming timer. Only pre is accepted during this phase; the scheduler promotes to green automatically when the timer expires.green: main racing phase. Clock running. All field flags are legal, and calling green again is idempotent.white: final lap window. Semantics mirror green, but UI may highlight the banner.checkered: race frozen. Clock and classification lock until the session resets or a new race loads.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:
_checkered_flag_start_ms for timeout trackingLap 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:
| 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.
POST /engine/flag){ "flag": "green" }
flag is case-insensitive on input but normalized to uppercase in /race/state snapshots.{ "flag": "green", "countdown_s": 5 } enters phase countdown and returns the projected start time in countdown_target_utc.200 { "ok": true, "flag": "GREEN", "phase": "green" }400 invalid flag token409 attempting to exit checkered without resetting the sessiongreen requests during countdown acknowledge with 200 but the scheduler still controls the actual transition.time.monotonic_ns; backend restarts cancel the countdown and drop the phase back to pre./race/state exposes phase, flag, countdown_remaining_ms, and green_at_utc while armed.green and begins scoring laps immediately./race/state at ~250 ms for two seconds after a flag change to keep the banner responsive.flag to .flag.flag--{color} classes, with .flag.is-pulsing for yellow, red, blue, and checkered.409 Conflict: only emitted when a request attempts to leave checkered.412 Precondition Failed: runtime session not loaded; load entrants first.event_type="flag" in the journal (backend/lap_logger.py). Include phase, flag, UTC timestamp, and operator ID if available.engine_flag_active{flag="GREEN"}=1 when green; engine_flag_transitions_total increments on each accepted change.pre, request green with a countdown; confirm phase=countdown, then green fires automatically.pre; verify the timer cancels and green_at_utc disappears from /race/state.green, send yellow → green; ensure standings continue updating and responses stay 200.checkered, attempt green; expect 409 with phase="checkered".pre and no stale countdown remains.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:
lap_events for the qualifying heatbrake_ok=true: Use fastest lapbrake_ok=false:
policy="use_next_valid": Use second-fastest lappolicy="demote": Use fastest but sort to backpolicy="exclude": Remove from grid entirelybrake_ok=null: Use fastest lap (no penalty)(excluded, demoted, best_ms)events.config_json under qualifying keyEvent 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:
grid[].ordergrid_index and brake_valid fields are included in /race/state standingsAuto-Clear on Delete:
When deleting frozen results via DELETE /results/{race_id}:
race_id matches any event’s qualifying.source_heat_idqualifying: null in event config"cleared_qualifying_grid": trueUI Integration:
Notes:
events.config_json)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:
Both UIs consume the same /race/state snapshot which includes:
flag - current race flag (PRE, GREEN, YELLOW, RED, BLUE, WHITE, CHECKERED)phase - lifecycle phase (pre, countdown, green, white, checkered)clock_ms - race clock in milliseconds (negative during countdown)countdown_remaining_s - seconds until auto-green (during countdown phase)standings - ordered array of entrant objects with laps, times, gapsrunning - boolean indicating if race clock is actively tickingfeatures - capability flags (e.g., pit_timing)limit - race limit configuration (type: time |
laps, value, remaining_ms) |
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”)
Standings are rendered directly from the /race/state response:
Each standing row includes:
position - 1-based finishing position (calculated by engine)entrant_id, number, name - identity fieldslaps - total laps completedlap_deficit - laps behind leaderlast, best, pace_5 - lap times in seconds (null if unavailable)gap_s - time gap to leader (0 if not on same lap)enabled, status - roster stategrid_index, brake_valid - qualifying metadataViewport: Operator UI shows ~16 rows before scrolling begins (configurable via app.ui.visible_rows)
The system provides bidirectional Open Sound Control (OSC) integration with QLC+ lighting software, enabling synchronized race flag lighting and operator-assisted flag controls.
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:
pythonosc.udp_client.SimpleUDPClient - Outbound OSC messagespythonosc.osc_server.ThreadingOSCUDPServer - Inbound OSC listenerpythonosc.dispatcher.Dispatcher - Message routingModules:
backend/osc_out.py - Sends flag/blackout commands to QLC+backend/osc_in.py - Receives flag/blackout signals from QLC+ operator buttonsbackend/server.py - Integration points and lifecycle managementPurpose: 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:
/{address}/{flag_name} → 1 (integer)/{address} → 1 (on) or 0 (off)UDP Reliability Strategy:
send_repeat)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:
GREENGREEN/race/control/flag → respective colorCHECKEREDPRE + blackoutPurpose: 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:
ThreadingOSCUDPServer runs on dedicated background threadasyncio.call_soon_threadsafe() to marshal callbacks safelyMessage Processing:
/ccrs/flag/YELLOW, extracts flag name from last segment/ccrs/blackout with integer value (1=on, 0=off)debounce_off_ms)threshold_on)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
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:
_send_flag_to_lighting(flag: str) - Send flag change to QLC+ (wrapped in try/except)_send_blackout_to_lighting(active: bool) - Send blackout state to QLC+_send_countdown_to_lighting() - Send RED flag during countdown staging_handle_flag_from_qlc(flag: str) - Process incoming flag from QLC+, includes safety guards_handle_blackout_from_qlc(active: bool) - Process incoming blackout from QLC+Integration Points:
_auto_go_green() - Countdown completion → GREEN lighting/race/control/start_race - Manual start → GREEN lighting/race/control/end_race - Race end → CHECKERED lighting/race/control/abort_reset - Abort → PRE lighting + blackout/race/setup - Setup screen → blackout/race/control/open_results - Results screen → blackout/race/control/flag - Manual flag changes → respective lightingFrontend Integration:
ui/js/race_control.js (~1324-1338): Results button calls /race/control/open_results before navigation to ensure blackout triggers before page transitionPhase-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:
_send_*_to_lighting() calls wrapped in try/exceptComplete 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:
/ccrs/flag/{COLOR} with value 1/ccrs/flag/{COLOR} for state sync/ccrs/blackout with values 0/1Latency Characteristics:
Impact on Race Timing:
Diagnostic Tools:
/diagnostics/stream SSE endpoint includes OSC eventsChronoCore 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.
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:
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:
enabled (boolean): Controls whether moxie board features appear in the UIauto_update (boolean): When true, moxie scores update in real-time as button presses are receivedtotal_points (integer): The pool of points to be distributed among entrants based on button press ratiosboard_positions (integer): How many top entrants can be displayed on the physical moxie boardThe 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.
When moxie.enabled = true:
/ui/operator/moxie_board.htmlThe moxie board page (currently in development) will provide:
total_points configurationboard_positions)The moxie score for each entrant is calculated as:
entrant_moxie_points = (entrant_button_presses / total_button_presses) * total_points
Where:
entrant_button_presses = count of button presses received by this entranttotal_button_presses = sum of all button presses across all entrantstotal_points = configured point pool (default 300)Example:
With total_points: 300 and 3 entrants:
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.
Currently Available (v0.1.1):
config.yaml/config/ui_features)Planned Features:
The system uses a single unified configuration file: config/config.yaml
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
sqlite_path is required - engine will not start without itconfig/sounds/ then assets/sounds/ (fallback)ui/ directoryThe 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:
ChronoCore provides production-ready startup scripts for different deployment scenarios:
Run-Server.ps1 (Windows browser-based deployment):
.\scripts\Run-Server.ps1 # Default: port 8000, auto-reload
.\scripts\Run-Server.ps1 -Port 8080 -NoReload # Custom port, no reload
http://localhost:8000/ui/operator/http://localhost:8000/ui/spectator/http://localhost:8000/healthzRun-Operator.ps1 (Windows desktop application):
.\scripts\Run-Operator.ps1 # Production mode
.\scripts\Run-Operator.ps1 -Debug # With DevTools enabled
operator_launcher.py for backend managementRun-Spectator.ps1 (Windows remote display):
.\scripts\Run-Spectator.ps1 # localhost:8000
.\scripts\Run-Spectator.ps1 -Server 192.168.1.100 # Remote server
.\scripts\Run-Spectator.ps1 -Server 10.0.0.5 -Port 8080 # Custom port
Run-Spectator.sh (Linux remote display):
chmod +x scripts/Run-Spectator.sh
./scripts/Run-Spectator.sh # localhost:8000
./scripts/Run-Spectator.sh 192.168.1.100 # Remote server
./scripts/Run-Spectator.sh 192.168.1.100 8080 # Custom port
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.
Sprint Simulator (scripts/Run-SimSprint.ps1):
Endurance Simulator (scripts/Run-SimEndurance.ps1):
backend/tools/load_dummy_from_xlsx.py:
Typical workflow:
python backend/tools/load_dummy_from_xlsx.py roster.xlsx
backend/tools/sim_feed.py:
/sensors/inject or direct engine callsUsage:
python backend/tools/sim_feed.py --entrants 10 --duration 300 --mean-lap 45
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
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.
Defaults live in:
config/app.yaml → app.client.engineconfig/config.yaml → (deprecated; may mirror for legacy tools)prefer_same_origin: true and UI is served by the engine)allow_client_override: true and set in localStorage cc.engine_host)mode: fixed → fixed_hostmode: localhost → 127.0.0.1:8000mode: auto → try same-origin, then fixed_host, then localhostapp:
client:
engine:
mode: localhost
allow_client_override: true
app:
client:
engine:
mode: fixed
fixed_host: "10.77.0.10:8000"
allow_client_override: false
app:
client:
engine:
mode: auto
fixed_host: "10.77.0.10:8000"
prefer_same_origin: true
allow_client_override: true
localhost.file://) must bootstrap the YAML defaults, since same-origin is not possible there.prefer_same_origin is true..\scripts\Run-Server.ps1 (Windows) - Launches server + lap logger with firewall auto-config.\scripts\Run-Operator.ps1 (Windows) - Native pywebview window with auto-start backend.\scripts\Run-Spectator.ps1 -Server <IP> (Windows) or ./scripts/Run-Spectator.sh <IP> (Linux)python -m uvicorn backend.server:app --reload --port 8000During 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.
Laps will NOT be credited if:
/race/state → flag and phase fieldsmin_lap_s)
config.yaml)min_lap_dup)
source="track" passes credit laps for Start/Finishpit_in/pit_out) use explicit roles and don’t credit lapssource field for each passACTIVE (not DNF, DQ, DISABLED)min_lap_s) credits Lap 1When laps aren’t counting but diagnostics shows passes:
GET /race/state
Verify: "flag": "green" and "running": true
Check config.yaml → app.engine.default_min_lap_s
Typical racing: 10-30 seconds
Bench testing: reduce to 2-5 seconds
reads (detection count)laps (credited laps)reads > 0 but laps = 0 indicates gating condition is activelog:
level: debug # Shows per-detection decision reasons
lap_events table is being writtenProblem identified:
_last_hit_ms)min_lap_s filterSolution implemented:
set_flag("green") is called, all _last_hit_ms timestamps are clearedCode 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
When testing timing hardware without actual racing:
app:
engine:
default_min_lap_s: 2 # Allow fast manual tag presentations
scanner:
source: mock
mock_tag: "3000999"
mock_period_s: 6.0
GET /race/state
Verify min_lap_s matches your expectations
migrate_add_car_num.py)load_dummy_from_xlsx.py)Last updated: 2025-11-13