Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 98 additions & 71 deletions docs/architecture.md

Large diffs are not rendered by default.

Binary file modified docs/images/architecture/timeline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions src/bin/train.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 16 additions & 8 deletions src/control/hybrid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>,
pub model: Option<SavedModel>,
}

Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand All @@ -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<Self> {
let sv = serde_json::from_str::<SavedHybrid>(txt).ok()?;
let mut h = match sv.model {
Expand All @@ -142,21 +147,23 @@ 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<f64>, with_model: bool) -> std::io::Result<()> {
let model = with_model.then(|| SavedModel {
layout: self.layout.clone(),
n_in: self.agent.online.n_in(),
n_out: self.agent.online.n_out(),
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())
}

Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 1 addition & 3 deletions src/control/rl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
33 changes: 21 additions & 12 deletions src/control/weights.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)] = &[
Expand Down Expand Up @@ -38,16 +39,24 @@ pub fn user_dir() -> Option<PathBuf> {
}
}

/// 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<T>(file: &str, parse: impl FnMut(&str) -> Option<T>) -> Option<(T, String)> {
let dirs: Vec<PathBuf> = 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<T>(dirs: &[impl AsRef<Path>], file: &str, mut parse: impl FnMut(&str) -> Option<T>) -> 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).
Expand Down
24 changes: 10 additions & 14 deletions src/gui/charts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down Expand Up @@ -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);
}
}

Expand Down
74 changes: 64 additions & 10 deletions src/gui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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);
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -523,14 +545,15 @@ 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;
self.world.set_coordination(gw);
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;
Expand Down Expand Up @@ -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)));
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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();
Expand All @@ -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 {
Expand All @@ -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));
}
}
Loading
Loading