diff --git a/docs/architecture.md b/docs/architecture.md index 0a06a53..54caf13 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,8 +3,9 @@ This document describes how Intersection Lab is put together. It covers what each Rust source file does, how the code is split into layers, and how those layers work together during a simulation step, a corridor run, a cooperative crossing, a benchmark and a -training run. It describes version 0.5.1. Section 9.4 is an illustrated tour of the -app's window, and Appendix A says how the screenshots were made. +training run. It describes the code on `main`: version 0.5.1 and the fixes made since. +Section 9.4 is an illustrated tour of the app's window, and Appendix A says how the +screenshots were made. The diagrams are written in [Mermaid](https://mermaid.js.org/), which GitHub renders inline. Their colors mean the same thing throughout: @@ -212,7 +213,7 @@ src/ bin/train.rs headless training of the Deep RL and Hybrid networks examples/ studies: cooperative, corridor, fya, lpi, preempt, satflow, sweep, plus make_icons -tests/ safety, cooperative, pedestrian_rule, weights_files +tests/ safety, cooperative, pedestrian_rule, weights_files, green_wave fuzz/ cargo-fuzz target for weights files (a workspace of its own) weights/ trained networks (JSON), also compiled into the binary web/index.html page that hosts the browser build (Trunk) @@ -226,14 +227,14 @@ docs/ this document, insights.md, cooperative-intersections.md (wi | Directory | Files | Lines | |---|---:|---:| -| `src/sim/` | 11 | 7,602 | -| `src/control/` | 8 | 1,474 | +| `src/sim/` | 11 | 7,678 | +| `src/control/` | 8 | 1,489 | | `src/rl/` | 3 | 360 | -| `src/gui/` | 5 | 3,644 | -| `src/bin/` | 2 | 589 | +| `src/gui/` | 5 | 3,694 | +| `src/bin/` | 2 | 590 | | `src/lib.rs`, `src/main.rs` | 2 | 78 | | `examples/` | 8 | 575 | -| `tests/` | 4 | 749 | +| `tests/` | 5 | 873 | | `fuzz/fuzz_targets/` | 1 | 54 | | Target | Built from | Purpose | @@ -405,8 +406,8 @@ Three things about this ordering matter: | `coop.rs` | 1,883 | The cooperative crossing's intersection manager | | `network.rs` | 1,074 | The layouts: legs, lanes, paths, movements, stages and conflict points | | `sensors.rs` | 572 | Sensor models and `Sim::sense` | -| `world.rs` | 387 | One intersection or a corridor | -| `signal.rs` | 286 | The signal state machine | +| `world.rs` | 410 | One intersection or a corridor | +| `signal.rs` | 339 | The signal state machine | | `demand.rs` | 273 | Arrivals | | `geom.rs` | 268 | Vectors, paths and curves | | `stats.rs` | 254 | Measurement | @@ -862,8 +863,9 @@ stateDiagram-v2 * **`no_late_walk`** is set while a coordinated fixed-time corridor runs. WALK then starts only with its stage, so a pedestrian call never pushes a stage past its force-off. -* **`history`** holds the last 400 `StageSpan`s, which the app draws as the signal - timeline. +* **`history`** holds the last 1,200 `StageSpan`s, which the app draws as the signal + timeline: each stage's green and, when the change needs them, the yellow and all-red + that end it. Each crosswalk has its own small machine (`CwSignal`), with a push-button `called` flag: @@ -1120,7 +1122,10 @@ flowchart TB longest of their Webster cycles, and offsets for a green wave. The offsets are chosen one intersection at a time, in 1 s steps, to give the most green to platoons in both directions traveling at 92% of the speed limit. It first sets every signalized - intersection to fixed-time control. + intersection to fixed-time control, remembering the controller each one ran + (`controller_before_wave`). Turning the wave off gives those controllers back, and + signals that were fixed-time return to their own Webster plans. An intersection + given a controller during the wave, through `World::set_controller`, keeps it. * **`sync_settings` and `follow`:** copy settings edited in the app to the other intersections and to the comparison twin. Each intersection keeps its own `fed` flags. @@ -1371,14 +1376,14 @@ but only as their reward (§8.3). | File | Lines | What it holds | |---|---:|---| -| `rl.rs` | 335 | `RlController`, the Deep RL controller, with its input features and safety shield | +| `rl.rs` | 333 | `RlController`, the Deep RL controller, with its input features and safety shield | | `predictive.rs` | 289 | `Predictive`, the rolling-horizon planner | -| `hybrid.rs` | 246 | `Hybrid`, the planner re-tuned by a network | +| `hybrid.rs` | 254 | `Hybrid`, the planner re-tuned by a network | | `fixed.rs` | 183 | `FixedTime`: Webster's plan and corridor coordination | | `mod.rs` | 141 | The `Controller` trait, `Ctx`, `Brain`, `CtrlKind` and `make` | | `actuated.rs` | 134 | `Actuated`: gap-out and max-out control | | `maxpressure.rs` | 86 | `MaxPressure` | -| `weights.rs` | 60 | Where trained networks are loaded from and saved to (§8.7) | +| `weights.rs` | 69 | Where trained networks are loaded from and saved to (§8.7) | ### 7.1 `control/mod.rs`: the contract @@ -1606,7 +1611,7 @@ Hybrid's major/minor and T-intersection files hold only its best static tuning | File | Lines | What it holds | |---|---:|---| -| `src/bin/train.rs` | 428 | Training, model selection and the test report | +| `src/bin/train.rs` | 429 | Training, model selection and the test report | | `rl/nn.rs` | 190 | `Mlp`, `Grads` and `Adam` | | `rl/dqn.rs` | 165 | `Dqn`, `Transition`, `SavedModel` and `argmax_masked` | | `rl/mod.rs` | 5 | The module list | @@ -2034,32 +2039,32 @@ the static tuning; on the town layout it all comes from the network. ### 8.7 `control/weights.rs`: loading and saving -`make()` loads a controller's file through `load_or_new`, which asks `weights::load` -for the file's text and `from_saved` for a controller built from it: +`make()` loads a controller's file through `load_or_new`. It gives `weights::load` +the file's name and `from_saved`, which builds a controller from a copy's text, and +`weights::load` tries each copy in turn: ```mermaid flowchart TB MK["make() for Deep RL
or the Hybrid"] - L1{{"weights/FILE in the
working directory
readable?"}} - L2{{"FILE in the user
data directory
readable?"}} - L3["The built-in copy,
compiled in with
include_str!"] - V{{"Parses, and the
network fits?"}} - RUN["Run the trained
network"] - NO["Otherwise, as in
the table below"] + L1{{"weights/FILE in the
working directory
readable, and loads?"}} + L2{{"FILE in the user
data directory
readable, and loads?"}} + L3{{"The built-in copy,
compiled in with
include_str!, loads?"}} + RUN["Run what
that copy holds"] + NO["Otherwise, as
described below"] MK --> L1 - L1 -->|yes| V + L1 -->|yes| RUN L1 -->|no| L2 - L2 -->|yes| V - L2 -->|no| L3 --> V - V -->|yes| RUN - V -->|"no: the other
copies aren't tried"| NO + L2 -->|yes| RUN + L2 -->|no| L3 + L3 -->|yes| RUN + L3 -->|no| NO classDef ctrl fill:#e8f0fe,stroke:#2b6cb0,color:#0b2545 classDef safe fill:#fdecec,stroke:#c53030,color:#4a0f0f - class MK,L1,L2,L3,RUN,NO ctrl - class V safe + class MK,RUN,NO ctrl + class L1,L2,L3 safe ``` -* **Where it looks:** the first *readable* copy wins. `weights/` in the working +* **Where it looks:** the first copy that *loads* wins. `weights/` in the working directory is where `train` writes. The user data directory is where the app saves: * macOS: `~/Library/Application Support/Intersection Lab/weights/`; * Windows: `%APPDATA%\Intersection Lab\weights\`; @@ -2080,18 +2085,24 @@ flowchart TB The layout name in the file isn't checked, but the layouts' input counts all differ (45, 83, 84 and 85), so a file can't be applied to the wrong layout. -* **What happens when a check fails:** nothing is logged, and the other copies aren't - tried. +* **What happens when a check fails:** nothing is logged, and the next copy is tried, + so a damaged file doesn't hide a good copy further down the list. A Hybrid file + whose network doesn't fit still loads, without the network, since its base tuning + can still run. -| The file found | Deep RL | Hybrid | +| The copy | Deep RL | Hybrid | |---|---|---| -| Parses, and its network fits | Runs the network, with learning off | Runs the network | -| Parses, with no network or one that doesn't fit | A fresh network (seed 7) that learns online with ε = 0.2 | The saved base tuning, with no network | -| Doesn't parse | The same fresh network | The planner's default tuning (`DEFAULT_ACTION`: fairness 0.004, no bias, 60 s), so it behaves exactly like `Predictive` | - -The panel then says "no pretrained weights found" for Deep RL. For the Hybrid it says -"no network beat the best static tuning", the same as for the shipped static files, or -"running the planner's default tuning". +| Parses, and its network fits | Loads, and runs the network with learning off | Loads, and runs the network | +| Parses, with no network or one that doesn't fit | Is skipped | Loads, and runs the saved base tuning with no network | +| Can't be read or doesn't parse | Is skipped | Is skipped | + +When no copy loads, Deep RL starts a fresh network (seed 7) that learns online with +ε = 0.2, and its panel says "no pretrained weights found". The Hybrid then runs the +planner's default tuning (`DEFAULT_ACTION`: fairness 0.004, no bias, 60 s), so it +behaves exactly like `Predictive`, and its panel says "running the planner's default +tuning". A Hybrid that loaded only a base tuning says "no network beat the best static +tuning" when its file has no network, the same as for the shipped static files, and +"the saved network doesn't fit this layout" when the file's network was left out. Saving goes through `save_path`. It writes to `weights/` in the working directory when that directory exists, which, run from a checkout, overwrites the shipped files. @@ -2099,25 +2110,29 @@ Otherwise it writes to the user data directory, creating it first, or to `weight there is no user data directory. `train` creates `weights/` when it saves, and the next build embeds whatever it wrote. -In 0.5.1 two saves write a Hybrid file that doesn't load. The app's *Save weights* -records the Hybrid's score as NaN, and `train` records an infinite score when no static -tuning passes the pedestrian check. The JSON writer stores either as `null`, which the -reader rejects, and since the other copies aren't tried, that Hybrid then runs the -planner's default tuning. +A Hybrid file also records `base_score`, the validation person delay of what it +holds, when there is one. The app's *Save weights* measures no score, and when no +static tuning passes the pedestrian check, `train`'s score is infinite, which JSON +can't represent. Both are written as `null`, which loads as no score. The app saves +the network only while it is choosing the tunings, trained or learning. Version 0.5.1 +wrote the same `null` but couldn't read it back, so such a file didn't load and that +Hybrid ran the planner's default tuning. In the app, each learned controller has a *Keep learning online* box, an ε slider and, on the desktop, *Save weights*. A loaded Deep RL network starts with learning off and ε = 0.05, and its updates start once 1,000 transitions are stored. Deep RL also has -*Reload pretrained*, which rebuilds the controller through `make`, so it loads whichever -copy the search finds first: after a save, the saved one. In the browser, learning and +*Reload pretrained*, which rebuilds the controller through `make`, so it gets the +first copy that loads: after a save, the saved one. In the browser, learning and *Reload pretrained* work in memory, and *Save weights* is hidden. Weights files can be damaged or edited by hand, so their loading is tested and fuzzed: * `tests/weights_files.rs` checks that every shipped file loads, that seven kinds of misshapen network are rejected (one with a weight of 10³⁹, which is infinite as an - `f32`), that a Hybrid whose network doesn't fit keeps its base tuning and that - networks whose outputs overflow don't crash anything. + `f32`), that a Hybrid whose network doesn't fit keeps its base tuning and says so, + that Hybrid files saved with a score that isn't finite load again, that a copy that + doesn't load is skipped for the next one and that networks whose outputs overflow + don't crash anything. * The `weights` fuzz target reads each input as both kinds of file for the T-intersection. A file that is accepted gets a training step if it has a network, then 20 s of simulation. The CI job "Fuzz (weights)" runs it for 60 s, starting from @@ -2266,8 +2281,9 @@ The app changes the simulation in only these ways: left-turn mode and the max-wait guard. `World::sync_settings` copies them to the other intersections of a corridor, and `World::follow` to the twin (§6.10). An edit to demand also rebuilds every fixed-time controller, so its Webster plan follows. -* **Controllers:** `set_controller` swaps the focused intersection's controller, or - side B's on every twin intersection. `set_coordination` runs the green wave. +* **Controllers:** `World::set_controller` swaps the focused intersection's + controller, or side B's on every twin intersection. `set_coordination` runs the + green wave, and gives back the controllers it replaced when the wave ends. * **The cooperative crossing:** `set_scheduling`, `set_gap_policy` and the maximum cycle and batch join window, all live. * **Rebuilds:** a new `World` when the layout, control mode or corridor size changes, @@ -2299,10 +2315,12 @@ arrivals, and hand-spawned vehicles and pedestrians are added to both. This section goes through every part of the window: what it shows, how to read it and which code produces it. The screenshots come from the desktop app of version 0.5.1 in -its default 1600 × 1000 window (the smallest it allows is 1100 × 700). They were taken -with the scripted-screenshot settings of Appendix A, two simulated minutes into the -default scenario unless a caption says otherwise. The browser version looks the same, -without the *Save weights* buttons and training commands of the learning panels. +its default 1600 × 1000 window (the smallest it allows is 1100 × 700), except the +close-up of the signal timing chart in part 5, which comes from `main` and shows the +yellow and all-red drawn since. They were taken with the scripted-screenshot settings +of Appendix A, two simulated minutes into the default scenario unless a caption says +otherwise. The browser version looks the same, without the *Save weights* buttons and +training commands of the learning panels. #### The window at a glance @@ -2381,7 +2399,9 @@ controller with its description, seed 1 with Restart and the Compare side by sid * **Fixed-time green wave** appears with a corridor. It runs `set_coordination` (§6.10), which makes every signal of the corridor fixed-time, in both worlds when comparing, with a common cycle and offsets that maximize the two-way green band. - Turning it off gives each signal back its own Webster plan; they stay fixed-time. + While it runs, *Controller* shows *Fixed-time (Webster)*. Turning it off gives each + signal back the controller it ran before, unless another was chosen meanwhile, and + the bottom panel confirms each change. Scenario
   section with 3 signals along the main road and the Fixed-time green wave box @@ -2392,7 +2412,7 @@ controller with its description, seed 1 with Restart and the Compare side by sid * **Controller** (signals only): one of six strategies (§7.3), with a short description below the list. Changing it swaps the controller live: traffic keeps moving, only the decision-maker changes and the bottom panel confirms the switch. In a corridor it - changes only the focused intersection. + shows and changes the focused intersection's controller. * **Seed** (1 to 99,999) fixes every random arrival: the same seed gives the same traffic. *Restart* rebuilds the world from time zero, like *Reset*. * **Compare side by side:** see [Comparing two strategies](#comparing-two-strategies). @@ -2614,14 +2634,16 @@ it describes the focused intersection. Signal timing chart for
 the first two minutes: NS Protected Left, then NS Through, then EW Through for a
-minute, then NS Through again; the other stages unused +minute, then NS Through again, with a yellow and a red segment after each of the first +three greens; the other stages unused"> The chart spans 3 minutes, the last three once the run is longer: * there is one row per stage, and the current stage's name is white; -* a bar runs from the start of a stage's green to the start of the next stage's green, - so it includes the stage's yellow and all-red; -* the colors only tell rows apart; +* a stage's green is a bar in the row's color, followed by the yellow change and the + all-red clearance that end it, in yellow and red; a change that needs neither + goes straight to the next green; +* the green bars' colors only tell rows apart; * the time axis has a tick every 30 s. In this window the predictive controller has used only three of its eight stages. @@ -2743,7 +2765,8 @@ by Q-value"> * the three tunings with the highest Q-values. On a layout whose shipped file has no network, the notes say that no network beat the -best static tuning, and name the tuning that runs (§8.7). +best static tuning, and name the tuning that runs (§8.7). When a saved file's network +doesn't fit the layout, they say that instead. **Other controllers** @@ -2855,7 +2878,7 @@ intersection's measurements"> * **Main-road trips end to end:** for vehicles that traveled the whole east–west road, the number of trips, travel time, stops and delay per trip. * The numbers below, the charts, the timeline and the brain describe only the focused - intersection. + intersection, and so do the *Controller* list and the status overlay. * Settings changed in the left panel apply to every intersection, except the controller, which changes only the focused one. Side B's controller applies to all of B's intersections. @@ -2966,8 +2989,8 @@ These numbers come from `observe` (§6.11), which measures what the vehicles act did, independently of the plans: * **closest crossing (PET):** the shortest post-encroachment time; -* **margin over gap:** the smallest PET minus the gap the policy required, green - while it isn't negative; +* **margin over gap:** the smallest PET minus the gap the policy required, with its + sign: green while it isn't negative, red otherwise; * **zone overlaps**, **entries without slot** (crossings without a reservation) and **off trajectory**, green at zero and red otherwise; * **worst timing error:** the largest difference between planned and actual times. @@ -3059,16 +3082,20 @@ The examples are the studies behind `docs/insights.md` and ### 10.3 Tests -`cargo test` runs four integration test files and the unit tests in `sim/coop.rs`. CI -runs them in release mode on Linux, macOS and Windows (§11). +`cargo test` runs five integration test files and the unit tests in `sim/coop.rs`, +`sim/signal.rs` and `gui/mod.rs`. CI runs them in release mode on Linux, macOS and +Windows (§11). | File | Tests | What they require | |---|---:|---| | `tests/safety.rs` | 4 | Every layout and control combination runs without collisions and serves at least 80% of arrivals at default volumes (65% under two-way stop and under all-way stop on the arterial, which are over capacity); different controllers receive identical arrivals; no gridlock in seeds that used to jam; no collision between a turn finishing late in the yellow and the next stage's traffic | | `tests/cooperative.rs` | 14 | The cooperative crossing: no signals, sensors or crosswalks; its geometry and phase order; every gap policy safe under heavy demand, bursts, heavy vehicles and live switching; about 18,000 vehicles per hour served at the design demand, about 19,000 oversaturated and less with single left lanes or first come, first served; reproducible runs; light traffic barely delayed; emergency vehicles first; hand-placed vehicles crossing every way. Every run must show no monitor violations | | `tests/pedestrian_rule.rs` | 4 | Deep RL's shield serves a crosswalk whose pedestrians have waited longer than `PED_MAX_WAIT`, longest wait first, and leaves shorter waits to the controller; the reward counts waiting pedestrians | -| `tests/weights_files.rs` | 4 | The shipped weights load, misshapen networks are rejected, a Hybrid without a fitting network keeps its base tuning and networks that overflow don't crash (§8.7) | +| `tests/weights_files.rs` | 6 | The shipped weights load, misshapen networks are rejected, a Hybrid without a fitting network keeps its base tuning and says so, Hybrid files saved with a score that isn't finite load again, a copy that doesn't load is skipped for the next one and networks that overflow don't crash (§8.7) | +| `tests/green_wave.rs` | 2 | The green wave runs every signal of a corridor fixed-time, and ending it gives back the controllers they ran before, except where another was chosen during the wave, without collisions; a single intersection isn't coordinated | | `src/sim/coop.rs` | 4 | Approach profiles arrive on time and at speed, a short run-up enters slower but on time, the gap policies give the gaps in §6.11 and the following law holds its time gap | +| `src/sim/signal.rs` | 1 | The signal history records a change's yellow and all-red between the two greens, with no gaps | +| `src/gui/mod.rs` | 1 | The safety monitors' margin over the gap keeps its sign | The `weights` fuzz target (`fuzz/`, a workspace of its own) feeds arbitrary bytes to both weights loaders. A network they accept must survive a training step, 20 s of diff --git a/docs/images/architecture/timeline.png b/docs/images/architecture/timeline.png index 5040707..7eaad14 100644 Binary files a/docs/images/architecture/timeline.png and b/docs/images/architecture/timeline.png differ diff --git a/src/bin/train.rs b/src/bin/train.rs index d115e6e..0d28be6 100644 --- a/src/bin/train.rs +++ b/src/bin/train.rs @@ -145,7 +145,7 @@ impl Agent { std::fs::create_dir_all("weights").ok(); match self { Agent::Dqn(a) => a.save(&rl::weights_path(layout.key())).ok(), - Agent::Hybrid(a) => a.save(&hybrid::weights_path(layout.key()), score, true).ok(), + Agent::Hybrid(a) => a.save(&hybrid::weights_path(layout.key()), Some(score), true).ok(), }; } fn loss(&self) -> f32 { @@ -272,10 +272,11 @@ fn train(kind: LayoutKind, episodes: u64, secs: f64, which: &str) { if let Agent::Hybrid(h) = &mut agent { // The base tuning is the exploration expert and the score to beat, also when // resuming; save it now so the hybrid falls back to it if no network does better. + // When no tuning met the pedestrian limit, its score is infinite and isn't saved. h.base_action = base.1; best = base.0; std::fs::create_dir_all("weights").ok(); - h.save(&hybrid::weights_path(kind.key()), base.0, false).ok(); + h.save(&hybrid::weights_path(kind.key()), Some(base.0), false).ok(); saved_any = true; } agent.set(true, 0.0, 0.0); diff --git a/src/control/hybrid.rs b/src/control/hybrid.rs index d94ab65..2c6bd9b 100644 --- a/src/control/hybrid.rs +++ b/src/control/hybrid.rs @@ -56,7 +56,10 @@ pub const HIDDEN: &[usize] = &[128]; #[derive(Serialize, Deserialize)] pub struct SavedHybrid { pub base_action: usize, - pub base_score: f64, + /// Validation person delay (s) of what the file holds, when training measured one. + /// Version 0.5.1 wrote `null` for a score it couldn't store; that loads as `None`. + #[serde(default)] + pub base_score: Option, pub model: Option, } @@ -66,6 +69,8 @@ pub struct Hybrid { pub base_action: usize, /// The network is trained (loaded or being trained) and may choose tunings. pub has_model: bool, + /// The weights file's network doesn't fit this layout, so it was left out. + pub model_rejected: bool, pub agent: Dqn, pub learning: bool, pub epsilon: f64, @@ -99,6 +104,7 @@ impl Hybrid { planner: Predictive::new(net), base_action: DEFAULT_ACTION, has_model: false, + model_rejected: false, agent, learning: false, epsilon: 0.0, @@ -121,9 +127,7 @@ impl Hybrid { /// keeps the planner's default tuning (it behaves exactly like the predictive /// controller). pub fn load_or_new(net: &Network) -> Self { - if let Some((txt, source)) = super::weights::load(&weights_file(net.kind.key())) - && let Some(mut h) = Self::from_saved(net, &txt) - { + if let Some((mut h, source)) = super::weights::load(&weights_file(net.kind.key()), |txt| Self::from_saved(net, txt)) { h.loaded_from = Some(source); return h; } @@ -132,7 +136,8 @@ impl Hybrid { /// The tuning and network saved as `txt` (a weights file's contents), or `None` if /// it doesn't parse. Weights files can be damaged or edited by hand, so a network - /// that doesn't fit this layout is left out and only the saved base tuning is kept. + /// that doesn't fit this layout is left out (`model_rejected`) and only the saved + /// base tuning is kept. pub fn from_saved(net: &Network, txt: &str) -> Option { let sv = serde_json::from_str::(txt).ok()?; let mut h = match sv.model { @@ -142,13 +147,15 @@ impl Hybrid { h.has_model = true; h } - _ => Self::new(net, 11), + other => Self { model_rejected: other.is_some(), ..Self::new(net, 11) }, }; h.base_action = sv.base_action.min(N_ACTIONS - 1); Some(h) } - pub fn save(&self, path: &std::path::Path, base_score: f64, with_model: bool) -> std::io::Result<()> { + /// Save the base tuning, its score if known, and the network if `with_model`. A + /// score that isn't finite is saved as unknown, since JSON has no number for it. + pub fn save(&self, path: &std::path::Path, base_score: Option, with_model: bool) -> std::io::Result<()> { let model = with_model.then(|| SavedModel { layout: self.layout.clone(), n_in: self.agent.online.n_in(), @@ -156,7 +163,7 @@ impl Hybrid { episodes: self.episodes, net: self.agent.online.clone(), }); - let sv = SavedHybrid { base_action: self.base_action, base_score, model }; + let sv = SavedHybrid { base_action: self.base_action, base_score: base_score.filter(|s| s.is_finite()), model }; std::fs::write(path, serde_json::to_string(&sv).unwrap()) } @@ -229,6 +236,7 @@ impl Controller for Hybrid { let mut notes = vec![format!("RL tuning now: {}", preset_name(self.action))]; notes.push(match (&self.loaded_from, self.has_model) { (Some(_), true) => format!("trained network ({} episodes) over base tuning {}", self.episodes, preset_name(self.base_action)), + (Some(_), false) if self.model_rejected => format!("the saved network doesn't fit this layout; running {}", preset_name(self.base_action)), (Some(_), false) => format!("no network beat the best static tuning; running {}", preset_name(self.base_action)), (None, _) if self.learning => "learning online from scratch".into(), (None, _) => "no trained weights: running the planner's default tuning".into(), diff --git a/src/control/rl.rs b/src/control/rl.rs index d364a7a..ac59465 100644 --- a/src/control/rl.rs +++ b/src/control/rl.rs @@ -105,9 +105,7 @@ impl RlController { } pub fn load_or_new(net: &Network) -> Self { - if let Some((txt, source)) = super::weights::load(&weights_file(net.kind.key())) - && let Some(mut c) = Self::from_saved(net, &txt) - { + if let Some((mut c, source)) = super::weights::load(&weights_file(net.kind.key()), |txt| Self::from_saved(net, txt)) { c.loaded_from = Some(source); return c; } diff --git a/src/control/weights.rs b/src/control/weights.rs index ab25e95..41927d4 100644 --- a/src/control/weights.rs +++ b/src/control/weights.rs @@ -5,11 +5,12 @@ //! //! Loading looks in `weights/` under the working directory (development and training), //! then in the per-user data directory (weights saved from the app), then falls back to -//! the copies built into the binary, so an installed app works anywhere. Saving goes to -//! `weights/` when that directory exists in the working directory, otherwise to the -//! per-user data directory. +//! the copies built into the binary, so an installed app works anywhere. A copy that +//! can't be read or doesn't load is skipped, so a damaged file doesn't hide a good copy +//! further down the list. Saving goes to `weights/` when that directory exists in the +//! working directory, otherwise to the per-user data directory. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Weights shipped with the application. const EMBEDDED: &[(&str, &str)] = &[ @@ -38,16 +39,24 @@ pub fn user_dir() -> Option { } } -/// Contents of weights file `file` and where they came from. -pub fn load(file: &str) -> Option<(String, String)> { - let local = PathBuf::from("weights").join(file); - let user = user_dir().map(|d| d.join("weights").join(file)); - for p in std::iter::once(local).chain(user) { - if let Ok(txt) = std::fs::read_to_string(&p) { - return Some((txt, p.display().to_string())); +/// Weights file `file` as `parse` loads it from the first copy it accepts, and where +/// that copy came from. +pub fn load(file: &str, parse: impl FnMut(&str) -> Option) -> Option<(T, String)> { + let dirs: Vec = std::iter::once(PathBuf::from("weights")).chain(user_dir().map(|d| d.join("weights"))).collect(); + load_from(&dirs, file, parse) +} + +/// Like [`load`], but looking in `dirs`, in order, before the built-in copy. +pub fn load_from(dirs: &[impl AsRef], file: &str, mut parse: impl FnMut(&str) -> Option) -> Option<(T, String)> { + for p in dirs.iter().map(|d| d.as_ref().join(file)) { + if let Ok(txt) = std::fs::read_to_string(&p) + && let Some(v) = parse(&txt) + { + return Some((v, p.display().to_string())); } } - EMBEDDED.iter().find(|e| e.0 == file).map(|e| (e.1.to_string(), format!("built-in {file}"))) + let txt = EMBEDDED.iter().find(|e| e.0 == file)?.1; + parse(txt).map(|v| (v, format!("built-in {file}"))) } /// Where to save weights file `file` (the directory may still need creating). diff --git a/src/gui/charts.rs b/src/gui/charts.rs index 4bd33f6..6f4469e 100644 --- a/src/gui/charts.rs +++ b/src/gui/charts.rs @@ -4,6 +4,7 @@ //! Small hand-painted charts (line, bars, histograms, signal and crossing timelines) in //! egui. +use crate::sim::signal::Phase; use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; pub const SERIES: [Color32; 6] = [ @@ -293,26 +294,21 @@ pub fn timeline(ui: &mut Ui, sim: &crate::sim::Sim, window: f64) { p.text(pos2(tx(t), plot.bottom() + 1.0), Align2::CENTER_TOP, format!("{:.0}:{:02.0}", (t / 60.0).floor(), t % 60.0), FontId::proportional(TXT), AXIS_TXT); } } - let hist: Vec<_> = sim.signal.history.iter().collect(); - for (hi, h) in hist.iter().enumerate() { + // Each stage's green in its own color, then the yellow change and all-red clearance + // that end it. + for h in &sim.signal.history { if h.end < t0 { continue; } let y = plot.top() + h.stage as f32 * row_h; let a = tx(h.start.max(t0)); let b = tx(h.end); - p.rect_filled(Rect::from_min_max(pos2(a, y + 1.5), pos2(b.max(a + 1.0), y + row_h - 2.5)), 2.0, SERIES[h.stage % SERIES.len()]); - // Clearance gap (yellow + all-red) before the next stage. - if let Some(nx) = hist.get(hi + 1) && nx.start > h.end + 0.05 { - let ya = plot.top() + h.stage as f32 * row_h; - p.rect_filled(Rect::from_min_max(pos2(b, ya + 1.5), pos2(tx(nx.start).max(b + 1.0), ya + row_h - 2.5)), 1.0, Color32::from_rgb(230, 190, 40)); - } - } - if sim.signal.phase != crate::sim::signal::Phase::Green { - let y = plot.top() + sim.signal.stage as f32 * row_h; - let a = tx(sim.signal.history.back().map(|h| h.end).unwrap_or(t1)); - let col = if sim.signal.phase == crate::sim::signal::Phase::Yellow { Color32::from_rgb(230, 190, 40) } else { Color32::from_rgb(220, 60, 50) }; - p.rect_filled(Rect::from_min_max(pos2(a, y + 1.5), pos2(tx(t1).max(a + 1.0), y + row_h - 2.5)), 1.0, col); + let (col, round) = match h.phase { + Phase::Green => (SERIES[h.stage % SERIES.len()], 2.0), + Phase::Yellow => (Color32::from_rgb(230, 190, 40), 1.0), + Phase::AllRed => (Color32::from_rgb(220, 60, 50), 1.0), + }; + p.rect_filled(Rect::from_min_max(pos2(a, y + 1.5), pos2(b.max(a + 1.0), y + row_h - 2.5)), round, col); } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 7471c7f..3d5c0e3 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -257,10 +257,13 @@ impl App { let net = Network::build(self.layout); let demand = if keep_demand && old.net.kind == self.layout { old.demand.clone() } else { DemandConfig::default_for(&net) }; let template = Self::settings_of(old); - self.world = self.make_world(self.mode, self.ctrl, (self.coop_policy, self.coop_sched), demand, &template); + // Under the green wave, the controller to give back when it ends. + let ctrl = self.world.controller_before_wave(self.focus).unwrap_or(self.ctrl); + self.world = self.make_world(self.mode, ctrl, (self.coop_policy, self.coop_sched), demand, &template); // The view is centered on the middle of the corridor: start the panels there. self.focus = self.world.sims.len() / 2; self.rebuild_twin(); + self.sync_ctrl(); if self.scene.kind != self.layout { self.scene = Scene::build(&self.world.sims[0].net); } @@ -282,10 +285,23 @@ impl App { let src = &self.world.sims[0]; let template = Self::settings_of(src); let demand = src.demand.clone(); - self.twin = Some(self.make_world(self.twin_mode, self.twin_ctrl, (self.twin_policy, self.twin_sched), demand, &template)); + // B runs one controller everywhere; under the green wave, the one it ran before. + let ctrl = self.twin.as_ref().and_then(|t| t.controller_before_wave(0)).unwrap_or(self.twin_ctrl); + self.twin = Some(self.make_world(self.twin_mode, ctrl, (self.twin_policy, self.twin_sched), demand, &template)); self.twin_summary = Summary::default(); } + /// Show the controllers the focused intersection runs, on both sides: the focus may + /// have moved, and the green wave changes controllers. Whether anything changed. + fn sync_ctrl(&mut self) -> bool { + let before = (self.ctrl, self.twin_ctrl); + self.ctrl = self.world.sims[self.focus].ctrl_kind; + if let Some(t) = &self.twin { + self.twin_ctrl = t.sims[self.focus.min(t.sims.len() - 1)].ctrl_kind; + } + (self.ctrl, self.twin_ctrl) != before + } + fn settings_of(s: &Sim) -> Sim { // A throwaway carrier for the settings a rebuild keeps. let mut t = Sim::new(LayoutKind::Town4, ControlMode::AllWayStop, CtrlKind::Fixed, 0); @@ -446,12 +462,18 @@ impl App { self.spawn_kind = k; } } - let walk = !self.world.sims[self.focus].net.crosswalks.is_empty(); + let net = &self.world.sims[self.focus].net; + let walk = !net.crosswalks.is_empty(); + let no_walk = match net.kind { + LayoutKind::Cooperative => "No crosswalks here: pedestrians use underground passages", + LayoutKind::Roundabout => "No crosswalks here: the roundabout is simulated without pedestrians", + _ => "No crosswalks here", + }; let ped = ui .add_enabled_ui(walk, |ui| ui.selectable_label(self.tool == Tool::Pedestrian, "🚶 Pedestrian")) .inner .on_hover_text("Click near a crosswalk corner") - .on_disabled_hover_text("No crosswalks here: pedestrians use underground passages"); + .on_disabled_hover_text(no_walk); if ped.clicked() { self.tool = if self.tool == Tool::Pedestrian { Tool::Select } else { Tool::Pedestrian }; } @@ -523,7 +545,7 @@ impl App { let mut gw = self.green_wave; if ui .checkbox(&mut gw, "Fixed-time green wave") - .on_hover_text("Common cycle and offsets that maximize the two-way green band (applies to fixed-time signals)") + .on_hover_text("Every signal runs fixed-time, with a common cycle and offsets that maximize the two-way green band. Turning it off gives each signal back the controller it ran before.") .changed() { self.green_wave = gw; @@ -531,6 +553,7 @@ impl App { if let Some(t) = self.twin.as_mut() { t.set_coordination(gw); } + self.notify(if gw { "Green wave on: every signal runs fixed-time" } else { "Green wave off: the signals run their previous controllers again" }); } } let mut mode = self.mode; @@ -565,7 +588,7 @@ impl App { if c != self.ctrl { // Hot-swap: traffic keeps flowing, only the brain changes. self.ctrl = c; - self.world.sims[self.focus].set_controller(c); + self.world.set_controller(self.focus, c); self.notify(format!("Controller switched live to {}", c.name())); } ui.label(RichText::new(self.ctrl.description()).small().color(Color32::from_gray(170))); @@ -662,8 +685,8 @@ impl App { } else if c != self.twin_ctrl { self.twin_ctrl = c; if let Some(t) = self.twin.as_mut() { - for s in &mut t.sims { - s.set_controller(c); + for i in 0..t.sims.len() { + t.set_controller(i, c); } } } @@ -842,7 +865,9 @@ impl App { if !WEB && ui.button("💾 Save weights").clicked() { let p = crate::control::hybrid::weights_path(layout.key()); std::fs::create_dir_all(p.parent().unwrap()).ok(); - saved = Some(h.save(&p, f64::NAN, true).map(|_| p.display().to_string())); + // The app measures no validation score. The network is saved only + // if it is choosing the tunings (trained or learning). + saved = Some(h.save(&p, None, h.has_model || h.learning).map(|_| p.display().to_string())); } } if let Some(r) = saved { @@ -1280,9 +1305,10 @@ impl App { let st = &c.stats; let ok = Color32::from_rgb(110, 220, 130); let flag = |n: u64| if n == 0 { ok } else { Color32::from_rgb(240, 90, 90) }; + let (margin, margin_ok) = margin_kpi(st.margin_min); egui::Grid::new("coop_safety").num_columns(3).spacing(vec2(16.0, 6.0)).show(ui, |ui| { Self::kpi(ui, "closest crossing (PET)", if st.pet_min.is_finite() { format!("{:.2} s", st.pet_min) } else { "–".into() }, Color32::WHITE); - Self::kpi(ui, "margin over gap", if st.margin_min.is_finite() { format!("{:+.2} s", st.margin_min.max(0.0)) } else { "–".into() }, if st.margin_min > -1e-6 { ok } else { Color32::from_rgb(240, 90, 90) }); + Self::kpi(ui, "margin over gap", margin, if margin_ok { ok } else { Color32::from_rgb(240, 90, 90) }); Self::kpi(ui, "zone overlaps", st.zone_violations.to_string(), flag(st.zone_violations)); ui.end_row(); Self::kpi(ui, "entries without slot", st.unauthorized.to_string(), flag(st.unauthorized)); @@ -1711,6 +1737,16 @@ impl App { } } +/// The safety monitors' smallest margin over the required gap, with its sign, and +/// whether every crossing kept its gap. Rounding error just below zero counts as zero. +fn margin_kpi(margin: f64) -> (String, bool) { + if !margin.is_finite() { + return ("–".into(), true); + } + let ok = margin > -1e-6; + (format!("{:+.2} s", if ok { margin.max(0.0) } else { margin }), ok) +} + impl eframe::App for App { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); @@ -1734,6 +1770,11 @@ impl eframe::App for App { egui::Panel::right("right").default_size(380.0).resizable(true).show(ui, |ui| self.right_panel(ui)); egui::Panel::bottom("bottom").show(ui, |ui| self.bottom_panel(ui)); egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| self.canvas(ui)); + // Panels drawn before a change of focus or controller this frame showed the old + // controller: draw again. + if self.sync_ctrl() { + ctx.request_repaint(); + } self.bench_window(&ctx); self.about_window(&ctx); if let Some((path, after)) = self.shot.clone() && self.world.sims[self.focus].t >= after { @@ -1746,3 +1787,16 @@ impl eframe::App for App { } } } + +#[cfg(test)] +mod tests { + use super::margin_kpi; + + #[test] + fn margin_over_gap_keeps_its_sign() { + assert_eq!(margin_kpi(0.437), ("+0.44 s".to_string(), true)); + assert_eq!(margin_kpi(-1e-9), ("+0.00 s".to_string(), true)); + assert_eq!(margin_kpi(-0.25), ("-0.25 s".to_string(), false)); + assert_eq!(margin_kpi(f64::INFINITY), ("–".to_string(), true)); + } +} diff --git a/src/sim/signal.rs b/src/sim/signal.rs index 1b7ca1f..6659f2a 100644 --- a/src/sim/signal.rs +++ b/src/sim/signal.rs @@ -46,13 +46,20 @@ pub struct CwSignal { pub called: bool, } +/// One interval of the signal's history: a stage's green, or the yellow change or the +/// all-red clearance that ends it. #[derive(Clone, Debug)] pub struct StageSpan { pub stage: usize, + pub phase: Phase, pub start: f64, pub end: f64, } +/// Intervals the history keeps (up to three per stage change): far more than the +/// 3-minute timing chart shows. +const HISTORY_LEN: usize = 1200; + #[derive(Clone, Debug)] pub struct SignalState { pub stage: usize, @@ -105,10 +112,22 @@ impl SignalState { lpi_hold: vec![false; net.movements.len()], no_late_walk: false, }; - s.history.push_back(StageSpan { stage: first, start: t, end: t }); + s.history.push_back(StageSpan { stage: first, phase: Phase::Green, start: t, end: t }); s } + /// End the interval the history is recording at `t` and start one for `phase` of + /// the current stage. + fn record(&mut self, phase: Phase, t: f64) { + if let Some(h) = self.history.back_mut() { + h.end = t; + } + self.history.push_back(StageSpan { stage: self.stage, phase, start: t, end: t }); + while self.history.len() > HISTORY_LEN { + self.history.pop_front(); + } + } + pub fn status(&self, net: &Network, m: usize) -> Sig { if net.stages.is_empty() { return Sig::G; @@ -181,15 +200,13 @@ impl SignalState { if any { self.phase = Phase::Yellow; self.t_phase = 0.0; + self.record(Phase::Yellow, t); } else { self.start_green(net, t); } } fn start_green(&mut self, net: &Network, t: f64) { - if let Some(h) = self.history.back_mut() { - h.end = t; - } let prev_stage = self.stage; self.stage = self.next; self.phase = Phase::Green; @@ -197,10 +214,7 @@ impl SignalState { self.green_elapsed = 0.0; self.cur_min = self.min_green; let was_departing = std::mem::replace(&mut self.departing, vec![false; net.movements.len()]); - self.history.push_back(StageSpan { stage: self.stage, start: t, end: t }); - while self.history.len() > 400 { - self.history.pop_front(); - } + self.record(Phase::Green, t); let prev: Vec = self.cw.iter().map(|c| c.state).collect(); self.serve_peds(net); // LPI applies only to WALK that starts with the stage (never mid-green), and @@ -256,6 +270,7 @@ impl SignalState { if self.t_phase >= self.yellow { self.phase = Phase::AllRed; self.t_phase = 0.0; + self.record(Phase::AllRed, t); } } Phase::AllRed => { @@ -284,3 +299,41 @@ impl SignalState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::control::needs_clearance; + use crate::sim::network::LayoutKind; + + /// The history records the yellow change and all-red clearance between two greens, + /// so the signal timing chart can show them. + #[test] + fn history_records_the_clearance() { + let net = Network::build(LayoutKind::Arterial4); + let dt = 0.1; + let mut s = SignalState::new(&net, 0.0); + let mut t = 0.0; + let mut run = |s: &mut SignalState, secs: f64| { + for _ in 0..(secs / dt).round() as usize { + t += dt; + s.step(&net, dt, t); + } + t + }; + let t_req = run(&mut s, 10.0); + let (a, b) = (s.stage, (0..net.stages.len()).find(|&j| needs_clearance(&net, s.stage, j)).unwrap()); + s.request(&net, b, t_req); + let t_end = run(&mut s, net.yellow + net.all_red + 5.0); + assert_eq!((s.stage, s.phase), (b, Phase::Green)); + + let spans: Vec<(usize, Phase)> = s.history.iter().map(|h| (h.stage, h.phase)).collect(); + assert_eq!(spans, [(a, Phase::Green), (a, Phase::Yellow), (a, Phase::AllRed), (b, Phase::Green)]); + let h = &s.history; + // One step's rounding is allowed on each interval's end. + let close = |x: f64, y: f64| (x - y).abs() <= dt + 1e-9; + assert!(close(h[0].end, t_req) && close(h[1].end - h[1].start, net.yellow) && close(h[2].end - h[2].start, net.all_red)); + assert!(h.iter().zip(h.iter().skip(1)).all(|(x, y)| x.end == y.start), "gaps between intervals"); + assert_eq!((h[0].start, h[3].end), (0.0, t_end)); + } +} diff --git a/src/sim/world.rs b/src/sim/world.rs index 6c8ffda..62efbb5 100644 --- a/src/sim/world.rs +++ b/src/sim/world.rs @@ -54,12 +54,14 @@ pub struct World { pub journeys: Vec, /// Fixed-time intersections run a coordinated (green wave) plan. pub coordinated: bool, + /// Each intersection's controller from before the green wave made it fixed-time. + before_wave: Vec>, rng: Rng, } impl World { pub fn single(sim: Sim) -> World { - World { sims: vec![sim], offsets: vec![v2(0.0, 0.0)], journeys: vec![], coordinated: false, rng: Rng::new(1) } + World { sims: vec![sim], offsets: vec![v2(0.0, 0.0)], journeys: vec![], coordinated: false, before_wave: vec![], rng: Rng::new(1) } } /// `n` intersections of `kind` along the east-west main road. Interior approaches @@ -85,6 +87,7 @@ impl World { sims, journeys: vec![], coordinated: false, + before_wave: vec![], rng: Rng::new(seed ^ 0xC0FF_EE00), } } @@ -268,16 +271,22 @@ impl World { self.sims.iter().map(|s| s.stats.summary(from_t, s.t, &s.vehicles, &s.queues).person_delay_all).sum::() / k } - /// Turn the fixed-time green wave on or off. A common cycle (the longest Webster - /// cycle), the main-road through stage first in each cycle, and offsets chosen - /// greedily to maximize the two-way green band at progression speed. + /// Turn the fixed-time green wave on or off. On, every signal runs fixed-time: a + /// common cycle (the longest Webster cycle), the main-road through stage first in + /// each cycle, and offsets chosen greedily to maximize the two-way green band at + /// progression speed. Off, each signal the wave made fixed-time gets back the + /// controller it ran before, and fixed-time signals return to their own Webster plans. pub fn set_coordination(&mut self, on: bool) { + if on && !self.coordinated { + self.before_wave = self.sims.iter().map(|s| s.signalized().then_some(s.ctrl_kind)).collect(); + } self.coordinated = on; if !on || !self.is_corridor() { - for s in &mut self.sims { + let before = std::mem::take(&mut self.before_wave); + for (i, s) in self.sims.iter_mut().enumerate() { s.coordination = None; if s.ctrl_kind == CtrlKind::Fixed && s.signalized() { - s.set_controller(CtrlKind::Fixed); + s.set_controller(before.get(i).copied().flatten().unwrap_or(CtrlKind::Fixed)); } } return; @@ -347,6 +356,20 @@ impl World { } } + /// Switch intersection `i` to `ctrl`, which it keeps when the green wave ends. + pub fn set_controller(&mut self, i: usize, ctrl: CtrlKind) { + self.sims[i].set_controller(ctrl); + if let Some(b) = self.before_wave.get_mut(i) { + *b = None; + } + } + + /// The controller intersection `i` ran before the green wave made it fixed-time, + /// which it gets back when the wave ends. + pub fn controller_before_wave(&self, i: usize) -> Option { + self.before_wave.get(i).copied().flatten() + } + /// Copy the user-adjustable settings (sensors, operations, demand) of intersection /// `from` to every other intersection. pub fn sync_settings(&mut self, from: usize) { diff --git a/tests/green_wave.rs b/tests/green_wave.rs new file mode 100644 index 0000000..af868ac --- /dev/null +++ b/tests/green_wave.rs @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Jim Schreckengast +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! The corridor's green wave runs every signal fixed-time. Turning it off gives each +//! signal back the controller it ran before, unless a new one was chosen meanwhile. + +use intersection_lab::control::CtrlKind; +use intersection_lab::sim::ControlMode; +use intersection_lab::sim::demand::DemandConfig; +use intersection_lab::sim::network::{LayoutKind, Network}; +use intersection_lab::sim::world::World; + +fn corridor(n: usize) -> World { + let kind = LayoutKind::Arterial4; + let mut w = World::corridor(n, kind, ControlMode::Signal, CtrlKind::Predictive, &DemandConfig::default_for(&Network::build(kind)), 1); + for s in &mut w.sims { + s.check_safety = true; + } + w +} + +fn controllers(w: &World) -> Vec { + w.sims.iter().map(|s| s.ctrl_kind).collect() +} + +#[test] +fn the_green_wave_gives_back_the_controllers() { + let mut w = corridor(4); + w.set_controller(2, CtrlKind::Actuated); + w.set_controller(3, CtrlKind::Fixed); + w.set_coordination(true); + assert_eq!(controllers(&w), [CtrlKind::Fixed; 4]); + assert!(w.sims.iter().all(|s| s.coordination.is_some())); + assert_eq!(w.controller_before_wave(2), Some(CtrlKind::Actuated)); + + // Chosen while the wave runs: kept when it ends. + w.set_controller(1, CtrlKind::MaxPressure); + w.set_controller(2, CtrlKind::Fixed); + assert_eq!(w.controller_before_wave(2), None); + w.run_for(30.0); + w.set_coordination(false); + assert_eq!(controllers(&w), [CtrlKind::Predictive, CtrlKind::MaxPressure, CtrlKind::Fixed, CtrlKind::Fixed]); + assert!(w.sims.iter().all(|s| s.coordination.is_none())); + w.run_for(30.0); + assert_eq!(w.sims.iter().map(|s| s.collisions).sum::(), 0); + + // A second wave starts from the controllers running now. + w.set_coordination(true); + w.set_coordination(false); + assert_eq!(controllers(&w), [CtrlKind::Predictive, CtrlKind::MaxPressure, CtrlKind::Fixed, CtrlKind::Fixed]); +} + +#[test] +fn a_single_intersection_keeps_its_controller() { + let mut w = corridor(1); + w.set_coordination(true); + assert_eq!(controllers(&w), [CtrlKind::Predictive]); + assert!(w.sims[0].coordination.is_none()); +} diff --git a/tests/weights_files.rs b/tests/weights_files.rs index a2c705f..b14784f 100644 --- a/tests/weights_files.rs +++ b/tests/weights_files.rs @@ -3,16 +3,33 @@ //! Weights files are read from disk, where they can be damaged or edited by hand. The //! loaders must reject a network that doesn't fit its controller, and nothing an -//! accepted network computes may crash the app. +//! accepted network computes may crash the app. What the app and the trainer save must +//! load again, and a copy that doesn't load mustn't hide one that does. use intersection_lab::control::hybrid::{Hybrid, N_ACTIONS}; use intersection_lab::control::rl::{RlController, n_inputs}; -use intersection_lab::control::{Controller, CtrlKind}; +use intersection_lab::control::{Controller, CtrlKind, weights}; use intersection_lab::sim::network::{LayoutKind, Network}; use intersection_lab::sim::{ControlMode, Sim}; use serde_json::{Value, json}; +use std::path::PathBuf; const DQN_TEE: &str = include_str!("../weights/dqn_tee.json"); +const HYBRID_TEE: &str = include_str!("../weights/hybrid_tee.json"); + +/// An empty directory for this test's files. +fn scratch(name: &str) -> PathBuf { + let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// What the app says about where the hybrid's tuning comes from. +fn status(mut h: Hybrid) -> String { + h.loaded_from = Some("a weights file".into()); + h.brain().notes.join("\n") +} /// The shipped tee-intersection network with `edit` applied to its layers. fn edited(edit: impl FnOnce(&mut Vec)) -> String { @@ -91,6 +108,54 @@ fn hybrid_without_a_fitting_network_keeps_its_base_tuning() { let h = Hybrid::from_saved(&net, &hybrid_file(&net, json!([]))).unwrap(); assert!(!h.has_model); assert_eq!(h.base_action, 10); + // The app says the network was left out, not that training found none worth keeping. + let notes = status(h); + assert!(notes.contains("network doesn't fit"), "{notes}"); + assert!(!notes.contains("no network beat"), "{notes}"); + + let no_network = json!({ "base_action": 10, "base_score": 0.0, "model": null }).to_string(); + let notes = status(Hybrid::from_saved(&net, &no_network).unwrap()); + assert!(notes.contains("no network beat the best static tuning"), "{notes}"); +} + +#[test] +fn hybrid_files_with_a_non_finite_score_load() { + let net = Network::build(LayoutKind::Tee); + let dir = scratch("non_finite_score"); + let mut h = Hybrid::new(&net, 1); + h.base_action = 7; + // The app knows no score; training has none when no tuning meets its pedestrian limit. + for (i, score) in [None, Some(f64::NAN), Some(f64::INFINITY), Some(f64::NEG_INFINITY)].into_iter().enumerate() { + for with_model in [false, true] { + let path = dir.join(format!("hybrid_{i}_{with_model}.json")); + h.save(&path, score, with_model).unwrap(); + let saved = std::fs::read_to_string(&path).unwrap(); + let back = Hybrid::from_saved(&net, &saved).unwrap_or_else(|| panic!("saved with score {score:?}, doesn't load: {saved:.80}")); + assert_eq!((back.base_action, back.has_model), (7, with_model)); + } + } + // What version 0.5.1 wrote for such a score. + let old = json!({ "base_action": 7, "base_score": null, "model": null }).to_string(); + assert_eq!(Hybrid::from_saved(&net, &old).unwrap().base_action, 7); +} + +#[test] +fn a_weights_file_that_does_not_load_is_skipped() { + let net = Network::build(LayoutKind::Tee); + let load = |dirs: &[PathBuf]| weights::load_from(dirs, "hybrid_tee.json", |txt| Hybrid::from_saved(&net, txt)).unwrap(); + let (bad, good) = (scratch("skip_bad"), scratch("skip_good")); + std::fs::write(bad.join("hybrid_tee.json"), r#"{"base_action": 3, "base_sc"#).unwrap(); + let mut h = Hybrid::new(&net, 1); + h.base_action = 5; + h.save(&good.join("hybrid_tee.json"), None, false).unwrap(); + + let (h, source) = load(&[bad.clone(), good.clone()]); + assert_eq!(h.base_action, 5); + assert_eq!(source, good.join("hybrid_tee.json").display().to_string()); + // With no copy on disk that loads, the built-in one is used. + let (h, source) = load(&[bad]); + assert_eq!(source, "built-in hybrid_tee.json"); + assert_eq!(h.base_action, Hybrid::from_saved(&net, HYBRID_TEE).unwrap().base_action); } #[test]