Add business-plane schema draft and ingest Rally Albania 2025 regs
Substantial design artifact + canonical-source ingest for the TRM business plane. Schema draft (synthesis): - wiki/synthesis/directus-schema-draft.md — working agreement for the multi-tenant schema. Pseudo multi-tenant under organizations; entries as the unit of timing; course definition (stages/segments/geofences/ waypoints/SLZs); penalty system "numbers in DB, math in code" with an evaluator registry and progressive bracket math; per-entry timing tables; per-stage start-order strategies (manual / previous_stage_clean_result / inverse_top_n_then_natural / inverse_of_overall) covering both Tirana 24h and Rally Albania patterns. Two role surfaces (org role vs racing role) called out explicitly. Decisions captured; Open questions reduced to one (geometry retroactivity engine, deferred to Phase 2.5). Source ingest: - raw/Regulations_2025.pdf + wiki/sources/rally-albania-regulations- 2025.md — formal ingest of the canonical Rally Albania 2025 rulebook. Section numbers preserved as §X.Y so the schema draft and future SPA work can cite precisely. Flagged follow-ups: the SLZ formula lives in the Supplementary Regulations (don't hardcode); M-7 numbering bug; unmodeled neutralization zones. Faulty-position flag (cross-plane operator workflow): - entities/postgres-timescaledb.md, entities/processor.md, concepts/position-record.md — operator-controlled boolean on the positions hypertable; processor filters WHERE faulty = false on every read; flagging triggers windowed recompute via the recompute:requests stream. Implementation strategy on entity pages: - entities/directus.md — Schema management section documenting the snapshots/ + db-init/ convention, container-startup apply pipeline. - entities/processor.md — Phase 2 long-lived branch model with PROCESSOR_PHASE_2_ENABLED flag-gating for incremental main merges; Phase 2.5 deferral note. Index and log updated.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
title: Position Record
|
||||
type: concept
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
updated: 2026-05-01
|
||||
sources: [gps-tracking-architecture, teltonika-ingestion-architecture, teltonika-data-sending-protocols]
|
||||
tags: [data-model, boundary-contract]
|
||||
---
|
||||
@@ -60,3 +60,11 @@ For [[teltonika]]:
|
||||
## Downstream contract
|
||||
|
||||
[[processor]] is responsible for naming IO elements (e.g. `"16"` → `"odometer_km"`), unit conversions, and any filtering. It writes the typed fields to the positions hypertable and may write derived/named attributes to other tables.
|
||||
|
||||
## Wire shape vs. storage shape
|
||||
|
||||
The Position type above is the **wire shape** — what [[tcp-ingestion]] produces and what flows through [[redis-streams]]. The **storage shape** in the positions hypertable extends it with one operator-controlled field:
|
||||
|
||||
- **`faulty: boolean`** (default `false`) — set after the fact by track operators through [[directus]] when a position is unrealistic (jumpy GPS, impossible coordinate/speed). [[processor]] evaluators filter `WHERE faulty = false` on every read; flagged positions are excluded from peak-speed calculations, crossing detection, and recompute. See the operator workflow in the [[directus-schema-draft]].
|
||||
|
||||
This field exists only at rest. Ingestion and the live channel never see it; it has no meaning until a human reviews the data.
|
||||
|
||||
@@ -56,6 +56,17 @@ This means **direct database writes from [[processor]] are not visible** to Dire
|
||||
|
||||
See [[live-channel-architecture]] for the full design, including why this split is preferable to routing telemetry writes through [[directus]]'s API or running a bridging extension inside [[directus]].
|
||||
|
||||
## Schema management — snapshot/apply pipeline
|
||||
|
||||
Schema changes flow through Directus's native snapshot mechanism, kept under git. Two artifact directories:
|
||||
|
||||
- **`snapshots/schema.yaml`** — Directus collections, fields, relations. Generated locally with `directus schema snapshot`. Applied at container startup with `directus schema apply --yes`. Idempotent — applies only the diff against the running DB.
|
||||
- **`db-init/*.sql`** — schema Directus does not manage: the [[postgres-timescaledb]] positions hypertable, the `faulty` column, indexes that need PostGIS-specific syntax, or any DDL that predates Directus knowing about a collection. Numbered (`001_`, `002_`, …) and applied by a sidecar container or one-shot job ahead of `directus schema apply`. Tracked via a `migrations_applied` guard table to skip already-run files.
|
||||
|
||||
Local dev edits the schema in the admin UI, then snapshots before commit. CI builds the image with both directories baked in, spins a throwaway Postgres, and dry-runs `apply` to catch breakage before deploy. Production (Portainer) runs the same apply at container start; multi-env separation is a connection string, not different artifacts.
|
||||
|
||||
This treats `schema.yaml` as the source of truth and the admin UI as its editor. Don't hand-edit `schema.yaml`; round-trip through the UI to keep the format consistent.
|
||||
|
||||
## Phase 2 role
|
||||
|
||||
Directus owns the `commands` collection and is the **single auth surface** for outbound device commands. The SPA inserts command rows; a Directus Flow routes them via Redis to the Ingestion instance holding the device's socket. See [[phase-2-commands]].
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: PostgreSQL + TimescaleDB
|
||||
type: entity
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
updated: 2026-05-01
|
||||
sources: [gps-tracking-architecture]
|
||||
tags: [infrastructure, business-plane, database]
|
||||
---
|
||||
@@ -22,6 +22,12 @@ The durable storage layer. PostgreSQL with the TimescaleDB extension. Holds the
|
||||
|
||||
Schema is **defined and migrated through [[directus]]** — see that page for why. The Processor inserts rows respecting that schema; it does not create tables.
|
||||
|
||||
## Positions hypertable
|
||||
|
||||
Stores normalized [[position-record]] rows from [[processor]]. Beyond the wire-shape fields (device_id, timestamp, lat/lon/alt, angle, speed, satellites, priority, attributes), the hypertable carries one storage-only field:
|
||||
|
||||
- **`faulty boolean DEFAULT false`** — set by track operators via [[directus]] when a position is unrealistic (jumpy GPS, impossible speed/coordinate). The [[processor]]'s evaluators (peak-speed, crossing detection, recompute) filter `WHERE faulty = false` on every read of position data. Untouched at write time; mutated only through the operator workflow described in the schema draft.
|
||||
|
||||
## Operational note
|
||||
|
||||
The database is the **only single point of failure** in the architecture. Everything else is restartable, replaceable, or naturally redundant. Operational attention concentrates here:
|
||||
|
||||
@@ -47,6 +47,12 @@ In multi-instance deployments, each Processor reads the [[redis-streams]] stream
|
||||
|
||||
Per-model IO mappings live here, not in the Ingestion layer. Example: `{ "FMB920": { "16": "odometer_km", "240": "movement" } }`. This is the boundary set by the [[teltonika]] adapter — Ingestion produces raw IO maps; the Processor names and interprets them.
|
||||
|
||||
## Faulty position handling
|
||||
|
||||
The positions hypertable in [[postgres-timescaledb]] carries a `faulty boolean DEFAULT false` column that operators can flip through [[directus]] when a position is unrealistic. **All Processor read paths against position data filter `WHERE faulty = false`** — peak-speed evaluation inside SLZs, geofence crossing detection, waypoint pass detection, replay-based recompute. The flag is never set at write time; it's a post-hoc operator action.
|
||||
|
||||
When an operator flips the flag (set or unset), Directus emits a webhook → Redis Stream `recompute:requests`. The Processor consumes the request and re-evaluates `entry_penalties` whose evaluation window overlaps the flagged position's timestamp. Cost sits between formula recompute (cheap) and full geometry replay (expensive) — the affected window is bounded, but the inputs (peak speed, missed-waypoint count) must be re-derived from the now-filtered position stream rather than from snapshotted values.
|
||||
|
||||
## Scaling
|
||||
|
||||
Multiple Processor instances join a Redis Streams consumer group and split the load across device IDs. Consumer-group offsets ensure a crashed instance's work is picked up by the next one.
|
||||
@@ -54,3 +60,14 @@ Multiple Processor instances join a Redis Streams consumer group and split the l
|
||||
## Failure mode
|
||||
|
||||
Crash → consumer-group offsets ensure the next instance picks up where the last left off. In-memory state is rehydrated from the database. See [[failure-domains]].
|
||||
|
||||
## Development workflow — Phase 2 branch model
|
||||
|
||||
Phase 2 (geofence engine, evaluator registry, crossings/penalties/results writers) is a substantial body of work and lives on a long-lived **`phase-2`** branch rather than landing piecemeal on `main`. Conventions:
|
||||
|
||||
- **Rebase weekly** against `main`, not merge. Keeps history readable and avoids merge-commit clutter when the branch eventually lands.
|
||||
- **CI parity** — same workflow on `phase-2` PRs as on `main` PRs. Test coverage doesn't diverge across the branch boundary.
|
||||
- **Flag-gated incremental merges** — chunks that are self-contained (a single evaluator, the geofence detector) can land on `main` behind `PROCESSOR_PHASE_2_ENABLED=false`. Off in prod, on in stage. Lets the work merge before it's user-visible without keeping the entire feature on a side branch indefinitely.
|
||||
- **Single squash merge to retire the branch** — when Phase 2 is feature-complete enough to dogfood end-to-end, one squash merge retires the branch. Avoid death-by-a-thousand-merges.
|
||||
|
||||
Phase 2.5 (the geometry retroactivity engine in [[directus-schema-draft]]) follows the same pattern on its own branch when it starts; it is explicitly deferred until Phase 2 has shipped and the manual operator workflow for geometry edits has surfaced real pain points.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
title: Rally Albania 2025 — Race Rules and Regulations
|
||||
type: source
|
||||
created: 2026-05-01
|
||||
updated: 2026-05-01
|
||||
source_path: raw/Regulations_2025.pdf
|
||||
source_date: 2024-10
|
||||
source_kind: other
|
||||
sources: []
|
||||
tags: [rally, regulations, albania, federation-rules, classes, start-order, penalties]
|
||||
---
|
||||
|
||||
# Rally Albania 2025 — Race Rules and Regulations
|
||||
|
||||
> Authoritative rulebook for Rally Albania 2025 (07–14 June 2025). Issued October 2024 by Motorsport Club Albania, technical organizer; legal entity is the Albanian Motorcycle Federation. Section numbers below reference the doc directly (§X.Y) so the rest of the wiki can cite precisely.
|
||||
|
||||
## TL;DR
|
||||
|
||||
A multi-day International Cross Country Rally Raid with a prologue, multiple regular stages, and an epilogue, running across four vehicle categories (MOTO/QUAD/CAR/SSV) further split into 17 classes. Timing is RFID + GPS. Each vehicle carries **two** independent GPS trackers; no tracker = no start. Start order is **dynamic per stage** with several distinct seeding rules (§5.5–§5.10). Penalties are time-additive only, never affect next-stage seeding. The Supplementary Regulation (SR), published 60 days before the event, overrides the general regs where in conflict (§1.8–§1.9).
|
||||
|
||||
This is the canonical real-world reference for the TRM business-plane schema and the React SPA's user-facing semantics.
|
||||
|
||||
## Categories and classes (§2.2–§2.5)
|
||||
|
||||
Four categories, 17 classes total. The class catalog is the concrete fixture for [[directus-schema-draft]] `classes`.
|
||||
|
||||
| Category | Class | Description |
|
||||
|---|---|---|
|
||||
| MOTO | M-1 | Under 450cc |
|
||||
| | M-2 | 450–600cc |
|
||||
| | M-3 | over 600cc, single cylinder |
|
||||
| | M-4 | over 600cc, bi-cylinder |
|
||||
| | M-5 | Senior, under 450cc (born 1.1.1967–31.12.1975) |
|
||||
| | M-6 | Senior, over 450cc (born 1.1.1967–31.12.1975) |
|
||||
| | M-7 | Veteran (born before 1.1.1967) **and** Female driver — both reuse code M-7 in the doc; likely a numbering bug. |
|
||||
| QUAD | Q-1 | Any engine, 2WD |
|
||||
| | Q-2 | Any engine, 4WD |
|
||||
| | Q-3 | Any quad, female pilot |
|
||||
| CAR | C-1 | Offroad, heavily modified |
|
||||
| | C-2 | Offroad, lightly modified |
|
||||
| | C-A | Standard passenger automobiles, any modifications |
|
||||
| | C-3 | Offroad, all-female pilot+copilot |
|
||||
| SSV | S-1 | Sport UTV, single pilot |
|
||||
| | S-2 | Sport UTV, two-driver team |
|
||||
| | S-3 | Sport UTV, all-female team |
|
||||
|
||||
Classifications: per-class within each category, plus **three independent general standings** (§2.7–§2.8): G1 = MOTO/QUAD together (all classes), G2 = CAR all classes, G3 = SSV all classes.
|
||||
|
||||
## Race numbers (§5.1–§5.4)
|
||||
|
||||
Numbers assigned by inscription order; previous-year category winners get 1 / 200 / 300 / 400; first three of each category are reserved.
|
||||
|
||||
| Group | Range | Plate background |
|
||||
|---|---|---|
|
||||
| Moto | 1–199 | white |
|
||||
| Quad | 2xx | white |
|
||||
| Car | 3xx | white |
|
||||
| SSV | 4xx | white |
|
||||
| Assistance vehicles | 6xx | yellow |
|
||||
| Media | 7xx | green |
|
||||
| Organization | 9xx | red |
|
||||
|
||||
Plate background color is a load-bearing visual signal — relevant to the SPA's vehicle marker styling.
|
||||
|
||||
## Start order — strategies per stage (§5.5–§5.10)
|
||||
|
||||
This is the single most schema-relevant section. Start order is **dynamic** and rule-driven; the rule varies by stage role and category.
|
||||
|
||||
| Stage role | Bikes / Quads | Cars / SSV |
|
||||
|---|---|---|
|
||||
| Prologue (§5.5) | Arbitrary by org based on history | Arbitrary by org based on history |
|
||||
| Stage 1 (§5.6, §5.7) | Top 20 of prologue **inverted**; positions 21+ in prologue order | Pure prologue result order |
|
||||
| Stages 2 → last (§5.8) | Previous stage's **clean** SS time | Previous stage's clean SS time |
|
||||
| Epilogue (§5.9) | **Inverse** of overall standings after the last stage | Inverse of overall standings |
|
||||
|
||||
**Penalties never feed seeding** — §5.8 is explicit: "possible penalties will not count on the starting order but will be added on overall time." Seeding is on clean SS time; penalties roll into the overall total only.
|
||||
|
||||
**Time between starts is set per stage**, the day before each one (§5.10). It is not an event-level constant.
|
||||
|
||||
Bikes/quads start as a combined grid; cars and SSVs each have their own grid (§5.10 + §2.8). Categories seed independently.
|
||||
|
||||
## Tracking system (§7.1–§7.5)
|
||||
|
||||
- **Every vehicle gets a GPS/GPRS tracker provided by the organization** (§7.1).
|
||||
- **Two independent devices per vehicle** — Rally Albania 2025 specifically mandates a dual-device tracking system (§7.2). The TRM schema's "many devices per vehicle" m2m is operationally required, not just supported.
|
||||
- **Tracker malfunction blocks the start**: "Competitors that have malfunctions on the tracker before the start, will not be allowed to start before repairing it" (§7.3, restated in §13.15 / §13.34).
|
||||
- Trackers are used for **route reconstruction, passage controls, hidden waypoint detection, speed in SLZs, and SOS** (§7.4).
|
||||
- Trackers are **rented from a partner company, paid by the participant** (§7.5). The org provides them; the competitor doesn't.
|
||||
- Competitor self-use of tracker data is **prohibited** (§7.2) — the tracker is data-recorder + SOS, not a navigation aid for the racer.
|
||||
|
||||
## Timekeeping (§9.1–§9.20)
|
||||
|
||||
- Timing run by the "Time and Race Management" company (§9.1).
|
||||
- **RFID transponder** for time keeping (§9.2). Paper Time Card for back-up and passage controls (§9.3).
|
||||
- **Late at the start of a stage** (§9.5–§9.6): one-minute-per-minute penalty up to a cutoff = 5 minutes before the first vehicle of the next category. Past that cutoff = disqualification from that stage day.
|
||||
- **Late at the start of first SS** (§9.7): one-second-per-second penalty.
|
||||
- **Late vehicles' exact start time** is at Race Marshal discretion, "when finding an open window to start" (§9.8). This is the operational basis for `entry_segment_starts.manual_override`.
|
||||
- **CP closing time = 60 minutes after the last competitor's ideal time** (§9.19). After this, the CP signpost closes.
|
||||
- **Late past CP closing** (§9.9): cannot enter the SS, takes a penalty per formula.
|
||||
- **Liaison sections** (§9.11–§9.13): target time given; one-minute-per-minute penalty for late or early arrival; exception is arriving early at the finish after the last SS of a stage.
|
||||
- **Special sections** (§9.14): fastest possible, but within a maximum time allowed; checking in past max time → fixed daily penalty.
|
||||
- **Waypoints** are numbered and verified by the Tracking System (§9.18) — pure GPS detection, no marshal.
|
||||
- **Late or early is equally penalised** (§9.10) — symmetric in time penalties for liaisons.
|
||||
|
||||
## Penalty taxonomy (§12)
|
||||
|
||||
The doc classifies penalties as Sportive / Speed / Disciplinary / Special.
|
||||
|
||||
### Sportive
|
||||
|
||||
| Type | Trigger | Penalty |
|
||||
|---|---|---|
|
||||
| Late/early at CP-1 / CP-2 / CP-4 / CP-6 / CP-8 (§12.2) | Liaison-style time controls | 1 minute per minute |
|
||||
| Last LS no-early-penalty (§12.3) | Stage finish | exempt from early-arrival rule |
|
||||
| **Missing CP** (§12.4) | No crossing detected within stage window | Worst valid time of day in your category + **120 min** per CP missed |
|
||||
| **Late than Maximum Time Allowed** (§12.5) | Stage MTA exceeded | Worst valid + **60 min** |
|
||||
| **Arrived at CP after closing hour** (§12.6) | Crossing after `time_control_closed_at` | Worst valid + 120 min per CP missed (same formula as §12.4 but distinct trigger) |
|
||||
| **Missing WP** (§12.7) | No GPS pass-through detected | **60 min** per WP |
|
||||
| Course cut, no CP/WP missed, gain (§12.8) | Track deviation | Best time of day in that sector × 3 |
|
||||
| Transported to next bivouac by org (§12.9) | Recovery | Fixed |
|
||||
|
||||
### Speed
|
||||
|
||||
- **Overspeed in Speed Limit Zones** (§12.11): time penalty per formula. Formula itself is in the **Supplementary Regulation**, not the general regs. The Tirana 24h regs publish a five-bracket table; Rally Albania defers it to SR. The TRM penalty system is data-driven, so this fits naturally.
|
||||
|
||||
### Disciplinary
|
||||
|
||||
| Type | Penalty |
|
||||
|---|---|
|
||||
| Not present at start without notice (§12.13) | Disqualification |
|
||||
| Not able to start (§12.14) | Fixed time penalty |
|
||||
| Withdraw from race (§12.15) | Maximum Time Allowed for remaining stages |
|
||||
| **Not wearing protection in SS** (§12.16) | **60 min** per SS spotted — marshal-observed event, not GPS-derivable |
|
||||
|
||||
### Special
|
||||
|
||||
- **Force majeure / fair play stops** (§12.18): manual time adjustment by Race Direction, can be positive or negative. SR carries detail.
|
||||
|
||||
§12.16 ("not wearing protection") and §12.13 are the only penalty types in the doc that are **not** GPS-derivable — they require marshal observation. The schema must allow `entry_penalties` rows to be created manually (operator path), not only by the processor.
|
||||
|
||||
## Liaison vs Special — formal distinctions (§6.8–§6.13)
|
||||
|
||||
- **Liaison**: defined average speed, exact target time. Time penalty for both early and late arrival.
|
||||
- **Special section**: fastest possible, sum of all SS times across all stages decides the winner.
|
||||
- Stage time = sum of SS times + penalties (§6.15).
|
||||
- Rally winner = sum of all stage times + penalties (§6.17).
|
||||
- **Tiebreaker** (§6.19): best time in the **last stage** wins; if still tied, walk back stage-by-stage in reverse calendar order.
|
||||
|
||||
## Roadbook (§6.1–§6.5)
|
||||
|
||||
- Per-stage roadbook distributed at 20:00 the day before each stage (§6.2).
|
||||
- A5 format for cars; 135 mm roll for bikes/quads; PDF for those who use it (§6.3).
|
||||
- Minimum unit distance 10 m; rally computer calibrated to exactly 1 km the day before, satellite-measured (§6.4).
|
||||
- Roadbook describes every PC, TC, WP, and SLZ (§6.5).
|
||||
|
||||
## Protests and live timing (§11.1–§11.8)
|
||||
|
||||
- Protests must be lodged within **60 minutes** after results publication (§11.2).
|
||||
- Decision before the next stage start, or before final results for last-stage protests (§11.4).
|
||||
- **Live timing is NOT official and CANNOT be subject to protests** (§11.8). This is a hard policy — the SPA must surface live data with appropriate "unofficial" framing; only published stage results are protestable.
|
||||
- Fair-play time deductions (§11.5–§11.7): stop for medical help can be deducted from SS time at Race Direction discretion, claimed within 60 minutes of day's results.
|
||||
- **Official bulletin channel = WhatsApp Broadcast** (§6.20). All participants must install WhatsApp with broadcast enabled. Not a TRM responsibility, but worth knowing.
|
||||
|
||||
## Supplementary Regulations (§1.8–§1.9)
|
||||
|
||||
- SR published 60 days before event.
|
||||
- SR carries: timing details, sportive penalties (specifics including the SLZ formula), and bivouac details.
|
||||
- **SR overrides general regs where in conflict** (§1.9). Implies the TRM event model needs to allow per-event overrides of rule data — practically, this is already what `penalty_formulas` scoped per event/stage achieves, but it deserves a note in the schema draft.
|
||||
- **Extraordinary rules**: applied by the Clerk of the Course on the spot (§1.9 second). Operationally these become manual `entry_penalties` rows or marshal-driven schedule adjustments.
|
||||
|
||||
## Refueling and assistance (§8.1–§8.13)
|
||||
|
||||
- Assistance only at designated points (§8.1), provided to assistance teams pre-stage (§8.2).
|
||||
- On-track assistance vehicles start 10 min after last race vehicle, in route direction only (§8.3).
|
||||
- **In-SS refueling**: only at declared passage controls; if refueling is on track, the point becomes a **neutralization zone equal for all competitors** (§8.12). Worth flagging — neutralization implies a per-segment "stopwatch pause" that the processor would need to subtract from SS time. Probably warrants a future segment subtype or a neutralization geofence kind. Not blocking.
|
||||
- Minimum fuel autonomy 140 km, recommended 160 km (§8.13).
|
||||
|
||||
## Notable quotes (verbatim)
|
||||
|
||||
> "Second stage up to the last stage will be defined by the result of the previous stage and NOT the overall standings. (possible penalties will not count on the starting order but will be added on overall time)." — §5.8
|
||||
|
||||
> "Live Timing it is not official and can not be subject to protests." — §11.8
|
||||
|
||||
> "No vehicle can take race numbers start without Tracking System mounted." — §13.15 / §13.34
|
||||
|
||||
> "Differing from the road book can lead to the entry of prohibited and/or dangerous areas." — Closing remarks
|
||||
|
||||
## Open questions / follow-ups
|
||||
|
||||
- **§12.11 SLZ formula** lives in the SR, not in the general regs. We have the Tirana 24h SLZ formula (5 brackets ×2/×5/×15/×40/×120 sec/km/h) as a reference, and a separate organizer-supplied version for the same rally (×5/×10/×30/×90/×240 — slice-by-slice progressive). For Rally Albania 2025, the actual brackets must come from the SR — we shouldn't hardcode either Tirana variant as the default.
|
||||
- **M-7 numbering bug** — both "Veteran" and "Female driver" use code M-7 in §2.2. Probably a typo for M-8. When seeding the `classes` table for Rally Albania, treat as two distinct classes; flag for organizer confirmation.
|
||||
- **Neutralization zones** (§8.12) — not yet modeled in the schema. Would require either a `neutralization` segment subtype or a `geofence.kind = neutralization-zone` with processor-side stopwatch handling. Defer until a real event uses one.
|
||||
- **Class M-5 / M-6 senior age window** — defined as "born before 1 January 1976 and after 31 December 1966." Birthdate-derived class assignment may shift year-over-year; the `classes` table should treat eligibility rules as data, not derive them.
|
||||
- **WhatsApp Broadcast** as official bulletin (§6.20) — not in scope for TRM, but a reminder that final/official data flow happens outside the system.
|
||||
@@ -0,0 +1,487 @@
|
||||
---
|
||||
title: Directus Schema — Working Draft
|
||||
type: synthesis
|
||||
created: 2026-05-01
|
||||
updated: 2026-05-01
|
||||
sources: [rally-albania-regulations-2025]
|
||||
tags: [directus, schema, business-plane, draft, penalties, geofences]
|
||||
---
|
||||
|
||||
# Directus Schema — Working Draft
|
||||
|
||||
> Status: **working agreement**, not final. Captured during the 2026-05-01 schema discussion. Open for additions and revisions as the domain shape clarifies.
|
||||
|
||||
The TRM business-plane schema, as worked through so far. Pseudo multi-tenant under `organizations`. Two layers: an org-level catalog of durable resources (users, teams, vehicles, devices) and a per-event participation layer (entries + their crew/devices). All event-scoped state hangs off `entries`; `entries` is the unit of timing.
|
||||
|
||||
> **Reference rulebook:** [[rally-albania-regulations-2025]] is the canonical real-world reference. Section numbers cited in this doc as `Rally Albania §X.Y` map to that source. Where the schema needs a federation-specific shape (start-order rules, penalty taxonomy, class catalog), that source is the ground truth.
|
||||
|
||||
## Tenancy model
|
||||
|
||||
`organizations` is the root. Users, teams, vehicles, and devices are **all m2m with orgs** — a single device can be loaned across orgs, a privateer can race for two clubs, a team can compete under multiple federations. Events, by contrast, are scoped to a **single org** (one FK).
|
||||
|
||||
## Org-level catalog
|
||||
|
||||
Durable resources that exist independently of any particular event.
|
||||
|
||||
### `organizations`
|
||||
|
||||
Tenant root. Every business-plane row ultimately traces back here.
|
||||
|
||||
### `users`
|
||||
|
||||
Directus users, augmented with TRM-specific profile fields. M2M with orgs via `organization_users`.
|
||||
|
||||
### `organization_users` (junction)
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `organization_id` | FK |
|
||||
| `user_id` | FK |
|
||||
| `role` | enum: `owner` / `admin` / `race-director` / `marshal` / `participant` |
|
||||
|
||||
The `role` column drives Directus permission policies — a user's effective permissions in a given org come from this row.
|
||||
|
||||
### `teams`
|
||||
|
||||
Durable rosters. M2M with orgs via `organization_teams`. A team's "presence" in an event is derived from `entries.team_id` — no separate team↔event join needed.
|
||||
|
||||
### `organization_teams` (junction)
|
||||
|
||||
`organization_id`, `team_id`.
|
||||
|
||||
### `team_members` (junction)
|
||||
|
||||
`team_id`, `user_id`. Durable team roster. Who actually races for the team in a given event is the subset that has entries with that `team_id`.
|
||||
|
||||
### `vehicles`
|
||||
|
||||
M2M with orgs via `organization_vehicles`. No ownership FKs — ownership is a real-world fact that doesn't affect timing or tracking, and which user/team a vehicle "belongs to" is fluid (factory loaner, rented, privateer, swapped between teams). Anyone with org-level entry-creation permission can register any org vehicle into an entry.
|
||||
|
||||
### `organization_vehicles` (junction)
|
||||
|
||||
`organization_id`, `vehicle_id`.
|
||||
|
||||
### `devices`
|
||||
|
||||
GPS hardware (Teltonika today, future vendors possible). M2M with orgs via `organization_devices`. The `imei` is the canonical identifier the [[tcp-ingestion]] service uses; the Directus row carries vendor/model/firmware metadata and the org bindings.
|
||||
|
||||
### `organization_devices` (junction)
|
||||
|
||||
`organization_id`, `device_id`.
|
||||
|
||||
## Event-level participation
|
||||
|
||||
The per-race shape. Built around `entries` as the unit of timing.
|
||||
|
||||
### `events`
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `organization_id` | FK, single-org per event |
|
||||
| `discipline` | enum: `rally` / `regatta` / `trail-run` / `hike` / … (extensible) |
|
||||
| `name`, `starts_at`, `ends_at`, … | descriptive |
|
||||
|
||||
`discipline` drives validation: which crew roles are valid, whether vehicles are required, which device mount points the UI offers.
|
||||
|
||||
### `classes`
|
||||
|
||||
Per-event class taxonomy. `event_id` FK, plus `name`, `code` (e.g. T1, T3, SSV), `sort_order`. Each event defines its own class set, so a rally's T1/T2/T3 doesn't pollute a regatta's classes.
|
||||
|
||||
### `entries`
|
||||
|
||||
The unit of timing — what gets a bib, gets timed, gets a result.
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `event_id` | FK |
|
||||
| `vehicle_id?` | nullable — null for foot/hike events |
|
||||
| `team_id?` | nullable — null for lone racers |
|
||||
| `class_id` | FK to `classes` |
|
||||
| `bib` | per-event identifier shown to spectators |
|
||||
| `status` | enum: `registered` / `started` / `finished` / `dnf` / `dns` / `dsq` / `withdrawn` |
|
||||
|
||||
Status semantics:
|
||||
|
||||
- `registered` — entered, not yet started.
|
||||
- `started` — currently competing. Live map only renders entries with this status.
|
||||
- `finished` — completed within the rules. Eligible for ranked results.
|
||||
- `dns` (Did Not Start) — registered but never crossed the start line.
|
||||
- `dnf` (Did Not Finish) — started but failed to complete (mechanical, retired, timeout). Shows on results board but unranked.
|
||||
- `dsq` (Disqualified) — penalized off the result by officials.
|
||||
- `withdrawn` — pulled themselves out before the event started; doesn't count toward field size.
|
||||
|
||||
A vehicle can race at most one entry per event (matches reality — same vehicle can't run two stages simultaneously). For foot races, `vehicle_id` is null and the entry is identified by its single crew member.
|
||||
|
||||
### `entry_crew` (junction)
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `entry_id` | FK |
|
||||
| `user_id` | FK |
|
||||
| `role` | enum: `pilot` / `co-pilot` / `navigator` / `mechanic` / `rider` / `runner` / `hiker` (extensible per discipline) |
|
||||
|
||||
One row for solo rides, four+ for big-truck rally crews. No separate `crews` collection — `teams` already provides durable group identity, and per-entry crew is the subset that showed up.
|
||||
|
||||
#### A user has two distinct role surfaces
|
||||
|
||||
The schema carries two `role` columns that look similar but mean different things. Easy to conflate; worth being explicit.
|
||||
|
||||
| Surface | Where | Scope | What it controls |
|
||||
|---|---|---|---|
|
||||
| **Org role** | `organization_users.role` | Per-tenant, durable | What the user can see and do inside an org. Drives Directus policies (race-director, marshal, participant, …). |
|
||||
| **Racing role** | `entry_crew.role` | Per-entry, per-event | What the user does on the track in this specific entry (pilot, co-pilot, navigator, mechanic, …). |
|
||||
|
||||
The two role enums don't overlap. Same user can be `race-director` in Org A (admin power), `participant` in Org B, and show up as `pilot` in three different entries across both orgs — three independent dimensions.
|
||||
|
||||
**Crew → org chain** is indirect:
|
||||
|
||||
```
|
||||
entry_crew.entry_id → entries.id
|
||||
entries.event_id → events.id
|
||||
events.organization_id → organizations.id
|
||||
```
|
||||
|
||||
The crew row itself doesn't reference org. The schema does **not** enforce that an `entry_crew.user_id` is also in `organization_users` for the entry's host org — guest racers happen. If a federation requires strict org membership for participation, that's a per-org permission rule on `entry_crew` insert, not a schema FK.
|
||||
|
||||
### `entry_devices` (junction)
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `entry_id` | FK |
|
||||
| `device_id` | FK |
|
||||
| `assigned_user_id?` | nullable — null = vehicle-mounted (hardwired/backup), set = body-worn on that crew member |
|
||||
|
||||
A rally Toyota with three devices: two rows with null `assigned_user_id` (hardwired + backup), one row with `assigned_user_id` set to the pilot (panic button). A solo runner: one or more rows all with `assigned_user_id` set to the runner.
|
||||
|
||||
## Course definition
|
||||
|
||||
The spatial and procedural definition of an event. Three layers: stages (ordered containers), segments (typed sub-units of a stage), and geographical features (geofences, waypoints, SLZs) attached to segments.
|
||||
|
||||
### `stages`
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `event_id` | FK |
|
||||
| `name` | "Stage 1", "Day 2 Loop", etc. |
|
||||
| `sort_order` | integer |
|
||||
| `role` | enum: `prologue` / `regular` / `epilogue`. Default `regular`. Drives default seeding strategy and standings handling. |
|
||||
| `starts_at` | base time used to compute per-entry start offsets |
|
||||
| `start_interval_seconds` | gap between consecutive starts. Set per stage (Rally Albania §5.10 — decided the day before each stage). |
|
||||
| `start_order_strategy` | enum: `manual` / `previous_stage_result` / `previous_stage_clean_result` / `inverse_top_n_then_natural` / `inverse_of_overall`. See "Start order strategies" below. |
|
||||
| `start_order_strategy_params?` | JSON. Strategy-specific (e.g. `{ "n": 20, "source_stage_id": <prologue_stage_id> }` for `inverse_top_n_then_natural`). |
|
||||
| `start_order_input_stage_id?` | FK to another `stages` row when a strategy reads from a specific previous stage (prologue → stage 1, or any non-immediate predecessor). Null = use immediately preceding stage by `sort_order`. |
|
||||
|
||||
A rally can have many stages; a trail run typically has one. The stage is just an ordered container — all the actual rules and geometry hang off its segments.
|
||||
|
||||
#### Start order strategies
|
||||
|
||||
Different federations seed each stage differently. The strategy is **declarative on the stage**; the [[processor]] (or a Directus operation) materializes `entry_segment_starts` rows when a stage is opened. Real-world cases derived from [[rally-albania-regulations-2025]] §5.5–§5.10.
|
||||
|
||||
| Strategy | Behavior | Real-world example |
|
||||
|---|---|---|
|
||||
| `manual` | Org sets each entry's `start_position` directly. No computation. | Tirana 24h every leg; Rally Albania prologue. |
|
||||
| `previous_stage_result` | Order by ascending SS time of the input stage (penalty-adjusted excluded). | Rally Albania Stage 1 cars/SSV (input = prologue). |
|
||||
| `previous_stage_clean_result` | Same as above, explicitly using clean SS time only. Penalties never affect seeding. | Rally Albania stages 2 → last (input = previous stage). |
|
||||
| `inverse_top_n_then_natural` | Top *n* of input stage in **inverse** order, then *n+1*…last in natural order. | Rally Albania Stage 1 bikes/quads (params: `n=20`, source=prologue). |
|
||||
| `inverse_of_overall` | Order by descending overall standings after the previous stage. | Rally Albania epilogue. |
|
||||
|
||||
Seeding input is always **clean SS time** (`stage_results.clean_time`) — penalties roll into overall total, never the next stage's grid (Rally Albania §5.8 explicit on this).
|
||||
|
||||
Strategies seed **per category** independently. Bikes/quads share a grid; cars share another; SSVs another (Rally Albania §2.8 + §5.10). The materialization step iterates each category's entries and assigns positions within that category.
|
||||
|
||||
Late-arrival reseeding stays operator-driven (Rally Albania §9.8, Tirana 24h §8.7) — Race Marshals adjust individual `entry_segment_starts.target_at` rows on the day, not via strategy.
|
||||
|
||||
### `segments`
|
||||
|
||||
The atomic unit of rules within a stage. Each segment has a type that drives processor and validation behavior.
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `stage_id` | FK |
|
||||
| `sort_order` | integer |
|
||||
| `type` | enum: `liaison` / `special-stage` / `parc-ferme` |
|
||||
| `entry_geofence_id` | FK to `geofences` — where you arrive / get timed in |
|
||||
| `exit_geofence_id?` | nullable — only `special-stage` segments have one (timed exit). Liaisons end implicitly when the next SS-start is hit; parc fermé has no exit. |
|
||||
| `target_duration_seconds?` | nominal duration used to compute target arrival times for liaisons |
|
||||
|
||||
Concrete shape of a rally stage (per Rally Albania pattern):
|
||||
|
||||
```
|
||||
[Stage Start] → Liaison → [SS1 Start] → SS1 → [SS1 Finish] →
|
||||
Liaison → [SS2 Start] → SS2 → [SS2 Finish] →
|
||||
Liaison → [Parc fermé]
|
||||
```
|
||||
|
||||
The `[…]` markers are geofences shared between segments — the SS1-Start geofence is both the exit-trigger of the preceding liaison and the entry-trigger of SS1.
|
||||
|
||||
### `geofences`
|
||||
|
||||
Reusable spatial assets. Polygons stored as PostGIS geometry.
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `event_id` | FK |
|
||||
| `name` | display name |
|
||||
| `kind` | enum: `stage-start` / `ss-start` / `ss-finish` / `parc-ferme` / `manual-checkpoint` |
|
||||
| `geometry` | PostGIS polygon |
|
||||
| `manual_verification` | bool — true = marshal-confirmed (CP), false = pure GPS detection |
|
||||
| `retroactive` | bool, default `false` — whether geometry edits trigger recompute of past crossings |
|
||||
|
||||
"Checkpoints" are not a separate collection; they are geofences with `manual_verification = true`. In practice these usually coincide with SS-start / SS-finish geofences (matches rally conventions).
|
||||
|
||||
### `waypoints`
|
||||
|
||||
Auto-detected points along an SS track. Missing one = penalty.
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `segment_id` | FK — must reference a `special-stage` segment |
|
||||
| `location` | PostGIS point |
|
||||
| `tolerance_meters` | radius for "passed within" detection |
|
||||
| `sort_order` | integer |
|
||||
|
||||
### `speed_limit_zones`
|
||||
|
||||
Distinct from `geofences` because they carry SLZ-specific evaluation parameters. Polygons where a speed cap applies.
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `segment_id` | FK |
|
||||
| `geometry` | PostGIS polygon |
|
||||
| `max_speed_kmh` | the cap |
|
||||
| `evaluation_window_meters?` | null = whole-polygon peak; `2000` = re-evaluate every 2km of transit (per the Tirana 24h rulebook). |
|
||||
| `retroactive` | bool, default `false` — whether geometry edits trigger recompute of past traversals |
|
||||
|
||||
## Penalty system
|
||||
|
||||
Penalty rules are stored as data, evaluated in code. The collection holds the *numbers* (per-bracket multipliers, per-miss penalties); the [[processor]] holds the *math* (one evaluator function per penalty type, in a registry).
|
||||
|
||||
### `penalty_formulas`
|
||||
|
||||
The collection. Single shape covering bracketed (SLZ) and flat (CP, WP, late-start) rules; bracket-specific fields are nullable.
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `belongs_to_type` | enum: `event` / `stage` / `speed_limit_zone` / `geofence` — the scope this rule attaches to |
|
||||
| `belongs_to_id` | FK to that scope |
|
||||
| `type` | enum: `speed_limit_offence` / `waypoint_missing` / `checkpoint_missing` / `early_start` / `late_start` |
|
||||
| `input` | descriptor of the variable being measured (e.g. `peak_overspeed_kmh`, `missed_count`, `seconds_late`) |
|
||||
| `offence_min?` | bracket lower bound (km/h, seconds, etc.). Null for flat rules. |
|
||||
| `offence_max?` | bracket upper bound. Null = open-ended. |
|
||||
| `operator` | enum: `multiplication` / `addition` |
|
||||
| `penalty` | the numeric value (sec/kmh, sec per miss, etc.) |
|
||||
| `retroactive` | bool, default `true` — whether formula edits trigger recompute of past penalties |
|
||||
| `enabled` | bool, default `true` |
|
||||
|
||||
Resolution at evaluation time: most-specific scope wins. The [[processor]] queries "what's the SLZ rule for this zone?" first against `speed_limit_zone`, falls back to `stage`, falls back to `event`.
|
||||
|
||||
### Bracketed rules: progressive slice-by-slice
|
||||
|
||||
For `speed_limit_offence`, multiple rows with the same `(belongs_to_type, belongs_to_id, type)` form a bracket table. Each bracket contributes only to the portion of the input within its range — same math as progressive income tax. For peak overspeed `P`:
|
||||
|
||||
```
|
||||
penalty = 0
|
||||
for each row in brackets ordered by offence_min:
|
||||
if P < row.offence_min: continue
|
||||
upper = min(P, row.offence_max ?? P)
|
||||
slice = upper - row.offence_min + 1
|
||||
penalty += slice * row.penalty
|
||||
```
|
||||
|
||||
Worked example (Tirana 24h table, peak `P = 58` km/h overspeed):
|
||||
|
||||
| offence_min | offence_max | sec/kmh | slice | contribution |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 10 | 5 | 10 | 50 |
|
||||
| 11 | 20 | 10 | 10 | 100 |
|
||||
| 21 | 30 | 30 | 10 | 300 |
|
||||
| 31 | 40 | 90 | 10 | 900 |
|
||||
| 41 | null | 240 | 18 | 4320 |
|
||||
| | | | **Total** | **5670** |
|
||||
|
||||
### Flat rules: one row, one number
|
||||
|
||||
For `waypoint_missing` and `checkpoint_missing`, a single row carries the per-miss value:
|
||||
|
||||
```jsonc
|
||||
// missed waypoint: 60 min × count
|
||||
{ "belongs_to_type": "event", "belongs_to_id": "<event-id>",
|
||||
"type": "waypoint_missing", "penalty": 3600, "enabled": true }
|
||||
|
||||
// missed checkpoint: 120 min × count + worst_valid_time_in_category (added by evaluator)
|
||||
{ "belongs_to_type": "event", "belongs_to_id": "<event-id>",
|
||||
"type": "checkpoint_missing", "penalty": 7200, "enabled": true }
|
||||
```
|
||||
|
||||
The "worst valid time in your category" addition for `checkpoint_missing` is **not** stored as a number. It's a runtime aggregate the [[processor]] computes from `entry_results` at stage close, and the evaluator function for that type adds it as part of its semantics. If a federation publishes a variant without that addition, that's a new `type` (`checkpoint_missing_flat`) with its own evaluator — not a flag on the row.
|
||||
|
||||
### Code side: the evaluator registry
|
||||
|
||||
The [[processor]] holds a registry mapping `type` → evaluator function. The evaluators contain no constants — all numbers come from `penalty_formulas` rows.
|
||||
|
||||
```ts
|
||||
const evaluators = {
|
||||
'speed_limit_offence': (peak, rules) => walkBrackets(peak, rules),
|
||||
'waypoint_missing': (count, [rule]) => count * rule.penalty,
|
||||
'checkpoint_missing': (count, [rule], ctx) => count * rule.penalty + ctx.worstValidTimeInCategory,
|
||||
'late_start': (secondsLate, rules) => walkBrackets(secondsLate, rules),
|
||||
// …
|
||||
};
|
||||
```
|
||||
|
||||
**The split:** numbers live in the database (rows in `penalty_formulas`); math shape lives in code (one function per `type`); runtime aggregates (peak speed, missed count, worst time) are computed by the [[processor]] and passed to the evaluator. Adding a new event with a different bracket table = pure data change, no deploy. Adding a fundamentally new kind of math = small code change (one new evaluator, one registry entry).
|
||||
|
||||
### Loading and reload
|
||||
|
||||
The [[processor]] queries enabled formulas in scope when an event becomes active or when it starts up:
|
||||
|
||||
```sql
|
||||
SELECT * FROM penalty_formulas
|
||||
WHERE belongs_to_id IN (
|
||||
$event_id,
|
||||
SELECT id FROM stages WHERE event_id = $event_id,
|
||||
SELECT id FROM geofences WHERE event_id = $event_id,
|
||||
SELECT id FROM speed_limit_zones WHERE event_id = $event_id
|
||||
)
|
||||
AND enabled = true;
|
||||
```
|
||||
|
||||
Indexes them in memory by `(belongs_to_type, belongs_to_id, type)` for O(1) lookup at violation time. Reload triggers: Directus webhook on formula edit (preferred), periodic refresh as fallback, manual "reload formulas" admin action.
|
||||
|
||||
### Retroactive recompute
|
||||
|
||||
`retroactive` lives on `penalty_formulas` (default `true` — math fixes usually apply across the field) and on `geofences` / `speed_limit_zones` (default `false` — physical crossings stand on their own merit). Per-edit override available so an organizer can flip the default for a specific change.
|
||||
|
||||
When a row is updated and `retroactive = true`:
|
||||
|
||||
1. A Directus Flow / hook enqueues a recompute job (Redis Stream `recompute:requests`).
|
||||
2. The [[processor]] consumes the job, walks affected `entry_penalties`, recomputes each from snapshotted inputs against the new formula, updates the rows in place, logs `recomputed_at` / `recomputed_reason`.
|
||||
3. Old values are preserved in Directus revisions for audit.
|
||||
|
||||
Two recompute kinds:
|
||||
|
||||
- **Formula change** — cheap. `entry_penalties.inputs` is snapshotted, so it's pure arithmetic. Seconds for thousands of rows.
|
||||
- **Geometry change** — expensive. Crossing decisions themselves change; have to re-detect from raw positions in [[postgres-timescaledb]]. Stage-bounded. Defer to Phase 2.5 of the [[processor]].
|
||||
|
||||
## Per-entry timing and results
|
||||
|
||||
Written by the [[processor]] as cars drive (Phase 2 work — not part of Phase 1 of [[processor]]).
|
||||
|
||||
### `entry_segment_starts`
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `entry_id` | FK |
|
||||
| `segment_id` | FK |
|
||||
| `start_position` | integer — seeded position within the entry's category for this stage. Output of the strategy. |
|
||||
| `target_at` | `stage.starts_at + (start_position - 1) × stage.start_interval_seconds`, plus nominal liaison durations for sub-segment starts. Operator-overridable. |
|
||||
| `manual_override` | bool, default `false`. Set to `true` when a Race Marshal hand-edits `target_at` (late arrival, etc.) — flags the row as not derivable from the strategy. |
|
||||
|
||||
Materialized by the [[processor]] (or a Directus flow) at stage-open time, scoped per category. Until then the rows do not exist.
|
||||
|
||||
### `entry_crossings`
|
||||
|
||||
The raw timeline of detected/recorded crossings. Append-only, written by the [[processor]] as positions stream in (or by marshals via Directus for manual checkpoints).
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `entry_id` | FK |
|
||||
| `geofence_id?` | one of `geofence_id` / `waypoint_id` set per row |
|
||||
| `waypoint_id?` | |
|
||||
| `crossed_at` | timestamp |
|
||||
| `source` | enum: `gps` / `manual` |
|
||||
|
||||
### `entry_penalties`
|
||||
|
||||
Computed from `entry_crossings` + `penalty_formulas` by the [[processor]].
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `entry_id` | FK |
|
||||
| `segment_id?` | FK, when applicable |
|
||||
| `rule_id` | FK to `penalty_formulas` (or to a "rule set" if grouped) |
|
||||
| `seconds` | computed penalty |
|
||||
| `reason` | free text + structured payload |
|
||||
| `formula_snapshot` | jsonb — the rule rows used at evaluation time, so the row is self-explaining if formulas later change |
|
||||
| `inputs` | jsonb — peak overspeed, missed count, etc. (whatever the evaluator consumed) |
|
||||
| `evaluated_at` | timestamp |
|
||||
| `recomputed_at?` | set if the row has been retroactively recalculated |
|
||||
| `recomputed_reason?` | what triggered the recompute |
|
||||
|
||||
The `formula_snapshot` + `inputs` fields make recompute a pure data transformation — no need to re-derive anything from raw GPS.
|
||||
|
||||
### `stage_results`
|
||||
|
||||
Materialized leaderboard per stage. Recomputed when the stage closes or on protest.
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `entry_id` | FK |
|
||||
| `stage_id` | FK |
|
||||
| `raw_time` | seconds — net time from start to finish |
|
||||
| `penalty_seconds` | sum of `entry_penalties` for this stage |
|
||||
| `final_time` | `raw_time + penalty_seconds` |
|
||||
| `position` | rank within class |
|
||||
|
||||
## Position flagging (cross-plane operator workflow)
|
||||
|
||||
Devices emit faulty data — jumpy GPS, impossible coordinates, unrealistic speeds. Track operators need to exclude such points from calculations after the fact, without deleting them (the raw record stays available for audit).
|
||||
|
||||
### Mechanism
|
||||
|
||||
The positions hypertable in [[postgres-timescaledb]] carries a `faulty boolean DEFAULT false` column. The hypertable is **exposed as a Directus collection** (read + update) so operators can flip the flag through the admin UI like any other row. Position records are owned by the [[processor]] (write side, telemetry plane), but the `faulty` flag is exclusively a business-plane operator concern.
|
||||
|
||||
Permission scope: operators with role `race-director` (or a more specific `track-operator` role added to the `organization_users.role` enum if granularity is needed) get update access to `positions.faulty`, scoped via the same dynamic-filter Policy pattern — only positions whose `device_id` belongs to a device entered in an event of the operator's org.
|
||||
|
||||
### Effect on the processor
|
||||
|
||||
All [[processor]] read paths against positions filter `WHERE faulty = false`:
|
||||
|
||||
- Peak-speed evaluation inside SLZs.
|
||||
- Geofence/waypoint crossing detection.
|
||||
- Replay-based recompute.
|
||||
|
||||
Flagged positions are still in the hypertable, still in the live broadcast (the live channel doesn't filter — operators flag historically, after the fact). They simply no longer contribute to penalties and results.
|
||||
|
||||
### Recompute on flag change
|
||||
|
||||
When `faulty` flips on a position whose timestamp is within an already-evaluated window:
|
||||
|
||||
1. Directus webhook fires on `positions` update where `faulty` changed.
|
||||
2. Webhook enqueues a recompute request on `recompute:requests` (Redis Stream).
|
||||
3. The [[processor]] identifies `entry_penalties` whose evaluation window overlaps the position's timestamp and re-evaluates them.
|
||||
4. Cost is mid-band: cheaper than full geometry replay (the window is bounded), but the inputs (peak speed, crossing detection) must be re-derived from raw positions — the snapshotted `entry_penalties.inputs` no longer reflect reality once a position they were derived from is excluded.
|
||||
|
||||
This is a third recompute kind alongside formula recompute (cheap, snapshot arithmetic) and geometry recompute (expensive, full replay).
|
||||
|
||||
## Decisions made
|
||||
|
||||
- **Vehicle is the racing unit, not the user.** In rally the vehicle gets the time; crew is sub-relation.
|
||||
- **Vehicle ownership is not modeled.** Vehicles belong to orgs (m2m); who "owns" the vehicle in the real world doesn't affect timing or tracking, so it's left out of the schema.
|
||||
- **Class belongs to the entry, not the vehicle.** A vehicle could race different classes in different events; class is a per-event property.
|
||||
- **Teams are org-level, durable.** A team's per-event roster is derived from `entries`, not stored separately. No `crews` collection.
|
||||
- **Classes are per-event.** `classes.event_id` FK; entries reference `class_id`.
|
||||
- **Discipline drives validation.** `events.discipline` decides whether vehicle is required and which crew roles/device mount points are valid.
|
||||
- **Org-user role lives on the junction.** `organization_users.role` is the per-tenant role driving Directus policies.
|
||||
- **Permission policies via dynamic filters.** Directus 11 Policies, one per logical role (race-director, marshal, participant, …). Each policy's filter reaches through the row → org → `organization_users` to confirm the current user has that role in *that* org. A user gets all applicable policies attached at once. No org-switcher UI needed.
|
||||
- **Stage = container, segment = atomic.** Stages just order their segments. Each segment has a type (`liaison` / `special-stage` / `parc-ferme`) that drives processor and validation behavior.
|
||||
- **Checkpoints are typed geofences, not their own collection.** A "checkpoint" is a geofence with `manual_verification = true` (and usually a `kind` of `ss-start` / `ss-finish`).
|
||||
- **Penalty rules are data, math is code.** The `penalty_formulas` collection holds all numeric values (bracket multipliers, per-miss penalties). The [[processor]] holds one evaluator function per penalty `type`. Adding new events / new bracket tables = data only.
|
||||
- **Speed limit penalties are progressive (slice-by-slice).** Each bracket contributes only to the portion of the input within its range — same as income tax. `peak_overspeed × per-bracket-rate` summed across brackets the peak crossed.
|
||||
- **`retroactive` defaults differ by what's edited.** Formulas default `true` (math fixes apply across the field). Geometry defaults `false` (physical crossings stand on their own). Per-edit override at save time.
|
||||
- **`entry_penalties` snapshot inputs and rule rows.** Recompute is pure arithmetic from snapshots, not re-derivation from raw GPS — the rare exception being geometry retroactive changes.
|
||||
- **Positions carry a `faulty` flag.** Operator-controlled, default `false`, set after the fact through Directus when a GPS reading is unrealistic. [[processor]] filters `WHERE faulty = false` on every read; flagging a position triggers a windowed recompute of affected penalties. The hypertable is exposed as a Directus collection for this workflow.
|
||||
- **Start order is per-stage, declarative, strategy-driven.** Stages carry `start_order_strategy` + params, materialized into `entry_segment_starts` at stage-open time, scoped per category. Penalties never feed the next stage's grid; only clean SS time does. Operator overrides via `manual_override` for late arrivals.
|
||||
- **Stages have a `role`.** `prologue` / `regular` / `epilogue`. Drives default strategy choice and lets the standings logic exclude the prologue from overall-time totals when a federation requires that. Most rallies = one prologue + N regulars; some end with an epilogue seeded by inverse-of-overall.
|
||||
- **CP missing vs CP-late-past-closing are distinct event types** with the same penalty formula. Rally Albania §12.4 (missing) and §12.6 (arrived after closing hour) both pay "worst valid + 120 min" but trigger on different processor signals — one from "no crossing detected within stage window," one from "crossing detected but after `time_control_closed_at`." Both surface as `entry_penalties` rows with distinct `type`s sharing a formula row.
|
||||
- **Tiebreaker is reverse-stage order.** Rally Albania §6.19: same total → best last-stage time wins; if still tied, walk back stage-by-stage. Pure SQL on `stage_results`; no schema impact.
|
||||
- **Crews are per-entry, not reusable.** No `crews` collection. Each entry builds its `entry_crew` rows fresh; the SPA solves "register the usual Toyota crew" via a "copy crew from previous entry" action that clones rows. Saved crews would go stale fast (people swap roles, drop out); copy-from-previous covers the real case without that risk.
|
||||
- **Non-bracket federation rules ship as code (Option A), one PR per rule shape.** When a federation publishes math that doesn't fit the bracket / flat-per-miss model (curves, multi-input aggregates, conditional rules), the response is to add a new evaluator function to the registry plus the fields it needs on `penalty_formulas`. Explicitly **not** building an in-database expression engine (Option B). Reason: expression engines bring sandboxing, validation, authoring UX, testing, versioning, and determinism obligations that dwarf the per-rule PR cost. PR-per-rule keeps the math reviewable, deterministic, testable, and versioned through normal git history. Stability over flexibility.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Geometry retroactivity engine** — the position-replay path for geofence/SLZ geometry edits is non-trivial. Defer to Phase 2.5 of [[processor]] once Phase 2 is shipping.
|
||||
|
||||
## Relationship to existing wiki
|
||||
|
||||
This schema lives in [[directus]] (the business plane). The [[processor]] writes telemetry data (positions hypertable) keyed by `device_id`; the join from telemetry back to "who was racing" is `device_id` → `entry_devices` → `entry` → everything else. See [[plane-separation]] for why the telemetry plane stays ignorant of this schema.
|
||||
Reference in New Issue
Block a user