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
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ train command and its TEST lines. Otherwise, delete this section. -->
## Checklist

- [ ] `cargo test --locked --release` passes (zero vehicle overlaps)
- [ ] The code is formatted with `cargo fmt --all`
- [ ] `cargo clippy --locked --release --all-targets -- -D warnings` is clean (and for `wasm32-unknown-unknown` if the app changed)
- [ ] New functionality has tests, and a bug fix has a test that fails without it (see [CONTRIBUTING.md](https://github.com/schreck61/intersection-lab/blob/main/CONTRIBUTING.md#guidelines))
- [ ] Runs stay deterministic for a given seed
Expand Down
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Copyright (c) 2026 Jim Schreckengast
# SPDX-License-Identifier: MIT OR Apache-2.0
#
# Build and test on every platform, build the browser version, lint with clippy, build
# the crate from the files crates.io would get, check licenses, advisories and
# attributions, and fuzz the loading of weights files.
# Build and test on every platform, build the browser version, check formatting and lint
# with clippy, build the crate from the files crates.io would get, check licenses,
# advisories and attributions, and fuzz the loading of weights files.
name: CI

on:
Expand Down Expand Up @@ -56,8 +56,12 @@ jobs:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: clippy
components: clippy, rustfmt
targets: wasm32-unknown-unknown
# Formatting (see rustfmt.toml): the crate, then the fuzz target, which is a
# workspace of its own.
- run: cargo fmt --all --check
- run: cargo fmt --manifest-path fuzz/Cargo.toml --check
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- run: cargo clippy --locked --release --all-targets -- -D warnings
- run: cargo clippy --locked --release --target wasm32-unknown-unknown --bin intersection-lab -- -D warnings
Expand Down
12 changes: 9 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ trunk serve --release --open
CI runs these on every pull request. Running them locally first saves a round trip:

```bash
cargo fmt --all --check
cargo build --locked --release --all-targets
cargo test --locked --release
cargo clippy --locked --release --all-targets -- -D warnings
Expand Down Expand Up @@ -106,9 +107,14 @@ pass that file's path in place of the two folders.
* **Few dependencies.** The project builds for desktop and the browser from one codebase
and implements its own neural network and simulation, so new crates need a good reason.
They must use a license allowed in [deny.toml](deny.toml).
* **Style:** match the surrounding code; the project doesn't run `rustfmt`, so please
don't reformat code you aren't changing. New source files start with the same
copyright and `SPDX-License-Identifier: MIT OR Apache-2.0` header as the others.
* **Style:** the code follows the
[Rust Style Guide](https://doc.rust-lang.org/style-guide/) as rustfmt applies it
(with the settings in [rustfmt.toml](rustfmt.toml)) and passes Clippy's default
lints. Run `cargo fmt --all` before you commit, and
`cargo fmt --manifest-path fuzz/Cargo.toml` if you changed the fuzz target. CI fails
a pull request that isn't formatted or that Clippy warns about. New source files
start with the same copyright and `SPDX-License-Identifier: MIT OR Apache-2.0` header
as the others.
* **Spelling:** use American spelling in code, comments, documentation and UI text.
* **Pull requests:** keep each one focused on a single change, explain what it does and
why, and note anything you couldn't test (for example a platform you don't have).
Expand Down
4 changes: 2 additions & 2 deletions GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ This document describes how decisions are made and who does what.
A change that lets vehicles collide isn't accepted, however much it helps delay.
* **Through pull requests.** Every change to `main`, including the maintainer's own,
goes through a pull request whose required checks pass: the tests on Linux, macOS and
Windows, the browser build, Clippy, the crate package, the license and advisory
checks, CodeQL, and DCO sign-off. Nobody can push to `main` directly.
Windows, the browser build, rustfmt and Clippy, the crate package, the license and
advisory checks, CodeQL, and DCO sign-off. Nobody can push to `main` directly.
* **With reasons.** The maintainer reviews contributions and decides whether to merge
them, and explains the reasons when declining a change or asking for a different
approach. If you disagree with a decision, say so in the issue or pull request.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -3143,7 +3143,7 @@ date:
|---|---|---|
| Test (ubuntu-22.04), Test (macos-15), Test (windows-latest) | `ci.yml` | `cargo build --locked --release --all-targets`, then `cargo test --locked --release` |
| Web (wasm32) | `ci.yml` | Builds the app for `wasm32-unknown-unknown` |
| Clippy | `ci.yml` | `cargo clippy` with warnings as errors, for the native targets and the browser app |
| Clippy | `ci.yml` | `cargo fmt --check` on the crate and the fuzz target, then `cargo clippy` with warnings as errors, for the native targets and the browser app |
| Crate package | `ci.yml` | `cargo package --locked`, which builds the crate from the files crates.io would get |
| Licenses and advisories | `ci.yml` | `cargo deny check` (licenses, advisories, bans and sources), and that `THIRD_PARTY_LICENSES.md` is up to date |
| Analyze (rust), Analyze (actions) | `codeql.yml` | CodeQL on the Rust code and on the workflows; also every Monday |
Expand Down
16 changes: 14 additions & 2 deletions examples/cooperative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,18 @@ fn main() {
);
println!(
"{:>9} {:>9} {:>9} {:>8} {:>8} {:>8} {:>7} {:>8} {:>8} {:>6} {:>6} {:>6}",
"demand", "arrived", "served", "box/h", "delay", "p95", "maxwait", "queued", "PETmin", "viol", "dev", "overlap"
"demand",
"arrived",
"served",
"box/h",
"delay",
"p95",
"maxwait",
"queued",
"PETmin",
"viol",
"dev",
"overlap"
);
let demands: Vec<f64> = match arg(6) {
a if !a.is_empty() => a.split(',').filter_map(|x| x.parse().ok()).collect(),
Expand All @@ -73,7 +84,8 @@ fn main() {
if cars {
demand.mix = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0];
}
let mut sim = Sim::with_demand(net, ControlMode::Cooperative, CtrlKind::Fixed, demand, 7000 + seed);
let mut sim =
Sim::with_demand(net, ControlMode::Cooperative, CtrlKind::Fixed, demand, 7000 + seed);
sim.set_gap_policy(policy);
sim.set_scheduling(scheduling);
sim.check_safety = true;
Expand Down
24 changes: 20 additions & 4 deletions examples/corridor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@
//!
//! usage: corridor [layout] [n] [minutes] [seeds] [demand-scale]
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;
use intersection_lab::sim::ControlMode;

fn main() {
let args: Vec<String> = std::env::args().collect();
let kind = args.get(1).and_then(|k| LayoutKind::ALL.into_iter().find(|x| x.key() == k)).unwrap_or(LayoutKind::Arterial4);
let kind = args
.get(1)
.and_then(|k| LayoutKind::ALL.into_iter().find(|x| x.key() == k))
.unwrap_or(LayoutKind::Arterial4);
let n: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(3);
let minutes: f64 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(20.0);
let seeds: u64 = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(4);
Expand All @@ -33,7 +36,17 @@ fn main() {
];
println!(
"=== Corridor: {} x {} | {:.0} min x {} seeds ===\n{:<32} {:>10} {:>9} {:>10} {:>10} {:>10} {:>6}",
n, kind.name(), minutes, seeds, "control", "persDly*", "thru n", "thru time", "thru stops", "thru dly", "coll"
n,
kind.name(),
minutes,
seeds,
"control",
"persDly*",
"thru n",
"thru time",
"thru stops",
"thru dly",
"coll"
);
let handles: Vec<_> = runs
.into_iter()
Expand Down Expand Up @@ -66,6 +79,9 @@ fn main() {
.collect();
for h in handles {
let (name, a) = h.join().unwrap();
println!("{:<32} {:>9.1}s {:>9.0} {:>9.1}s {:>10.2} {:>9.1}s {:>6.0}", name, a[0], a[1], a[2], a[3], a[4], a[5]);
println!(
"{:<32} {:>9.1}s {:>9.0} {:>9.1}s {:>10.2} {:>9.1}s {:>6.0}",
name, a[0], a[1], a[2], a[3], a[4], a[5]
);
}
}
24 changes: 20 additions & 4 deletions examples/fya.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ use intersection_lab::sim::{ControlMode, LeftMode, Sim};

fn main() {
let seeds: u64 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(6);
println!("{:<11} {:<11} {:>5} {:<30}| {:>12} {:>10} {:>9}", "layout", "controller", "scale", "left turns", "person delay", "left delay", "protected");
println!(
"{:<11} {:<11} {:>5} {:<30}| {:>12} {:>10} {:>9}",
"layout", "controller", "scale", "left turns", "person delay", "left delay", "protected"
);
for kind in [LayoutKind::Arterial4, LayoutKind::MajorMinor4] {
for ctrl in [CtrlKind::Fixed, CtrlKind::Actuated, CtrlKind::Predictive] {
for scale in [0.7, 1.0, 1.3] {
Expand All @@ -24,8 +27,12 @@ fn main() {
let t0 = sim.t;
sim.run_for(1800.0);
let s = sim.stats.summary(t0, sim.t, &sim.vehicles, &sim.queues);
let lefts: Vec<usize> = (0..sim.net.movements.len()).filter(|&m| sim.net.movements[m].turn == Turn::L).collect();
let (ld, ln) = lefts.iter().fold((0.0, 0), |a, &m| (a.0 + s.by_move[m].delay, a.1 + s.by_move[m].n));
let lefts: Vec<usize> = (0..sim.net.movements.len())
.filter(|&m| sim.net.movements[m].turn == Turn::L)
.collect();
let (ld, ln) = lefts
.iter()
.fold((0.0, 0), |a, &m| (a.0 + s.by_move[m].delay, a.1 + s.by_move[m].n));
let prot = sim.left_protected.iter().filter(|&&p| p).count();
(s.person_delay_all, ld / ln.max(1) as f64, prot as f64)
})
Expand All @@ -34,7 +41,16 @@ fn main() {
let r: Vec<(f64, f64, f64)> = jobs.into_iter().map(|j| j.join().unwrap()).collect();
let n = seeds as f64;
let m = |f: fn(&(f64, f64, f64)) -> f64| r.iter().map(f).sum::<f64>() / n;
println!("{:<11} {:<11} {:>5} {:<30}| {:>11.1}s {:>9.1}s {:>9.1}", kind.key(), ctrl.short(), scale, mode.name(), m(|x| x.0), m(|x| x.1), m(|x| x.2));
println!(
"{:<11} {:<11} {:>5} {:<30}| {:>11.1}s {:>9.1}s {:>9.1}",
kind.key(),
ctrl.short(),
scale,
mode.name(),
m(|x| x.0),
m(|x| x.1),
m(|x| x.2)
);
}
}
}
Expand Down
23 changes: 20 additions & 3 deletions examples/lpi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@ use intersection_lab::sim::{ControlMode, Sim};

fn main() {
let seeds: u64 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(8);
println!("{:<11} {:<11} {:>6} {:<14}| {:>11} {:>9} {:>12}", "layout", "controller", "peds/h", "treatment", "near-miss/h", "ped wait", "person delay");
println!(
"{:<11} {:<11} {:>6} {:<14}| {:>11} {:>9} {:>12}",
"layout", "controller", "peds/h", "treatment", "near-miss/h", "ped wait", "person delay"
);
for kind in [LayoutKind::Arterial4, LayoutKind::Town4] {
for ctrl in [CtrlKind::Fixed, CtrlKind::Predictive] {
for ped in [60.0, 240.0] {
for (name, lpi, ntor) in [("none", 0.0, false), ("LPI 4 s", 4.0, false), ("NTOR w/ peds", 0.0, true), ("both", 4.0, true)] {
for (name, lpi, ntor) in [
("none", 0.0, false),
("LPI 4 s", 4.0, false),
("NTOR w/ peds", 0.0, true),
("both", 4.0, true),
] {
let jobs: Vec<_> = (0..seeds)
.map(|seed| {
std::thread::spawn(move || {
Expand All @@ -33,7 +41,16 @@ fn main() {
let r: Vec<(f64, f64, f64)> = jobs.into_iter().map(|j| j.join().unwrap()).collect();
let n = seeds as f64;
let m = |f: fn(&(f64, f64, f64)) -> f64| r.iter().map(f).sum::<f64>() / n;
println!("{:<11} {:<11} {:>6} {:<14}| {:>11.1} {:>8.1}s {:>11.1}s", kind.key(), ctrl.short(), ped, name, m(|x| x.0), m(|x| x.1), m(|x| x.2));
println!(
"{:<11} {:<11} {:>6} {:<14}| {:>11.1} {:>8.1}s {:>11.1}s",
kind.key(),
ctrl.short(),
ped,
name,
m(|x| x.0),
m(|x| x.1),
m(|x| x.2)
);
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion examples/make_icons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ fn shade(x: f32, y: f32) -> Rgba {
let road_w = 0.115;
let in_v = (x - 0.5).abs() < road_w;
let in_h = (y - 0.5).abs() < road_w;
let curb = ((x - 0.5).abs() - road_w).abs() < 0.012 && !in_h || ((y - 0.5).abs() - road_w).abs() < 0.012 && !in_v;
let curb = ((x - 0.5).abs() - road_w).abs() < 0.012 && !in_h
|| ((y - 0.5).abs() - road_w).abs() < 0.012 && !in_v;
if curb {
c = rgb(214, 214, 206);
}
Expand Down
23 changes: 18 additions & 5 deletions examples/satflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ use intersection_lab::sim::{ControlMode, Sim};
use std::collections::HashMap;

fn main() {
for (label, mix) in [("cars only", [1.0, 0.0, 0.0, 0.0, 0.0, 0.0]), ("default mix", [0.62, 0.24, 0.05, 0.03, 0.04, 0.0])] {
for (label, mix) in
[("cars only", [1.0, 0.0, 0.0, 0.0, 0.0, 0.0]), ("default mix", [0.62, 0.24, 0.05, 0.03, 0.04, 0.0])]
{
let net = Network::build(LayoutKind::Arterial4);
let mut d = DemandConfig::default_for(&net);
// Saturate the eastbound through lanes; nothing else.
Expand All @@ -19,7 +21,10 @@ fn main() {
d.mix = mix;
d.ped_rate = 0.0;
let mut sim = Sim::with_demand(net, ControlMode::Signal, CtrlKind::Fixed, d, 1);
let m = sim.net.movement_for(sim.net.leg_index(intersection_lab::sim::network::Compass::W).unwrap(), Turn::T).unwrap();
let m = sim
.net
.movement_for(sim.net.leg_index(intersection_lab::sim::network::Compass::W).unwrap(), Turn::T)
.unwrap();
let mut prev_ri: HashMap<u64, usize> = HashMap::new();
let mut green_start = None;
// (lane, position in queue discharge, time since green)
Expand All @@ -40,13 +45,19 @@ fn main() {
}
for v in &sim.vehicles {
let p = prev_ri.insert(v.id, v.ri).unwrap_or(0);
if p == 0 && v.ri >= 1 && v.movement == m && let Some(g) = green_start {
if p == 0
&& v.ri >= 1
&& v.movement == m
&& let Some(g) = green_start
{
let lane = v.route[0];
let n = count.entry(lane).or_insert(0);
*n += 1;
let since = sim.t - g;
if *n > 4 {
if let Some(&t0) = last_cross.get(&lane) && (v.queue_since.is_some() || v.stops > 0) {
if let Some(&t0) = last_cross.get(&lane)
&& (v.queue_since.is_some() || v.stops > 0)
{
headways.push(sim.t - t0);
}
} else if *n == 4 {
Expand All @@ -69,5 +80,7 @@ fn main() {
headways.len()
);
}
println!("HCM reference: base saturation flow 1,900 pc/h/ln (headway 1.89 s), start-up lost time ~2 s; heavy vehicles reduce flow ~1-2% per 1% trucks.");
println!(
"HCM reference: base saturation flow 1,900 pc/h/ln (headway 1.89 s), start-up lost time ~2 s; heavy vehicles reduce flow ~1-2% per 1% trucks."
);
}
20 changes: 16 additions & 4 deletions examples/sweep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ fn main() {
let mut jobs = vec![];
for kind in LayoutKind::ALL {
for mode in ControlMode::options(kind) {
let ctrls: Vec<CtrlKind> = if mode == ControlMode::Signal { CtrlKind::ALL[..4].to_vec() } else { vec![CtrlKind::Fixed] };
let ctrls: Vec<CtrlKind> =
if mode == ControlMode::Signal { CtrlKind::ALL[..4].to_vec() } else { vec![CtrlKind::Fixed] };
for c in ctrls {
jobs.push(std::thread::spawn(move || {
let mut total = 0; let mut first = String::new();
let mut total = 0;
let mut first = String::new();
for seed in 0..seeds {
let mut sim = Sim::new(kind, mode, c, 2000 + seed);
sim.check_safety = true;
Expand All @@ -38,14 +40,24 @@ fn main() {
}
sim.run_for(1500.0);
total += sim.collisions;
if first.is_empty() && let Some(l) = sim.collision_log.first() { first = l.clone(); }
if first.is_empty()
&& let Some(l) = sim.collision_log.first()
{
first = l.clone();
}
}
(format!("{:<12} {:<28} {:<12}", kind.key(), mode.name(), c.short()), total, first)
}));
}
}
}
let mut sum = 0;
for j in jobs { let (n, t, f) = j.join().unwrap(); sum += t; if t > 0 { println!("{n} {t:>4} {f}"); } }
for j in jobs {
let (n, t, f) = j.join().unwrap();
sum += t;
if t > 0 {
println!("{n} {t:>4} {f}");
}
}
println!("TOTAL overlap ticks: {sum}");
}
9 changes: 8 additions & 1 deletion fuzz/fuzz_targets/weights.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,14 @@ fuzz_target!(|data: &[u8]| {
/// One training update, as online learning makes once its replay memory fills.
fn train(agent: &mut Dqn) {
let (n_in, n_out) = (agent.online.n_in(), agent.online.n_out());
let t = Transition { s: vec![1.0; n_in], a: n_out - 1, r: -1.0, s2: vec![0.5; n_in], disc: 0.9, mask2: vec![true; n_out] };
let t = Transition {
s: vec![1.0; n_in],
a: n_out - 1,
r: -1.0,
s2: vec![0.5; n_in],
disc: 0.9,
mask2: vec![true; n_out],
};
for _ in 0..1000 {
agent.push(t.clone());
}
Expand Down
6 changes: 6 additions & 0 deletions rustfmt.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Formatting for `cargo fmt`, using only settings stable rustfmt supports. Lines may
# run to 110 characters, and "Max" keeps a short expression on one line whenever it
# fits, which stays close to the compact layout the code had before rustfmt.
edition = "2024"
max_width = 110
use_small_heuristics = "Max"
Loading
Loading