diff --git a/tests/rate-loop-proof/Cargo.toml b/tests/rate-loop-proof/Cargo.toml new file mode 100644 index 0000000..613bbab --- /dev/null +++ b/tests/rate-loop-proof/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "rate-loop-proof" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +wasmtime = { version = "41", features = ["component-model"] } +anyhow = "1" + +[workspace] diff --git a/tests/rate-loop-proof/src/main.rs b/tests/rate-loop-proof/src/main.rs new file mode 100644 index 0000000..2ad6b26 --- /dev/null +++ b/tests/rate-loop-proof/src/main.rs @@ -0,0 +1,68 @@ +//! Closed-loop proof THROUGH the WASI-free falcon-rate wasm component. +//! +//! Loads the component, then closes the control loop across the real WIT seam: +//! plant (omega_dot = torque / inertia) -> gyro into vehicle-state -> the +//! component's `rate.tick` -> torque -> plant. Asserts the body rate tracks a +//! 1 rad/s step. No WASI: the component imports only falcon:cascade/types. + +use anyhow::{bail, Result}; +use wasmtime::component::{Component, Linker}; +use wasmtime::{Config, Engine, Store}; + +wasmtime::component::bindgen!({ + world: "rate-component", + path: "../../wit/falcon-cascade/cascade.wit", +}); + +use falcon::cascade::types::{RateSetpoint, VehicleState}; + +fn main() -> Result<()> { + let wasm = std::env::args().nth(1).unwrap_or_else(|| { + eprintln!("usage: rate-loop-proof "); + std::process::exit(2); + }); + + let mut cfg = Config::new(); + cfg.wasm_component_model(true); + let engine = Engine::new(&cfg)?; + let component = Component::from_file(&engine, &wasm)?; + let linker: Linker<()> = Linker::new(&engine); + let mut store = Store::new(&engine, ()); + let bindings = RateComponent::instantiate(&mut store, &component, &linker)?; + let rate = bindings.falcon_cascade_rate(); + + // Plant: 5 g·m² roll inertia (500 g, 10-inch quad), no friction. + let inertia = 0.005_f32; + let dt = 1.0 / 1000.0; + let mut omega = [0.0_f32; 3]; + let target = 1.0_f32; // rad/s step about x + + let mut converged_at: Option = None; + for step in 0..3000 { + let state = VehicleState { + qw: 1.0, qx: 0.0, qy: 0.0, qz: 0.0, + pos_n: 0.0, pos_e: 0.0, pos_d: 0.0, + vel_n: 0.0, vel_e: 0.0, vel_d: 0.0, + wx: omega[0], wy: omega[1], wz: omega[2], + innovation: 0.0, + }; + let sp = RateSetpoint { rx: target, ry: 0.0, rz: 0.0, thrust: 0.5 }; + let torque = rate.call_tick(&mut store, state, sp)?; // <-- across the wasm seam + omega[0] += torque.tx / inertia * dt; + omega[1] += torque.ty / inertia * dt; + omega[2] += torque.tz / inertia * dt; + if (omega[0] - target).abs() < 0.01 && converged_at.is_none() { + converged_at = Some(step as f32 * dt); + } + } + + let err = (omega[0] - target).abs(); + println!("through-wasm closed loop: omega_x -> {:.4} rad/s (target {:.1}), |err| = {:.4}", omega[0], target, err); + match converged_at { + Some(t) if t < 2.0 && err < 0.01 => { + println!("PASS: converged at {:.3}s, steady-state |err| {:.4} < 0.01 — loop closes through the component", t, err); + Ok(()) + } + _ => bail!("FAIL: did not close the loop (converged_at={:?}, err={:.4})", converged_at, err), + } +} diff --git a/wasm/cm/rate/Cargo.toml b/wasm/cm/rate/Cargo.toml index fccd10a..22f521c 100644 --- a/wasm/cm/rate/Cargo.toml +++ b/wasm/cm/rate/Cargo.toml @@ -15,6 +15,8 @@ crate-type = ["cdylib"] [dependencies] relay-rate = { path = "../../../crates/relay-rate" } wit-bindgen-rt = { version = "0.41.0", features = ["bitflags"] } +# no_std (cargo-component path) global allocator — single-threaded wasm. +lol_alloc = "0.4" [package.metadata.component] package = "falcon:cascade" diff --git a/wasm/cm/rate/src/lib.rs b/wasm/cm/rate/src/lib.rs index 266035d..51862d1 100644 --- a/wasm/cm/rate/src/lib.rs +++ b/wasm/cm/rate/src/lib.rs @@ -1,10 +1,27 @@ //! falcon-rate — the body-rate PID as a Component Model component. //! Wraps `relay-rate::RatePid`. //! -//! Stateful: the PID integrator + derivative state persist across -//! `tick` calls. Timestamps synthesised as a 1 kHz counter. The -//! measured body rate comes from `vehicle-state.{wx,wy,wz}` (the -//! gyro reading the EKF component passes through). +//! Stateful: the PID integrator + derivative state persist across `tick` +//! calls. Timestamps synthesised as a 1 kHz counter. The measured body rate +//! comes from `vehicle-state.{wx,wy,wz}` (the gyro reading the EKF component +//! passes through). +//! +//! `no_std` on the cargo-component path so the component imports NO WASI — a +//! pure `falcon:cascade/types` -> `rate` transformer, which is what lowers to +//! bare metal (synth -> gale on M4/M7/F100) and what a WASI-less wasmtime host +//! can drive directly. (The single-threaded statics replace `thread_local!`, +//! whose `std` dependency was the sole reason WASI was linked.) The Bazel +//! bindings path keeps its existing environment. + +#![cfg_attr(not(feature = "bazel-bindings"), no_std)] + +// no_std (cargo-component path) global allocator — the component ABI's +// cabi_realloc needs one. Single-threaded is sound: a component instance is +// never re-entered concurrently by the runtime. +#[cfg(not(feature = "bazel-bindings"))] +#[global_allocator] +static ALLOC: lol_alloc::AssumeSingleThreaded = + unsafe { lol_alloc::AssumeSingleThreaded::new(lol_alloc::FreeListAllocator::new()) }; // Bindings generated by cargo-component as src/bindings.rs. #[allow(warnings)] @@ -20,33 +37,35 @@ use bindings::falcon::cascade::types::{RateSetpoint, TorqueSetpoint, VehicleStat use relay_rate::{RatePid, Timestamp}; -thread_local! { - static PID: RefCell = RefCell::new(RatePid::new()); - static TICK_MS: RefCell = const { RefCell::new(0) }; -} +/// A wasm component instance is single-threaded and never re-entered +/// concurrently by the runtime, so wrapping the per-instance state in a +/// `Sync` cell is sound. This replaces `thread_local!` (which is `std`, and +/// pulled in the WASI imports). +struct SingleThreaded(RefCell); +// SAFETY: the component model guarantees single-threaded, non-reentrant access. +unsafe impl Sync for SingleThreaded {} + +static PID: SingleThreaded = SingleThreaded(RefCell::new(RatePid::new())); +static TICK_MS: SingleThreaded = SingleThreaded(RefCell::new(0)); fn next_timestamp() -> Timestamp { - TICK_MS.with(|c| { - let ms = *c.borrow(); - *c.borrow_mut() = ms + 1; - Timestamp { - seconds: ms / 1000, - fraction: ((ms % 1000) * (1u64 << 32) / 1000) as u32, - } - }) + let ms = *TICK_MS.0.borrow(); + *TICK_MS.0.borrow_mut() = ms + 1; + Timestamp { + seconds: ms / 1000, + fraction: ((ms % 1000) * (1u64 << 32) / 1000) as u32, + } } struct Component; impl Guest for Component { fn tick(state: VehicleState, sp: RateSetpoint) -> TorqueSetpoint { - let torque = PID.with(|p| { - p.borrow_mut().tick( - next_timestamp(), - [state.wx, state.wy, state.wz], - [sp.rx, sp.ry, sp.rz], - ) - }); + let torque = PID.0.borrow_mut().tick( + next_timestamp(), + [state.wx, state.wy, state.wz], + [sp.rx, sp.ry, sp.rz], + ); TorqueSetpoint { tx: torque[0], ty: torque[1], @@ -58,3 +77,11 @@ impl Guest for Component { } bindings::export!(Component with_types_in bindings); + +// no_std (cargo-component path) needs a panic handler; the Bazel path (std) +// provides its own. +#[cfg(not(feature = "bazel-bindings"))] +#[panic_handler] +fn panic(_: &core::panic::PanicInfo) -> ! { + loop {} +}