diff --git a/CLAUDE.md b/CLAUDE.md index 6e23980..1b0cc12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,12 +8,15 @@ Beam402 is an open source drag racing timing system: beam sensors, Christmas tree, ET / 60ft / trap speed measurement, and race control software, built from industrial off-the-shelf parts. -**The repository currently contains no code.** It is design documentation at -the pre-validation stage: architecture, a decision log, a software design, and a -prototype BOM. There is nothing to build, lint, or test — do not invent build -commands or claim that any subsystem works. +**No hardware exists.** Nothing here has run against a beam, a node or a tree, +and no number any of it prints was measured — every one came from a scenario +file that stated it. Do not claim a subsystem works because its tests pass. -The stack is decided but unwritten: **C on ESP-IDF** for node and tree firmware +Race control, however, **is written and does run**: `software/` is a Rust +workspace — `cargo test` in it, `cargo run -p beam402 -- ` for the +CLI, `cargo clippy --all-targets`. Firmware is still unwritten. + +The firmware stack is decided but unwritten: **C on ESP-IDF** for node and tree (`D22`, status *revisit* — chosen so the gating `T3` measurement carries one fewer unknown, **not** because Rust cannot do it: the `esp32s3` PAC exposes the capture and sync registers, and a Rust node becomes admissible the moment it @@ -21,17 +24,19 @@ reproduces the T3 number on the same rig), **Rust** for race control as a single binary that also serves the scoreboard (`D23`), **Python** for bench tooling only, and KiCad for hardware. `.gitignore` reserves space accordingly. -Until bench validation passes, **the design documents *are* the project** — so -edits to them are the substantive work, not paperwork around it. +Until bench validation passes, the design documents carry as much of the project +as the code does — so edits to them are substantive work, not paperwork around +it. | File | Role | |---|---| | `docs/architecture.md` | Full system design, §11 = ranked list of unverified assumptions, §12 = deployment stages | -| `docs/decisions.md` | ADRs `D01`–`D36`: context → decision → why → what would change it | +| `docs/decisions.md` | ADRs `D01`–`D37`: context → decision → why → what would change it | | `docs/bench-validation.md` | The current stage: rig construction, tests `T1`–`T5`, pass/fail criteria | | `docs/software.md` | Software architecture: program boundaries, poll strategy, build order, §8 = software-side open questions | | `docs/protocol.md` | Modbus register map and mapping file format — the contract between firmware and race control | | `hardware/BOM.md` | v0 prototype BOM (bench + parking-lot demo), organized by supplier basket | +| `software/` | The Rust workspace: protocol, mapping, simulator, poller, race logic, the event layer, the HTTP server and the `beam402` CLI | | `events/` | An entry sheet, a season skeleton and a registration CSV — the format a club fills in (**D34**) | | `deploy/` | Reference way to run a results receiver: reverse proxy for TLS, unit file, loopback binding (**D33**) | | `README.md` / `README.ru.md` | English canonical, Russian overview | diff --git a/README.md b/README.md index 2e757b1..1721c5a 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,20 @@ already a complete working system). competition has something to count and a mirror can say why somebody lost. An official calls the fouls the beams cannot see, voids a pass and takes a car out of a class from the panel, in their own rulebook's words +- [x] A day in qualifying has a **board**, derived by the draw's own arithmetic + so it cannot disagree with the ladder people end up in: where each car + stands, its passes, where the cut falls, and no provisional place for one + that has not run. A practice day used to produce no output at all +- [x] A finished class publishes **every round it ran**, not the last one, so a + results page can show how somebody reached a final — plus who missed the + cut and who withdrew, which a facade with a field and a count cannot work + out for itself +- [x] A meeting over several days off **one** rulebook — `beam402 sheet … --date + 2026-08-07 --id practice-day`, because hand-edited copies of a class list + drift and then the class runs two ways in one weekend +- [x] The record button waits for the **numbers**, not the finish beam: a car + is over the line about a second before its ET is off the node (**D25**), + and a press inside that beat wrote the pass down with no time in it - [ ] Tree-hosted deployment (**D31**): a tree, two nodes and a phone — arm and read every run with no computer at the track - [x] A reference receiver actually deployed, so the chain runs end to end — diff --git a/README.ru.md b/README.ru.md index ed2f7ef..3df7a58 100644 --- a/README.ru.md +++ b/README.ru.md @@ -130,6 +130,20 @@ Beam402 — ответ на это: открытая, воспроизводим круги, есть что считать, а зеркалу есть что сказать о причине поражения. Фолы, которых лучи не видят, аннулирование проезда и снятие из класса судья ставит с панели — словами своего регламента +- [x] У дня в квалификации есть **таблица**, и считается она той же + арифметикой, что и жеребьёвка, — поэтому не может разойтись с сеткой, в + которую машины попадут: место каждой, число попыток, где проходит отсечка. + У машины, которая ещё не проехала, места нет. Раньше тренировочный день не + давал вообще никакого вывода +- [x] Закончившийся класс публикует **все свои раунды**, а не последний, — чтобы + страница результатов показывала, как человек дошёл до финала; плюс кто не + прошёл отсечку и кто снялся, а это фасад по полю и счётчику не выведет +- [x] Этап на несколько дней с **одного** регламента — `beam402 sheet … --date + 2026-08-07 --id practice-day`: копии списка классов, поправленные руками, + расходятся, и тогда класс едет в один уикенд по двум правилам +- [x] Кнопка записи ждёт **цифры**, а не створ финиша: машина за створом + примерно на секунду раньше, чем её ET уходит с узла (**D25**), и нажатие + внутри этой секунды записывало проезд вообще без времени - [ ] Конфигурация с ёлкой-мастером (**D31**): ёлка, два узла и телефон — запуск и результат каждого заезда без компьютера на трассе - [x] Референсный получатель, реально задеплоенный, чтобы цепочка шла целиком — diff --git a/events/season.toml b/events/season.toml index 4e36dbe..1b56c97 100644 --- a/events/season.toml +++ b/events/season.toml @@ -6,8 +6,14 @@ # # beam402 sheet entries.csv --event events/season.toml -o today.toml # -# Change the id and the date for each event, or generate them from whatever the -# club already uses to schedule its calendar. +# The [event] block below is the one thing here that is not a rule, so a meeting +# running over several days names each day on the command line rather than in a +# copy of this file — copies of a rulebook drift: +# +# beam402 sheet entries.csv --event events/season.toml \ +# --id club-2026-08-07 --name Practice --date 2026-08-07 -o friday.toml +# +# What is left here is the fallback for a club running one day at a time. [event] id = "club-day" diff --git a/software/crates/cli/src/live.rs b/software/crates/cli/src/live.rs index 477c047..f2ba3f6 100644 --- a/software/crates/cli/src/live.rs +++ b/software/crates/cli/src/live.rs @@ -35,7 +35,7 @@ use beam402_mapping::Mapping; use beam402_poller::{Phase as BusPhase, Poller}; use beam402_protocol::Lane; use beam402_race::staging::{Action, Config, Phase, Staging}; -use beam402_race::{decide, Outcome, Pairing, RunBuilder}; +use beam402_race::{decide, Outcome, Pairing, Round, RunBuilder}; use crate::meeting::Meeting; use crate::round::{self, STEP_MS}; @@ -210,9 +210,52 @@ pub struct Runtime<'m, B> { meeting: Option, armed: bool, cycles: u64, + /// How long the round has been [`Phase::Complete`], in loop time. + /// + /// The staging machine calls a round complete when every car has been *seen* + /// to cross the finish beam, which is the right statement about the strip and + /// not yet a statement about the numbers: the ETs are latched in the nodes and + /// arrive on a later poll (**D25**). This is how long they have had to. + complete_ms: u64, + /// A car never reached the finish beam, so this round's numbers are not late — + /// they do not exist. + abandoned: bool, note: String, } +/// How long a finished round is given to produce its numbers before "not yet" +/// becomes "never". +/// +/// Recording inside that window wrote a result with no time in it — a driver's +/// real pass logged as `-`, which seeds them at the back of a class they were +/// leading. Past it, a lane that was seen at the finish and still has nothing is a +/// record that is not coming, and refusing forever would strand the day. +/// +/// Wide enough for several poll cycles at 19,200 bps with a node down, short +/// enough that nobody standing at the panel thinks the button is broken. +const SETTLE_MS: u64 = 3_000; + +/// Whether a finished round is **whole**. +/// +/// Whole means every lane that raced has its ET, or there is no longer any reason +/// to expect one: the round was abandoned, or the records have had [`SETTLE_MS`] +/// to arrive and did not. That last case is a node that stopped answering, and the +/// honest record for it is the one with `-` in it — written deliberately, rather +/// than by beating the poll loop to a button. +/// +/// Only ever asked about a round the staging machine already calls complete. A +/// pairing sitting on the line has no times either, and that is not the same +/// question. +fn settled(round: &Round, pairing: &Pairing, abandoned: bool, complete_ms: u64) -> bool { + if abandoned || complete_ms >= SETTLE_MS { + return true; + } + pairing + .entries() + .iter() + .all(|e| round.lane(e.lane).is_some_and(|r| r.et_s.is_some())) +} + impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { pub fn new( bus: B, @@ -236,6 +279,8 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { meeting: None, armed: false, cycles: 0, + complete_ms: 0, + abandoned: false, note: String::new(), } } @@ -305,11 +350,22 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { } } Action::Abandon => { + // Nothing more is coming for a car that never reached the + // finish beam, so the round is as whole as it will get. + self.abandoned = true; self.note = "abandoned: a car never reached the finish beam".into() } } } + // How long the numbers have had to come back. Measured about this round + // rather than the day, so anything that is not a finished round clears it. + self.complete_ms = if self.staging.phase() == Phase::Complete { + self.complete_ms + STEP_MS + } else { + 0 + }; + for intent in intents { match intent { Intent::Arm => self.do_arm(), @@ -363,6 +419,7 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { self.staging.armed(handicap); self.poller.set_phase(BusPhase::Quiet); self.armed = true; + self.abandoned = false; self.note.clear(); } Err(e) => self.note = e, @@ -391,6 +448,16 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { self.note = "the round is not over".into(); return; } + // **Seen at the finish beam is not the same as measured.** The staging + // machine goes `Complete` on the beam edge; the ET is latched in the node + // and arrives on a later poll (**D25**). Recording in between wrote the + // pass down with no time in it — a real 11.85 logged as `-`, which seeds + // that driver at the back of a class they were leading, silently. So the + // gate is the round being whole rather than the car being over the line. + if !settled(&round, &pairing, self.abandoned, self.complete_ms) { + self.note = "the finish records have not come back yet".into(); + return; + } self.note = match meeting.record(&round, &pairing) { Ok(line) => format!("recorded: {line}"), Err(why) => why, @@ -553,9 +620,21 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { self.note = "this round has a result that has not been recorded".into(); return; } + // Nor by the button that brings up the next pair while the numbers are + // still on their way. Clearing the round here does not stop the records + // arriving — they are latched in the nodes and a poll is already asking + // (**D25**) — so what used to happen is that the previous car's ET landed + // in the next pair's round and showed on the panel before it had staged. + if self.staging.phase() == Phase::Complete + && !settled(&round, &self.pairing, self.abandoned, self.complete_ms) + { + self.note = "the finish records have not come back yet".into(); + return; + } self.builder.clear_round(); self.staging.reset(); + self.abandoned = false; self.poller.set_phase(BusPhase::Live); self.poller.release_tree(self.tree); // Deliberately **no** refetch. The nodes still hold the last round's @@ -644,12 +723,16 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { }; format!( - "{{\"phase\":\"{phase}\",\"ready\":{},\"armed\":{},\"held\":{},\"holder\":{},\ + "{{\"phase\":\"{phase}\",\"ready\":{},\"armed\":{},\"settled\":{},\ +\"held\":{},\"holder\":{},\ \"cycles\":{},\"bus_ms\":{:.0},\"note\":\"{}\",\"winner\":\"{verdict}\",\ \"board\":{{\"w\":{},\"h\":{},\"bits\":\"{}\"}},\"event\":{event},\ \"lanes\":[{lanes}],\"nodes\":[{nodes}],\"slip\":\"{}\"}}", self.staging.is_ready(), self.armed, + // Whether the round is whole, so the panel can stop offering `record` + // in the window where it would write a pass down with no time in it. + settled(&round, &self.pairing, self.abandoned, self.complete_ms), holder.is_some(), match holder { Some(t) => t.to_string(), @@ -696,6 +779,79 @@ pub fn pace() { mod tests { use super::*; + /// The defect a practice day found. The staging machine calls a round complete + /// on the **finish beam**; the ET is latched in the node and comes back on a + /// later poll (**D25**) — 1.3 s later on the reference venue. The panel offered + /// `record` for that whole beat, and a press inside it wrote + /// `Q Unlimited 17 - - -`: a real 11.85 logged as no time at all, which seeds + /// that driver at the back of a class they were leading. Silently, because a + /// pass with no time is a legitimate record — it is what a car that stops on + /// the track gets. + #[test] + fn a_finished_round_is_not_whole_until_its_numbers_arrive() { + use beam402_race::{Entry, Format, LaneRun}; + + let one_lane = Pairing::new( + Format::HeadsUp, + vec![Entry { + lane: Lane::L1, + dial_s: None, + }], + ) + .unwrap(); + let timed = |et: f64| { + let mut r = Round::default(); + r.set_lane( + Lane::L1, + LaneRun { + reaction_s: Some(0.412), + et_s: Some(et), + ..LaneRun::default() + }, + ); + r + }; + // The window: over the line, nothing off the node yet. + let mut crossed = Round::default(); + crossed.set_lane( + Lane::L1, + LaneRun { + reaction_s: Some(0.412), + ..LaneRun::default() + }, + ); + assert!(!settled(&crossed, &one_lane, false, 0)); + assert!(!settled(&crossed, &one_lane, false, SETTLE_MS - STEP_MS)); + + // The number arrives and the round is whole. + assert!(settled(&timed(11.85), &one_lane, false, 0)); + + // Two ways it is whole without one. A car that never reached the finish + // beam has no number coming... + assert!(settled(&crossed, &one_lane, true, 0)); + // ...and neither has a node that stopped answering, which is the case that + // must not strand the day: past the grace, `-` is the honest record and it + // is written deliberately. + assert!(settled(&crossed, &one_lane, false, SETTLE_MS)); + + // Both lanes, because a pair is only as recordable as its slower node. + let two = Pairing::new( + Format::HeadsUp, + vec![ + Entry { + lane: Lane::L1, + dial_s: None, + }, + Entry { + lane: Lane::L2, + dial_s: None, + }, + ], + ) + .unwrap(); + assert!(!settled(&timed(11.85), &two, false, 0), "lane 2 has nothing"); + } + #[test] fn one_client_holds_control_and_the_others_are_told_so() { // D30's rule, and the failure it prevents: two people arming is worse diff --git a/software/crates/cli/src/main.rs b/software/crates/cli/src/main.rs index f3cfb1d..e36b473 100644 --- a/software/crates/cli/src/main.rs +++ b/software/crates/cli/src/main.rs @@ -44,7 +44,7 @@ USAGE: beam402 ladder --format pro|sportsman beam402 serve [OPTIONS] [-o 0.0.0.0:8402] beam402 event [--log ] [--draw ] - beam402 sheet --event [-o sheet.toml] + beam402 sheet --event [--date ] [-o sheet.toml] beam402 push --log --to [--token ] beam402 host [-o 0.0.0.0:8403] @@ -53,6 +53,10 @@ OPTIONS: --event run a meeting: classes, ladders and pairs off an entry sheet --log the meeting's result log; required by --event --draw close qualifying and draw that class's ladder + --id this day's id, name, date and league key, overriding what + --name the season skeleton carries — a meeting that runs over + --date three days is three days off one rulebook rather than + --ref three hand-edited copies of it --to where to push a day (D33); also serve's live push target --token write authority for that event; BEAM402_TOKEN is preferred --format heads-up | bracket | index (default: heads-up) @@ -126,11 +130,22 @@ fn main() -> ExitCode { } } +/// Which day of a meeting the desk is importing, where the season skeleton says +/// something else. Empty is the common case: one rulebook, one Saturday. +#[derive(Default)] +struct DayArgs { + id: Option, + name: Option, + date: Option, + external: Option, +} + struct Args { command: Command, path: String, mapping: Option, event: Option, + day: DayArgs, log: Option, draw: Option, to: Option, @@ -195,7 +210,14 @@ fn desk(args: &Args) -> Result { "--event is required: the classes are a rulebook the club keeps, \ not a column of a spreadsheet somebody retypes every Saturday", )?; - let out = desk::import(&read(skeleton)?, &text).map_err(|e| format!("{}: {e}", args.path))?; + let day = desk::Day { + id: args.day.id.as_deref(), + name: args.day.name.as_deref(), + date: args.day.date.as_deref(), + external: args.day.external.as_deref(), + }; + let out = desk::import_as(&read(skeleton)?, &text, &day) + .map_err(|e| format!("{}: {e}", args.path))?; let sheet = Sheet::parse(&out).expect("import validates before it returns"); match &args.out { @@ -253,6 +275,15 @@ fn push_day(args: &Args) -> Result { Ok(out) } +/// A number that may be absent, as JSON. Never a zero standing in for one nobody +/// measured — the same rule the time slip keeps. +fn num(v: Option) -> String { + match v { + Some(v) => format!("{v:.4}"), + None => "null".into(), + } +} + /// A string that may be absent, as JSON. fn opt_str(v: Option<&str>) -> String { match v { @@ -274,6 +305,40 @@ pub fn event_json(day: &beam402_event::Progress, skipped: usize) -> String { for name in day.class_names().map(str::to_string).collect::>() { let mut s = format!("{{\"name\":\"{}\"", esc(&name)); let _ = write!(s, ",\"entered\":{}", day.sheet().entries_in(&name).len()); + // Where a class stands while it is still qualifying — the same board the + // tower reads, so a league's front end can show a session in progress + // instead of an empty class until somebody draws. Absent once drawn: from + // that moment `field` is the answer and two orders would be one too many. + if day.field(&name).is_none() { + if let Some(n) = day.cut_at(&name) { + let _ = write!(s, ",\"cut\":{n}"); + } + let board: Vec = day + .standings(&name) + .into_iter() + .map(|st| { + let entry = day.sheet().entry(st.entry); + format!( + "{{\"seed\":{},\"number\":{},\"driver\":\"{}\",\"car\":\"{}\",\ +\"runs\":{},\"best\":{},\"off_dial\":{},\"ref\":{}}}", + st.seed.map_or("null".to_string(), |n| n.to_string()), + st.entry.0, + esc(entry.map(|e| e.driver.as_str()).unwrap_or_default()), + esc(entry.map(|e| e.car.as_str()).unwrap_or_default()), + st.runs, + num(st.best_et_s), + // Only where the class seeds by it, so a heads-up class + // does not carry a null nobody will ever read. + match day.sheet().class(&name).map(|c| c.seeding) { + Some(beam402_event::Seeding::ClosestToDial) => num(st.best), + _ => "null".into(), + }, + opt_str(entry.and_then(|e| e.external.as_deref())), + ) + }) + .collect(); + let _ = write!(s, ",\"qualifying\":[{}]", board.join(",")); + } if let Some(field) = day.field(&name) { let seeds: Vec = field .seeds() @@ -297,7 +362,14 @@ pub fn event_json(day: &beam402_event::Progress, skipped: usize) -> String { } None => s.push_str(",\"champion\":null"), } - if let Some(round) = day.round(&name) { + // Every round, earliest first — and `round` kept beside them, pointing at + // the one the class is on. A day that published only the current round + // published a final with nothing under it: once a class was settled its + // semi-finals were gone, and how somebody reached that final is the thing a + // league's page exists to show. `round` stays because it is somebody's + // dependency and **D35** says a shape gains fields rather than changing what + // one means. + let one = |round: &beam402_event::Round| { let pairs: Vec = round .pairs .iter() @@ -313,14 +385,47 @@ pub fn event_json(day: &beam402_event::Progress, skipped: usize) -> String { ) }) .collect(); - let _ = write!( - s, - ",\"round\":{{\"number\":{},\"name\":\"{}\",\"pairs\":[{}]}}", + format!( + "{{\"number\":{},\"name\":\"{}\",\"pairs\":[{}]}}", round.number, esc(&beam402_event::round_name(round.pairs.len(), round.number)), pairs.join(",") - ); + ) + }; + let history: Vec = day.rounds(&name).iter().map(one).collect(); + if !history.is_empty() { + let _ = write!(s, ",\"rounds\":[{}]", history.join(",")); } + if let Some(round) = day.round(&name) { + let _ = write!(s, ",\"round\":{}", one(round)); + } + // Who entered and did not make it, which a facade cannot work out for + // itself: it has the field and the number entered, never the names the cut + // removed. Withdrawn is a different sentence from missing a cut, so it is a + // different key rather than a flag on the same one. + let withdrawn = day.scratched(&name); + let who = |ids: Vec| { + ids.into_iter() + .filter_map(|id| { + let e = day.sheet().entry(id)?; + Some(format!( + "{{\"number\":{},\"driver\":\"{}\",\"car\":\"{}\",\"ref\":{}}}", + e.number, + esc(&e.driver), + esc(&e.car), + opt_str(e.external.as_deref()), + )) + }) + .collect::>() + .join(",") + }; + let missed: Vec = day + .did_not_qualify(&name) + .into_iter() + .filter(|id| !withdrawn.contains(id)) + .collect(); + let _ = write!(s, ",\"did_not_qualify\":[{}]", who(missed)); + let _ = write!(s, ",\"withdrawn\":[{}]", who(withdrawn)); s.push('}'); classes.push(s); } @@ -411,8 +516,63 @@ fn event(args: &Args) -> Result { } match day.field(&name) { + // Still qualifying, which used to print one sentence and nothing else + // — so a practice day had no output at all and a qualifying session + // could not be read off anything but the raw log. The board is where a + // class stands if it closed now, by the draw's own arithmetic. None => { - let _ = writeln!(out, " qualifying — the ladder has not been drawn\n"); + let cut = day.cut_at(&name); + let _ = writeln!( + out, + " qualifying — the ladder has not been drawn{}", + match cut { + Some(n) => format!(", top {n} would make the field"), + None => String::new(), + } + ); + let board = day.standings(&name); + if board.is_empty() { + let _ = writeln!(out); + continue; + } + let dialled = matches!( + day.sheet().class(&name).map(|c| c.seeding), + Some(beam402_event::Seeding::ClosestToDial) + ); + // No line until somebody has a time: with nothing run, the order + // above and below it is entry order and the cut would be a + // statement about the alphabet. + let cut = cut.filter(|_| board.iter().any(|s| s.best.is_some())); + for (i, s) in board.iter().enumerate() { + // Where the cut would fall, drawn once. A class everybody + // qualifies for has no line to draw. + if Some(i) == cut { + let _ = writeln!(out, " ---- cut ----"); + } + let _ = writeln!( + out, + " {:>3} {:<28}{:>9}{} {}", + s.seed.map_or("-".to_string(), |n| n.to_string()), + day.driver(s.entry), + match s.best_et_s { + Some(et) => format!("{et:.4}"), + // Never a zero and never a blank: this car has been + // down the track and has no time, or has not gone. + None => "—".into(), + }, + match (dialled, s.best) { + (true, Some(off)) => format!(" off dial {off:.4}"), + (true, None) => " ".into(), + _ => String::new(), + }, + match s.runs { + 0 => "no passes yet".into(), + 1 => "1 pass".to_string(), + n => format!("{n} passes"), + } + ); + } + let _ = writeln!(out); continue; } Some(field) => { @@ -1043,6 +1203,7 @@ fn pairing_from(text: &str) -> Result { path: String::new(), mapping: None, event: None, + day: DayArgs::default(), log: None, draw: None, to: None, @@ -1118,6 +1279,7 @@ fn parse(argv: Vec) -> Result { path, mapping: None, event: None, + day: DayArgs::default(), log: None, draw: None, to: None, @@ -1138,6 +1300,10 @@ fn parse(argv: Vec) -> Result { match flag.as_str() { "--mapping" => args.mapping = Some(value()?), "--event" => args.event = Some(value()?), + "--id" => args.day.id = Some(value()?), + "--name" => args.day.name = Some(value()?), + "--date" => args.day.date = Some(value()?), + "--ref" => args.day.external = Some(value()?), "--log" => args.log = Some(value()?), "--draw" => args.draw = Some(value()?), "--to" => args.to = Some(value()?), diff --git a/software/crates/cli/src/pages.rs b/software/crates/cli/src/pages.rs index e5139ef..266b6d1 100644 --- a/software/crates/cli/src/pages.rs +++ b/software/crates/cli/src/pages.rs @@ -297,8 +297,13 @@ a{{color:var(--accent)}} $("next").disabled = !mine; // Recording is offered only when there is a round to record and an event to // record it into. Swapping stops being offered once the pair has been raced. + // + // `settled` and not just `complete`: the car is over the line a beat before its + // ET is off the node, and a press inside that beat wrote the pass down with no + // time in it. var ev = s.event; - $("record").disabled = !(mine && ev && ev.on && s.phase === "complete" && !ev.recorded); + $("record").disabled = !(mine && ev && ev.on && s.phase === "complete" + && s.settled && !ev.recorded); $("swap").disabled = !(mine && ev && ev.on && !s.armed && !ev.recorded); // Closing qualifying is offered only while a class is in it. How many passes // is a club's business, so nothing here decides the moment is right. diff --git a/software/crates/event/src/desk.rs b/software/crates/event/src/desk.rs index 983e85f..6880c93 100644 --- a/software/crates/event/src/desk.rs +++ b/software/crates/event/src/desk.rs @@ -141,12 +141,47 @@ fn rows(text: &str) -> Vec { out } +/// Which day this is, where the skeleton does not already say. +/// +/// A skeleton is a **rulebook kept across a season**, so the one thing in it that +/// is not a rule is the particular Saturday in `[event]`. A meeting that runs +/// over three days — practice, qualifying, eliminations — would otherwise need +/// three hand-edited copies of the rulebook, and copies of a rulebook drift: +/// somebody fixes a field size in one of them and the class runs two ways in one +/// weekend. +/// +/// Absent fields keep whatever the skeleton carries, so a club running one day at +/// a time never sees this. +#[derive(Clone, Copy, Default, Debug)] +pub struct Day<'a> { + pub id: Option<&'a str>, + pub name: Option<&'a str>, + pub date: Option<&'a str>, + /// The league's own key for this day, carried and never interpreted (**D35**). + pub external: Option<&'a str>, +} + +impl Day<'_> { + fn is_empty(&self) -> bool { + self.id.is_none() + && self.name.is_none() + && self.date.is_none() + && self.external.is_none() + } +} + /// Entries from a CSV, checked against the classes a skeleton declares. /// /// `skeleton` is a sheet with `[event]` and `[[class]]` and no entries — what a /// club maintains for a season. The output is that skeleton with the day's /// entries written into it. pub fn import(skeleton: &str, csv: &str) -> Result { + import_as(skeleton, csv, &Day::default()) +} + +/// The same, for one day of a meeting that runs over several. +pub fn import_as(skeleton: &str, csv: &str, day: &Day) -> Result { + let skeleton = &retitle(skeleton, day); let rows = rows(csv); let (header, body) = rows.split_first().ok_or(DeskError::Empty)?; if body.is_empty() { @@ -232,6 +267,83 @@ pub fn import(skeleton: &str, csv: &str) -> Result { Ok(text) } +/// Rewrite the `[event]` keys this day overrides, and nothing else. +/// +/// Line by line rather than through a TOML round trip, for the same reason +/// [`write`] appends instead of re-emitting: the skeleton is a file the club wrote +/// and reads, and a parse-and-print would hand it back with its comments gone. +/// +/// A key the skeleton does not have is **inserted** — a season that never carried +/// an `id` because it was never uploaded still gets one the day it is. +fn retitle(skeleton: &str, day: &Day) -> String { + if day.is_empty() { + return skeleton.to_string(); + } + let fields = [ + ("id", day.id), + ("name", day.name), + ("date", day.date), + ("ref", day.external), + ]; + + let mut out = String::with_capacity(skeleton.len() + 128); + let mut in_event = false; + let mut done = false; + let mut written: Vec<&str> = Vec::new(); + for line in skeleton.lines() { + let t = line.trim(); + // A table header ends the one before it, so `[event]` runs until the next + // `[` in column one — which is how every skeleton here is laid out. + if t.starts_with('[') { + if in_event { + // Anything overridden that the skeleton never had goes in before + // the section closes, while `[event]` is still the open table. + for (key, value) in fields { + if let Some(v) = value.filter(|_| !written.contains(&key)) { + let _ = writeln!(out, "{key} = {}", quote(v)); + } + } + done = true; + } + in_event = !done && t == "[event]"; + out.push_str(line); + out.push('\n'); + continue; + } + // A commented-out key is a note to a human, not a value to rewrite. + if in_event && !t.starts_with('#') { + if let Some((key, _)) = t.split_once('=') { + let key = key.trim(); + if let Some((_, value)) = fields.iter().find(|(k, _)| *k == key) { + written.push(key); + match value { + Some(v) => { + let _ = writeln!(out, "{key} = {}", quote(v)); + } + // Not overridden: keep the club's line verbatim. + None => { + out.push_str(line); + out.push('\n'); + } + } + continue; + } + } + } + out.push_str(line); + out.push('\n'); + } + // A skeleton whose `[event]` is its last table closes here instead. + if in_event { + for (key, value) in fields { + if let Some(v) = value.filter(|_| !written.contains(&key)) { + let _ = writeln!(out, "{key} = {}", quote(v)); + } + } + } + out +} + /// Render a skeleton plus entries as an entry sheet. /// /// The skeleton's own text is kept verbatim — comments and all — because it is a @@ -481,6 +593,67 @@ ladder = "pro" assert_eq!(sheet.entries[0].driver, "D. Kuznetsov"); } + /// A meeting that runs over three days is three days off **one** rulebook. + /// The alternative is three hand-edited copies of it, and copies of a rulebook + /// drift — somebody fixes a field size in one and the class runs two ways in + /// one weekend. + #[test] + fn one_rulebook_produces_the_days_of_a_meeting() { + let csv = "number,driver,class\n9,A,Super Gas\n"; + let day = Day { + id: Some("kubok-2026-08-07"), + name: Some("Кубок РК — тренировка"), + date: Some("2026-08-07"), + external: Some("RK-2026-E03-P"), + }; + let text = import_as(SKELETON, csv, &day).unwrap(); + let sheet = Sheet::parse(&text).unwrap(); + assert_eq!(sheet.event.id.as_deref(), Some("kubok-2026-08-07")); + assert_eq!(sheet.event.date, "2026-08-07"); + assert_eq!(sheet.event.name, "Кубок РК — тренировка"); + // The skeleton carries no `ref`, so an overridden one is inserted rather + // than dropped on the floor. + assert_eq!(sheet.event.external.as_deref(), Some("RK-2026-E03-P")); + + // And the club's file survives it: comments, classes, everything unnamed. + assert!(text.contains("# Kept across a season"), "{text}"); + assert!(text.contains("index_s = 9.90"), "{text}"); + assert_eq!(text.matches("[event]").count(), 1, "{text}"); + + // Overriding one thing leaves the others as the skeleton had them. + let only_date = Day { + date: Some("2026-08-09"), + ..Day::default() + }; + let sheet = Sheet::parse(&import_as(SKELETON, csv, &only_date).unwrap()).unwrap(); + assert_eq!(sheet.event.date, "2026-08-09"); + assert_eq!(sheet.event.name, "Club day"); + assert_eq!(sheet.event.id.as_deref(), Some("club-day")); + + // And overriding nothing is the same file as before this existed. + assert_eq!( + import(SKELETON, csv).unwrap(), + import_as(SKELETON, csv, &Day::default()).unwrap() + ); + } + + #[test] + fn a_days_id_is_checked_like_any_other() { + // The override goes through the sheet's own checks rather than around + // them: wrong at the desk is an inconvenience, wrong at upload time is a + // day nobody can publish. + let csv = "number,driver,class\n9,A,Super Gas\n"; + let day = Day { + id: Some("Тренировка 7 августа"), + ..Day::default() + }; + let err = import_as(SKELETON, csv, &day).unwrap_err(); + assert!( + matches!(err, DeskError::Rejected(ref w) if w.contains("cannot go in a URL")), + "{err}" + ); + } + #[test] fn an_event_id_that_cannot_be_a_url_is_caught_at_the_desk() { // The alternative is finding out at the end of the day, when the day is diff --git a/software/crates/event/src/lib.rs b/software/crates/event/src/lib.rs index 5715668..1365cfc 100644 --- a/software/crates/event/src/lib.rs +++ b/software/crates/event/src/lib.rs @@ -27,7 +27,7 @@ pub mod sheet; pub mod sync; pub use ladder::{Pair, Style}; -pub use progress::{OnDeck, Progress, Refused}; +pub use progress::{OnDeck, Progress, Refused, Standing}; pub use sheet::{Record, Sheet}; pub use sync::{Appended, Cursor, Held, SyncError}; diff --git a/software/crates/event/src/progress.rs b/software/crates/event/src/progress.rs index 08e7013..fb1e1aa 100644 --- a/software/crates/event/src/progress.rs +++ b/software/crates/event/src/progress.rs @@ -16,7 +16,7 @@ use beam402_protocol::Lane; use beam402_race::{Entry as RaceEntry, Format, Pairing, PairingError}; use crate::sheet::{Record, Sheet}; -use crate::{Attempt, Class, Entry, EntryId, Field, Round, Seed}; +use crate::{Attempt, Class, Entry, EntryId, Field, Round, Seed, Seeding}; /// One pair, ready to be sent down the track. #[derive(Clone, Debug)] @@ -41,6 +41,33 @@ impl OnDeck { } } +/// One line of the qualifying board. +/// +/// A day in qualifying has a standing order all the same, and it is the thing +/// everybody at the track reads between passes: a driver deciding whether to take +/// another one, and an official deciding whether the class is ready to draw. +#[derive(Clone, PartialEq, Debug)] +pub struct Standing { + pub entry: EntryId, + /// The seed this car would be drawn at if qualifying closed now. + /// + /// `None` for a car the cut would leave out — which is the whole reason the + /// board exists while there are still passes left to take — and `None` for a + /// car with no time yet. The draw does place those: they qualify last, in entry + /// order, because they entered and they paid. But entry order is not a + /// qualifying position, and a board that printed one would be telling a driver + /// who has not run that they are provisionally third. + pub seed: Option, + /// Passes taken, scoring or not. A rulebook that gives three attempts does not + /// give a fourth to whoever broke on the first. + pub runs: usize, + /// The best pass by the class's **own** measure: seconds for `quickest-et`, + /// seconds off the dial for `closest-to-dial`. `None` until there is one. + pub best: Option, + /// The ET of that pass, which is the number a board shows either way. + pub best_et_s: Option, +} + /// Which end of a class's window a car fell out of. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum TooFast { @@ -59,7 +86,14 @@ struct ClassState { /// ladder is fixed and a scratch is an annotation. scratched: std::collections::BTreeSet, field: Option, - round: Option, + /// Every round this class has run, in order — the one it is on is the last. + /// + /// Kept rather than replaced because a finished class **is** its rounds: a day + /// that published only the current one published a final with no semi-finals + /// under it, and how somebody got to that final is the thing a league's page + /// exists to show. They are in the log either way; discarding them here only + /// meant every reader had to replay it again to get them back. + rounds: Vec, /// The class is over and this seed won it. champion: Option, } @@ -216,11 +250,11 @@ impl Progress { }), Record::Drawn { order, .. } => { let field = Field::from_order(order.clone()); - state.round = Some(Round::open(&class, &field)); + state.rounds = vec![Round::open(&class, &field)]; state.field = Some(field); } Record::Won { position, seed, .. } => { - if let Some(round) = state.round.as_mut() { + if let Some(round) = state.rounds.last_mut() { let _ = round.win(*position, *seed); } Self::advance(state, &class); @@ -230,7 +264,7 @@ impl Progress { completed, .. } => { - if let Some(round) = state.round.as_mut() { + if let Some(round) = state.rounds.last_mut() { let _ = round.bye(*position, *completed); } Self::advance(state, &class); @@ -260,14 +294,14 @@ impl Progress { } fn advance(state: &mut ClassState, class: &Class) { - let Some(round) = state.round.as_ref() else { + let Some(round) = state.rounds.last() else { return; }; if !round.is_complete() { return; } match round.advance(class) { - Ok(Some(next)) => state.round = Some(next), + Ok(Some(next)) => state.rounds.push(next), // Nothing left to pair: whoever is standing has won the class. Ok(None) => { state.champion = round.survivors().first().copied(); @@ -374,6 +408,98 @@ impl Progress { Ok(r) } + /// The qualifying board: where a class stands if qualifying closed now. + /// + /// **Derived, and derived by the code the draw itself uses.** The order comes + /// from [`Field::qualify`] over the attempts so far, so the board a driver + /// reads between passes cannot disagree with the ladder they end up in — which + /// a second implementation of "best pass" would eventually manage to do. + /// + /// The cut is shown rather than applied: cars below the line are in the list, + /// with no seed. They are still racing, and a board that hid them would be + /// hiding the only thing they are looking at. + /// Empty once the ladder is drawn: from that moment [`Progress::field`] is the + /// answer, and two orders for one class is one too many. + pub fn standings(&self, class: &str) -> Vec { + let Ok(c) = self.class(class) else { + return Vec::new(); + }; + if self.is_drawn(class) { + return Vec::new(); + } + let attempts = self.attempts(class); + let out = self.scratched(class); + let entries: Vec = self + .sheet + .entries_in(class) + .into_iter() + .filter(|e| !out.contains(&e.id)) + .collect(); + + // Ordered with the cut off, then the cut taken from the real call. Two + // reads of one function rather than a second copy of the rulebook. + let uncut = Class { + field: Vec::new(), + ..c.clone() + }; + let order = Field::qualify(&uncut, &entries, attempts); + let cut = Field::qualify(&c, &entries, attempts).len(); + + order + .seeds() + .map(|(place, id)| { + let mut runs = 0; + let mut best: Option<(f64, Option)> = None; + for a in attempts.iter().filter(|a| a.entry == id) { + runs += 1; + // The same two rules the draw applies: a pass past the scoring + // limit and a voided one are taken and not counted. + if c.attempts.is_some_and(|max| runs > max) || a.void { + continue; + } + let score = match c.seeding { + Seeding::QuickestEt => a.et_s, + Seeding::ClosestToDial => match (a.et_s, a.dial_s) { + (Some(et), Some(dial)) => Some((et - dial).abs()), + _ => None, + }, + Seeding::EntryOrder | Seeding::Draw { .. } => None, + }; + if let Some(score) = score { + if best.is_none_or(|(b, _)| score < b) { + best = Some((score, a.et_s)); + } + } + } + Standing { + entry: id, + seed: (place <= cut && best.is_some()).then_some(place), + runs, + best: best.map(|(b, _)| b), + best_et_s: best.and_then(|(_, et)| et), + } + }) + .collect() + } + + /// How many the cut takes, where a class has one — the size a board draws its + /// line at. `None` is a class everybody qualifies for. + pub fn cut_at(&self, class: &str) -> Option { + let c = self.class(class).ok()?; + if c.field.is_empty() { + return None; + } + let out = self.scratched(class); + let entries: Vec = self + .sheet + .entries_in(class) + .into_iter() + .filter(|e| !out.contains(&e.id)) + .collect(); + let size = Field::qualify(&c, &entries, self.attempts(class)).len(); + (size < entries.len()).then_some(size) + } + /// Cars whose qualifying puts them outside their class's time window. /// /// **Reported, never acted on.** A class defined as `13.000–14.000` is a @@ -499,8 +625,8 @@ impl Progress { .get(class) .ok_or_else(|| Refused::NoSuchClass(class.into()))?; let round = state - .round - .as_ref() + .rounds + .last() .ok_or_else(|| Refused::NotDrawnYet(class.into()))?; let pair = round .pairs @@ -536,8 +662,8 @@ impl Progress { .get(class) .ok_or_else(|| Refused::NoSuchClass(class.into()))?; let round = state - .round - .as_ref() + .rounds + .last() .ok_or_else(|| Refused::NotDrawnYet(class.into()))?; let r = Record::Bye { class: class.into(), @@ -562,7 +688,7 @@ impl Progress { pub fn round_number(&self, class: &str) -> Option { self.classes .get(class) - .and_then(|s| s.round.as_ref()) + .and_then(|s| s.rounds.last()) .map(|r| r.number) } @@ -570,8 +696,14 @@ impl Progress { self.classes.get(class).and_then(|s| s.field.as_ref()) } + /// The round a class is on, which is the last one it has opened. pub fn round(&self, class: &str) -> Option<&Round> { - self.classes.get(class).and_then(|s| s.round.as_ref()) + self.classes.get(class).and_then(|s| s.rounds.last()) + } + + /// Every round the class has run, earliest first. What a finished class *is*. + pub fn rounds(&self, class: &str) -> &[Round] { + self.classes.get(class).map_or(&[], |s| s.rounds.as_slice()) } /// The next pair that has not run, in any class — the operator's queue. @@ -587,7 +719,7 @@ impl Progress { if state.champion.is_some() { return None; } - let round = state.round.as_ref()?; + let round = state.rounds.last()?; let field = state.field.as_ref()?; let c = self.sheet.class(class)?; let pair = round.outstanding().first().copied().copied()?; @@ -826,6 +958,73 @@ class = "Bracket" assert_eq!(p.did_not_qualify("Bracket"), vec![EntryId(1)]); } + /// A day in qualifying has a standing order all the same, and it used to be + /// invisible: the class printed one sentence and the API said nothing, so a + /// practice day produced no output at all and a session in progress could only + /// be read off the raw log. + #[test] + fn a_class_still_qualifying_has_a_board() { + let mut p = Progress::new(Sheet::parse(CUT).unwrap()); + + // Nobody has run. Three cars, no places: the draw would order them by + // entry number, and entry number is not a qualifying position. + let board = p.standings("Bracket"); + assert_eq!(board.len(), 3); + assert!(board + .iter() + .all(|s| s.seed.is_none() && s.runs == 0 && s.best_et_s.is_none())); + assert_eq!(p.cut_at("Bracket"), Some(2), "field = [2, 4] over three cars"); + + for (n, et) in [(1u32, 10.50), (2, 10.10)] { + p.qualified("Bracket", EntryId(n), Some(et), None, false) + .unwrap(); + } + let board = p.standings("Bracket"); + assert_eq!( + (board[0].entry, board[0].seed, board[0].best_et_s, board[0].runs), + (EntryId(2), Some(1), Some(10.10), 1) + ); + assert_eq!((board[1].entry, board[1].seed), (EntryId(1), Some(2))); + assert_eq!( + (board[2].entry, board[2].seed), + (EntryId(3), None), + "entered, has not run, and the cut takes two" + ); + + // The property the board exists for: it is the draw, asked early. A second + // implementation of "best pass" would eventually disagree with the ladder + // people are racing off. + p.draw("Bracket").unwrap(); + let drawn: Vec = p.field("Bracket").unwrap().seeds().map(|(_, e)| e).collect(); + assert_eq!(drawn, vec![EntryId(2), EntryId(1)]); + assert!( + p.standings("Bracket").is_empty(), + "and it stops answering once there is a field to read instead" + ); + } + + /// A bracket class is seeded on how close to the dial, so that is what its + /// board has to show — the ET alone would look like the wrong order. + #[test] + fn a_bracket_board_scores_the_dial_and_not_the_clock() { + let mut p = Progress::new(sheet()); + // #2 runs 7.60 on a 7.50 dial: quickest car on the property, 0.10 off. + // #1 runs 12.36 on 12.34: slowest car here, 0.02 off, and top qualifier. + for (n, et) in [(1u32, 12.36), (2, 7.60)] { + let dial = p.sheet().entry(EntryId(n)).unwrap().dial_s; + p.qualified("Bracket", EntryId(n), Some(et), dial, false) + .unwrap(); + } + let board = p.standings("Bracket"); + assert_eq!(board[0].entry, EntryId(1)); + assert_eq!(board[0].best.map(|b| (b * 1e4).round()), Some(200.0)); + assert_eq!(board[0].best_et_s, Some(12.36), "and the clock is still shown"); + assert_eq!(board[1].entry, EntryId(2)); + assert_eq!(board[1].best.map(|b| (b * 1e4).round()), Some(1000.0)); + // No cut in this class, so nothing is out. + assert_eq!(p.cut_at("Bracket"), None); + } + /// **D37.** A voided pass loses its time and not its attempt, and it does not /// touch the ladder — which is the property the whole format rule rests on. #[test]