Skip to content
Draft
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
30 changes: 19 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,18 @@ first failure:
cargo fmt --all --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps
cargo xtask package-fmus
cargo test --workspace
cargo run -p continuo-examples --example traffic
```

Packaging comes before the tests because the demo's traffic drives on the
controller FMU, so neither the tests nor the demo can start without a
package. It costs a release build of the FMU crate whenever a law changed,
and well under a second when nothing did. Leaving it out would let an edited
law pass here on the copy packaged before it and fail in CI. It needs
`cargo install cargo-fmi` once.

It adds the viewer's `ruff check .`, `ruff format --check .` and `pytest`
when the viewer is installed, and says they were skipped when it is not, so a
Rust-only change is not held up by a Python environment nobody set up.
Expand All @@ -107,24 +115,24 @@ covers the lot locally.

A change reaching an FMU crate or the laws it links needs more than that,
because a `.fmu` carries its own compiled copy of everything it links and the
checks above compare nothing against it:
checks above drive that copy without comparing it against anything:

```sh
cargo xtask verify-fmus
```

It packages every FMU, validates each with fmpy, and runs the tests that
check the packaged copy against the laws it was built from, asking for the
feature each FMU crate holds those tests behind. `verify` leaves all of it
out on purpose: packaging costs a release build of the FMU crate whenever a
law changed, and the tests say nothing without it, so the pair belongs with a
change that reaches a law rather than in an editing loop. Validation is
skipped, and says so, when fmpy is not installed.

`continuo-actors` is the one to watch, since editing a control law there
leaves the packaged FMU a build behind and `cargo test --workspace` stays
green. Packaging needs `cargo install cargo-fmi` once, and validating needs
fmpy in the environment the viewer's checks run in.
feature each FMU crate holds those tests behind. `verify` leaves the
validation and the comparison out on purpose: the feature rebuilds much of
the graph, so the pair belongs with a change that reaches a law rather than
in an editing loop. Validation is skipped, and says so, when fmpy is not
installed.

`continuo-actors` is the one to watch, since a control law there is compiled
into the FMU as well as linked natively, and only this comparison says the
two still agree. Validating needs fmpy in the environment the viewer's checks
run in.

## Turn a mistake into a check

Expand Down
5 changes: 4 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 75 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1515,3 +1515,78 @@ it got there, including the roads not taken.
that makes a rerun fail the same way until the fix. CI now fetches the
dependency crates with the lock held before its first build, so a
stale lock fails the job at the cause.

- **2026-10-12**: **Traffic drives on the controller FMU, and the world
hash moved for it.** Every car the spawner asks for is now `[radar,
controller, physics]`, the controller being the packaged FMU, so it
follows the car ahead in its lane and wants its spawn speed back on a
free road. The ego and `traffic_scale`'s cars stay `[controller,
physics]` at constant speed: the ego until it shares a lane with
traffic, and the scaling run because it measures the conductor, and an
FMU per car is an experiment of its own.
- **What joined the fingerprint**: `eccd08f9a316bbbc` to
`580e8d37b14c6abd`, for every traffic car's scans and acceleration
commands and the poses they changed. Every traffic car slowed
somewhat, since IDM brakes for a lead well before it is close: with a
faster one 65 m ahead, a car at its desired speed brakes at 13 percent
of its maximum acceleration. The slowest fell from 16.2 to 13.3 m/s.
The ego's poses did not move, and neither did the turnover: fourteen
cars, eight retired.
- **A free road is not exactly zero.** `FREE_ROAD` is a long finite
range, so the interaction term is tiny rather than absent, and a car
at its desired speed is commanded about -3e-16. Nothing depends on
exact zero, but a test written against it failed, which is how this
was found.
- **The FMU registers as `controller`**, so a following car's paths read
like a native car's, and the leave order is radar, controller,
physics.
- **One spawn state feeds both halves of a car.** At the instant a car
joins, its controller steps before its physics has published, so the
mapping gives the FMU's pose and speed inputs the state the physics is
built from as their starting values. Without them it would steer from
the origin and accelerate from a standstill it is not in.
- **The mapping's keys are concrete.** A native component wildcards the
world, because its constructor does not know it; a scenario does, so
the FMU subscribes and publishes exactly.
- **Two tests guard following and one guards the meeting point.** No
two same-lane cars overlap at any instant of the run, and some traffic
car changes speed by more than 1 m/s. Pointing the radar binding at a
key nobody publishes fails both, with two origins 0.56 m apart. A unit
test steps one FMU controller and decodes what it published as the
plant's own `AccelCmd` and `SteerCmd`, which is the place a rename on
either side would otherwise surface only as a car holding its speed.
- **`cargo xtask verify` packages the FMUs before testing.** It had left
packaging to `verify-fmus` so the editing loop stayed Rust-only, which
held while nothing in the demo loaded an FMU. Now the tests and the
demo cannot start without a package, and an edited law would leave the
demo driving the copy packaged before it, passing locally and failing
in CI. Packaging costs 0.7 s when nothing changed. Validation and the
feature-gated comparison stay in `verify-fmus`.
- **Building the scenario can now fail in two new ways**, a missing
package and an FMU that will not instantiate, so the setup and run
functions return a `ScenarioError` wrapping those beside
`ConductorError`.
- **The examples drop an FMU's trace of its own calls from the log.**
`fmi-export` reports every FMI call under a `trace` category, one line
per car per step, about 1,700 a run, and `fmi` logs them at `INFO` with
the category as the target. Turning logging off at instantiation would
silence the FMU's errors too, since they travel the same way, so the
examples' subscriber drops a message only when its target is exactly
`trace` and its module path is the `fmu` that `fmi` gives everything an
FMU logs. `Targets` was the obvious tool and is wrong here: it matches a
target as a prefix and knows nothing of module paths, so it would
silence any target beginning with the word, from any crate. The three
lifecycle lines a car logs share their category with the errors and
stay.
- **Joining costs 70 ms a car on Windows, measured and deferred.** `fmi`
0.8 loads a fresh copy of the DLL for each instance. The demo's
release run went from 0.07 s to 1.27 s, 1.0 s of it the fourteen
joins, and PLAN.md's deferred list carries the shared archive that
would fix it.
- **The spawner can place a car right beside one already there**, left
for the milestone's ego-lane change. Its frontier is where it last
spawned, but cars drive on after spawning, so two of the run's eight
mid-run spawns landed 1.73 m and 0.99 m bumper to bumper from a car in
the same lane. IDM brakes and nothing overlaps, but nothing prevents a
spawn landing inside a car; measuring from the furthest car the
spawner can see in that lane would.
29 changes: 24 additions & 5 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,24 @@ under "Wire format", rather than churning the fingerprint once apiece.
which `traffic_scale` is the instrument for measuring. Nothing at demo
scale needs it.

- **An FMU archive opened once and shared by every instance built from
it.** `fmi` 0.8 extracts the archive and loads its library for each
`FmuComponent`, so every following car loads a fresh copy of the
controller's DLL. On Windows that measured about 70 ms per car joining,
against 3 ms for the extraction: the demo's fourteen traffic joins cost
1.0 s of a 1.27 s release run, where the same run with native traffic took
0.07 s. Other platforms were not measured, and a scan of each newly
written DLL is the likely reason Windows pays it.
- An opened archive held behind an `Arc` would load the library once per
run, since loading a path already loaded costs next to nothing. An FMU
declaring `canBeInstantiatedOnlyOncePerProcess` would still need an
archive of its own per instance, so the sharing is the FMU's call.
- `TrafficRequestHandler` is where the demo would hold it, since it builds
the cars and lives exactly as long as the run; a process-wide static
would never clean up its temporary directory.
- Deferred because the demo still runs at 24x real time. It bites when a
scenario joins cars often, or when `traffic_scale` takes the FMU car.

- **Road-network importer**: which format (OpenDRIVE, Lanelet2, other) lowers
into the world spec; decide when realistic road scenarios are needed.

Expand Down Expand Up @@ -883,11 +901,12 @@ under "Wire format", rather than churning the fingerprint once apiece.

- **The viewer's `--check` counts components and calls them joins.** Its
summary prints `joins / leaves` directly above `actors seen`, and the two
count different things: membership is published per component, so a car
joining raises two events, one for its controller and one for its physics.
A demo run reports 32 joins and 16 leaves against 15 actors, which reads as
a discrepancy and is not one: 15 cars at two components each, plus the
logger and the spawner at one, and eight cars retiring. Saying "component
count different things: membership is published per component, so a
traffic car joining raises three events, one each for its radar, its
controller and its physics. A demo run reports 46 joins and 24 leaves
against 15 actors, which reads as a discrepancy and is not one: fourteen
traffic cars at three components each and the ego at two, plus the logger
and the spawner at one, and eight cars retiring. Saying "component
joins / leaves" would settle it. The CI smoke asserts those exact labels, so
the two move together.

Expand Down
46 changes: 29 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,17 @@ Requires a recent stable Rust toolchain (edition 2024, rust ≥ 1.85) and
libclang, the workspace's only native-code prerequisite. See
[Installing libclang](#installing-libclang) below.

The demo's traffic drives on a controller packaged as an FMU, so the tests
and the demo need that package built first, and built again after a control
law changes. `cargo xtask verify` does it every time.

```sh
# Once, for the packaging subcommand
cargo install cargo-fmi

# Package the controller FMU into target/fmu
cargo xtask package-fmus

# Build everything
cargo build --workspace

Expand Down Expand Up @@ -177,43 +187,45 @@ anything that runs several lives in [`xtask`](xtask/), reached as
how many tests ran.

```sh
# What to run before a commit: format, lint, docs, tests and the demo, in
# that order, cheapest first, stopping at the first failure. The viewer's
# ruff and pytest run when the viewer is installed and say they were skipped
# when it is not. A quick check rather than a thorough one, since CI is the
# authority on whether a commit is good.
# What to run before a commit: format, lint, docs, packaging, tests and the
# demo, in that order, cheapest first, stopping at the first failure. The
# viewer's ruff and pytest run when the viewer is installed and say they were
# skipped when it is not. A quick check rather than a thorough one, since CI
# is the authority on whether a commit is good.
cargo xtask verify

# What a change reaching an FMU crate needs on top: package every FMU into
# target/fmu, validate each with fmpy, and run the tests that check the
# packaged copy against the laws it was built from. An .fmu links those laws
# statically, so it carries a snapshot of them. Needs
# `cargo install cargo-fmi` once, and fmpy for the validation.
# What a change reaching an FMU crate needs on top: validate every packaged
# FMU with fmpy, and run the tests that check the packaged copy against the
# laws it was built from. An .fmu links those laws statically, so it carries
# a snapshot of them. Needs fmpy for the validation.
cargo xtask verify-fmus

# The packaging on its own, which is what CI calls before its own tests.
cargo xtask package-fmus
```

The demo logs every live car's pose once per sim-second and finishes in a
fraction of a wall-second. Traffic appears as it is spawned, so the roll
few wall-seconds. Traffic appears as it is spawned, so the roll
changes as the run goes on:

```
INFO initial pose sim_time=0.0 key="continuo/demo/actor/ego/pose" x=0.00 y=0.00 yaw_deg=0.0
INFO initial pose sim_time=0.5 key="continuo/demo/actor/traffic1/pose" x=80.36 y=-3.50 yaw_deg=0.0
...
INFO pose sim_time=4.0 key="continuo/demo/actor/ego/pose" x=120.00 y=0.00 yaw_deg=0.0
INFO pose sim_time=4.0 key="continuo/demo/actor/traffic1/pose" x=145.38 y=-3.50 yaw_deg=0.0
INFO pose sim_time=4.0 key="continuo/demo/actor/traffic2/pose" x=190.87 y=-3.50 yaw_deg=0.0
INFO pose sim_time=4.0 key="continuo/demo/actor/traffic1/pose" x=143.47 y=-3.50 yaw_deg=0.0
INFO pose sim_time=4.0 key="continuo/demo/actor/traffic2/pose" x=189.79 y=-3.50 yaw_deg=0.0
...
done: world 'demo' reached sim time 30.0 in 3031 ticks (free-run)
actual time: 0.404 s (74x real-time), world hash eccd08f9a316bbbc
actual time: 2.295 s (13x real-time), world hash 580e8d37b14c6abd
```

The ego holds the centre lane at 30 m/s; traffic runs 16-22 m/s in the lanes
either side, so the ego spends the run overtaking. Nothing here models a
collision, which is why traffic never shares the ego's lane. A car is
The ego holds the centre lane at 30 m/s; traffic spawns at 16-22 m/s in the
lanes either side, so the ego spends the run overtaking. Each traffic car
drives on the controller FMU, following the car ahead in its lane with IDM
and steering with pure pursuit, so a faster car closes on a slower one and
settles behind it rather than driving through it. Nothing here yet watches
for a collision, which is why traffic never shares the ego's lane. A car is
retired once it falls 60 m behind, and a replacement spawns ahead. Over
30 sim-seconds fourteen different cars pass through a world that holds six
at a time, eight of them retired along the way.
Expand Down
9 changes: 6 additions & 3 deletions crates/continuo-examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ continuo-core = { workspace = true }
continuo-transport = { workspace = true }
continuo-conductor = { workspace = true }
continuo-actors = { workspace = true }
continuo-fmi = { workspace = true }
# For finding the packaged `.fmu` and the array length it was built with,
# not for the FMU itself, which a car loads from the archive at run time.
continuo-fmu-controller-idm = { workspace = true }
continuo-viz-bridge = { workspace = true, optional = true }

[dev-dependencies]
tracing = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing-subscriber = { workspace = true }

[[example]]
Expand Down
5 changes: 1 addition & 4 deletions crates/continuo-examples/examples/traffic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@ use continuo_examples::traffic_world::{self, TrafficRequestHandler};
use continuo_transport::InProcTransport;

fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_target(false)
.init();
continuo_examples::init_logging();

let traffic_request_handler = TrafficRequestHandler::default();
let mut conductor = Conductor::new(
Expand Down
5 changes: 1 addition & 4 deletions crates/continuo-examples/examples/traffic_realtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@ use continuo_examples::traffic_world::{self, TrafficRequestHandler};
use continuo_transport::InProcTransport;

fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_target(false)
.init();
continuo_examples::init_logging();

let seconds: i64 = std::env::args()
.nth(1)
Expand Down
5 changes: 1 addition & 4 deletions crates/continuo-examples/examples/traffic_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ use continuo_examples::traffic_world::{self, TrafficRequestHandler};
use continuo_transport::{InProcTransport, MonitorTransport};

fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_target(false)
.init();
continuo_examples::init_logging();

let file = std::env::args()
.nth(1)
Expand Down
5 changes: 1 addition & 4 deletions crates/continuo-examples/examples/traffic_resim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,7 @@ use continuo_transport::InProcTransport;
const WHAT_IF_EGO_SPEED: f64 = 36.0;

fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_target(false)
.init();
continuo_examples::init_logging();

let file = std::env::args()
.nth(1)
Expand Down
5 changes: 1 addition & 4 deletions crates/continuo-examples/examples/traffic_verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ use continuo_examples::traffic_world::{self, TrafficRequestHandler};
use continuo_transport::{InProcTransport, MonitorTransport};

fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_target(false)
.init();
continuo_examples::init_logging();

let file = std::env::args()
.nth(1)
Expand Down
5 changes: 1 addition & 4 deletions crates/continuo-examples/examples/traffic_viz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,7 @@ use continuo_transport::InProcTransport;
use continuo_viz_bridge::{VizBridge, ZenohSink};

fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_target(false)
.init();
continuo_examples::init_logging();

let seconds: i64 = std::env::args()
.nth(1)
Expand Down
33 changes: 33 additions & 0 deletions crates/continuo-examples/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,36 @@
//! - `traffic_resim`: a live ego re-run against played-back traffic

pub mod traffic_world;

use tracing_subscriber::filter::{LevelFilter, filter_fn};
use tracing_subscriber::prelude::*;

/// The module path `fmi` gives every message an FMU logs, which is what
/// tells an FMU's log apart from the host's own.
const FMU_MODULE_PATH: &str = "fmu";

/// The logging category an FMU built with `fmi-export` reports each FMI call
/// under. `fmi` passes the category through as the log target.
const FMU_TRACE_CATEGORY: &str = "trace";

/// Sends the examples' log to standard output at `INFO`, without an FMU's
/// trace of the calls it receives.
///
/// The controller FMU logs every call under its `trace` category, one line
/// per car per step. Switching its logging off when it is instantiated would
/// silence its errors as well, which travel the same way and are the only
/// account of why it refused a call, so this drops exactly the messages an
/// FMU logged under that category and lets everything else through. The
/// match is exact on both, since `Targets` would match the target as a
/// prefix and silence anything whose target began with the same word.
pub fn init_logging() {
let not_an_fmu_trace = filter_fn(|metadata| {
metadata.target() != FMU_TRACE_CATEGORY || metadata.module_path() != Some(FMU_MODULE_PATH)
});

tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer().with_target(false))
.with(LevelFilter::INFO)
.with(not_an_fmu_trace)
.init();
}
Loading
Loading