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
11 changes: 11 additions & 0 deletions tests/rate-loop-proof/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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]
68 changes: 68 additions & 0 deletions tests/rate-loop-proof/src/main.rs
Original file line number Diff line number Diff line change
@@ -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 <falcon_rate_cm.wasm>");
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<f32> = 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),
}
}
2 changes: 2 additions & 0 deletions wasm/cm/rate/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
73 changes: 50 additions & 23 deletions wasm/cm/rate/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<lol_alloc::FreeListAllocator> =
unsafe { lol_alloc::AssumeSingleThreaded::new(lol_alloc::FreeListAllocator::new()) };

// Bindings generated by cargo-component as src/bindings.rs.
#[allow(warnings)]
Expand All @@ -20,33 +37,35 @@ use bindings::falcon::cascade::types::{RateSetpoint, TorqueSetpoint, VehicleStat

use relay_rate::{RatePid, Timestamp};

thread_local! {
static PID: RefCell<RatePid> = RefCell::new(RatePid::new());
static TICK_MS: RefCell<u64> = 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<T>(RefCell<T>);
// SAFETY: the component model guarantees single-threaded, non-reentrant access.
unsafe impl<T> Sync for SingleThreaded<T> {}

static PID: SingleThreaded<RatePid> = SingleThreaded(RefCell::new(RatePid::new()));
static TICK_MS: SingleThreaded<u64> = 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],
Expand All @@ -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 {}
}
Loading