From c8845e20a3c86951bea08108abf7e941b83e14f6 Mon Sep 17 00:00:00 2001 From: w1ne <14119286+w1ne@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:06:45 +0200 Subject: [PATCH] feat(cosim): generic co-simulation plugin surface + cosim-step CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a domain-neutral co-simulation boundary so external physical-plant models can be declared in the system manifest and stepped through an adapter, independent of any specific device or vendor. - Manifest: `cosim_models` with external_process | fmi | mock adapters, typed inputs/outputs signal routing, manifest-relative model paths, and validation (non-empty id, positive step_ns, model required for external_process/fmi). - Core: `CosimAdapter` trait, `StaticCosimAdapter`, typed `CosimStep`/`CosimStepResult`, an external-process JSONL adapter, and a `CosimRunner` that steps models at their configured boundaries and routes signals into/out of a signal store. - CLI: `labwired cosim-step --set =` builds the runner from a manifest and prints routed outputs (table or --json). - Example: `examples/cosim-plant-demo` — a minimal reduced-order discrete plant exercising the full contract. - Docs: docs/cosimulation_plugins.md. - Contract tests cover the static/external-process adapters, registry, runner boundaries, signal routing, and manifest-relative loading. Existing SystemManifest constructors gain the new `cosim_models` field. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/cli/src/asset_validation.rs | 12 + crates/cli/src/commands/cosim.rs | 168 +++++++++ crates/cli/src/commands/mod.rs | 1 + crates/cli/src/commands/run.rs | 1 + crates/cli/src/main.rs | 5 + crates/config/src/lib.rs | 59 ++++ crates/config/tests/config_tests.rs | 74 +++- crates/core/src/bus/mod.rs | 6 + crates/core/src/cosim/external_process.rs | 145 ++++++++ crates/core/src/cosim/mod.rs | 61 ++++ crates/core/src/cosim/registry.rs | 237 +++++++++++++ crates/core/src/system/xtensa/mod.rs | 3 + crates/core/src/tests/integration.rs | 10 + crates/core/tests/chip_conformance.rs | 1 + crates/core/tests/cosim_contract.rs | 325 ++++++++++++++++++ crates/core/tests/fixtures/cosim_echo.py | 18 + crates/core/tests/flash_h5_ops.rs | 1 + crates/core/tests/h563_conformance.rs | 1 + crates/core/tests/kw41z_clock_boot.rs | 1 + crates/core/tests/nrf5340_clock_boot.rs | 1 + crates/core/tests/pin_map_resolution.rs | 1 + crates/core/tests/register_compliance.rs | 1 + crates/core/tests/register_coverage.rs | 1 + crates/core/tests/uart_parity_ratchet.rs | 1 + crates/dap/src/adapter.rs | 3 + .../tests/esp32c3_reset_conformance.rs | 1 + .../hw-oracle/tests/foreign_firmware_probe.rs | 1 + crates/hw-oracle/tests/h563_mmio_diff.rs | 1 + docs/cosimulation_plugins.md | 63 ++++ examples/cosim-plant-demo/README.md | 33 ++ .../cosim-plant-demo/models/discrete-plant.py | 50 +++ examples/cosim-plant-demo/system.yaml | 29 ++ 32 files changed, 1314 insertions(+), 1 deletion(-) create mode 100644 crates/cli/src/commands/cosim.rs create mode 100644 crates/core/src/cosim/external_process.rs create mode 100644 crates/core/src/cosim/registry.rs create mode 100644 crates/core/tests/cosim_contract.rs create mode 100644 crates/core/tests/fixtures/cosim_echo.py create mode 100644 docs/cosimulation_plugins.md create mode 100644 examples/cosim-plant-demo/README.md create mode 100644 examples/cosim-plant-demo/models/discrete-plant.py create mode 100644 examples/cosim-plant-demo/system.yaml diff --git a/crates/cli/src/asset_validation.rs b/crates/cli/src/asset_validation.rs index 0db440db1..ad267a47f 100644 --- a/crates/cli/src/asset_validation.rs +++ b/crates/cli/src/asset_validation.rs @@ -174,6 +174,18 @@ fn validate_system(path: &PathBuf) -> ExitCode { result.record_check(); } + for issue in system.validate_cosim_models() { + result.add_error( + "INVALID_COSIM_MODEL", + issue, + Some( + "Set a non-empty id, a positive step_ns, and a model path for fmi/external_process adapters" + .to_string(), + ), + Some("cosim_models[]".to_string()), + ); + } + // 2. Load Referenced Chip // Resolving chip path relative to system file let chip_path_resolved = if let Some(parent) = path.parent() { diff --git a/crates/cli/src/commands/cosim.rs b/crates/cli/src/commands/cosim.rs new file mode 100644 index 000000000..478f22457 --- /dev/null +++ b/crates/cli/src/commands/cosim.rs @@ -0,0 +1,168 @@ +//! `labwired cosim-step`: drive a manifest-declared co-simulation model +//! through the real runner/adapter chain and print the routed outputs. +//! +//! This is the command-line proof that a co-sim model runs *through* +//! LabWired's manifest routing, not just next to it: +//! +//! ```text +//! labwired cosim-step examples/cosim-plant-demo/system.yaml \ +//! --set plant.channels.11.enabled=false --json +//! ``` + +use clap::Args; +use labwired_config::SystemManifest; +use labwired_core::cosim::{CosimRunner, CosimSignalValue, CosimSignals}; +use std::path::PathBuf; +use std::process::ExitCode; + +use crate::{EXIT_CONFIG_ERROR, EXIT_RUNTIME_ERROR}; + +#[derive(Args, Debug)] +pub struct CosimStepArgs { + /// Path to the system manifest declaring `cosim_models`. + pub system: PathBuf, + + /// Seed a signal-store path before stepping, e.g. + /// `--set plant.channels.11.enabled=false`. Values parse as bool, + /// integer, then float, falling back to text. Repeatable. + #[arg(long = "set", value_name = "PATH=VALUE")] + pub sets: Vec, + + /// Number of model steps to advance (time advances to + /// `steps * max(step_ns)`). + #[arg(long, default_value_t = 1)] + pub steps: u64, + + /// Print routed outputs as JSON instead of a table. + #[arg(long)] + pub json: bool, +} + +fn parse_signal_value(raw: &str) -> CosimSignalValue { + match raw { + "true" => CosimSignalValue::Bool(true), + "false" => CosimSignalValue::Bool(false), + _ => { + if let Ok(value) = raw.parse::() { + CosimSignalValue::I64(value) + } else if let Ok(value) = raw.parse::() { + CosimSignalValue::F64(value) + } else { + CosimSignalValue::Text(raw.to_string()) + } + } + } +} + +fn signal_to_display(value: &CosimSignalValue) -> String { + match value { + CosimSignalValue::Bool(value) => value.to_string(), + CosimSignalValue::I64(value) => value.to_string(), + CosimSignalValue::F64(value) => format!("{value}"), + CosimSignalValue::Text(value) => value.clone(), + } +} + +fn signal_to_json(value: &CosimSignalValue) -> serde_json::Value { + match value { + CosimSignalValue::Bool(value) => serde_json::Value::Bool(*value), + CosimSignalValue::I64(value) => serde_json::json!(value), + CosimSignalValue::F64(value) => serde_json::json!(value), + CosimSignalValue::Text(value) => serde_json::Value::String(value.clone()), + } +} + +pub fn run_cosim_step(args: CosimStepArgs) -> ExitCode { + let manifest = match SystemManifest::from_file(&args.system) { + Ok(manifest) => manifest, + Err(err) => { + eprintln!( + "error: failed to load manifest {}: {err:#}", + args.system.display() + ); + return ExitCode::from(EXIT_CONFIG_ERROR); + } + }; + + let issues = manifest.validate_cosim_models(); + if !issues.is_empty() { + for issue in issues { + eprintln!("error: {issue}"); + } + return ExitCode::from(EXIT_CONFIG_ERROR); + } + if manifest.cosim_models.is_empty() { + eprintln!( + "error: manifest {} declares no cosim_models", + args.system.display() + ); + return ExitCode::from(EXIT_CONFIG_ERROR); + } + + let base_dir = args + .system + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let mut runner = match CosimRunner::from_configs_with_base(&manifest.cosim_models, &base_dir) { + Ok(runner) => runner, + Err(err) => { + eprintln!("error: failed to build co-sim runner: {err}"); + return ExitCode::from(EXIT_RUNTIME_ERROR); + } + }; + + let mut signals = CosimSignals::new(); + for set in &args.sets { + let Some((path, raw)) = set.split_once('=') else { + eprintln!("error: --set expects PATH=VALUE, got '{set}'"); + return ExitCode::from(EXIT_CONFIG_ERROR); + }; + signals.insert(path.trim().to_string(), parse_signal_value(raw.trim())); + } + + let max_step_ns = manifest + .cosim_models + .iter() + .map(|model| model.step_ns) + .max() + .unwrap_or(1); + let target_ns = args.steps.saturating_mul(max_step_ns); + + let routed = match runner.step_until_with_signals(target_ns, &mut signals) { + Ok(routed) => routed, + Err(err) => { + eprintln!("error: co-sim step failed: {err}"); + return ExitCode::from(EXIT_RUNTIME_ERROR); + } + }; + + if args.json { + let steps: Vec = routed + .iter() + .map(|step| { + serde_json::json!({ + "model": step.model_id, + "outputs": step + .outputs + .iter() + .map(|(path, value)| (path.clone(), signal_to_json(value))) + .collect::>(), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&steps).unwrap()); + } else { + for step in &routed { + println!("model '{}' routed outputs:", step.model_id); + for (path, value) in &step.outputs { + println!(" {path} = {}", signal_to_display(value)); + } + } + if routed.is_empty() { + println!("no model reached its step boundary (try --steps > 0)"); + } + } + + ExitCode::SUCCESS +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 80b4c0d4d..356335777 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod asset; pub mod codegen; +pub mod cosim; pub mod coverage; pub mod fuzz; pub mod machine; diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index f8418aabc..e41089eb1 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -67,6 +67,7 @@ pub(crate) fn run_firmware_riscv(args: RunArgs, _chip_yaml: String) -> ExitCode chip: args.chip.to_string_lossy().into_owned(), memory_overrides: Default::default(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 302bb091f..a662a18c0 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -132,6 +132,10 @@ enum Commands { /// Run the Tier-1 chip × peripheral validation matrix and export it. Tier1Matrix(Tier1MatrixArgs), + /// Step a manifest-declared co-simulation model through the real + /// runner/adapter chain and print the routed outputs. + CosimStep(commands::cosim::CosimStepArgs), + /// Coverage-guided fuzz a firmware in the silicon-validated simulator. /// /// Mutates an input byte stream injected into the firmware's RAM buffer, @@ -878,6 +882,7 @@ fn main() -> ExitCode { Some(Commands::Snapshot(args)) => commands::snapshot::run_snapshot(args), Some(Commands::Coverage(args)) => commands::coverage::run_coverage(args), Some(Commands::Tier1Matrix(args)) => commands::tier1::run_tier1_matrix(args), + Some(Commands::CosimStep(args)) => commands::cosim::run_cosim_step(args), Some(Commands::Fuzz(args)) => commands::fuzz::run_fuzz(args), None => commands::run::run_interactive(cli), } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 7f89afe66..ac27af08e 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -179,6 +179,34 @@ pub struct ExternalDevice { pub config: HashMap, } +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CosimAdapter { + ExternalProcess, + Fmi, + Mock, +} + +fn default_cosim_step_ns() -> u64 { + 1_000 +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CosimModelConfig { + pub id: String, + pub adapter: CosimAdapter, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default = "default_cosim_step_ns")] + pub step_ns: u64, + #[serde(default)] + pub inputs: HashMap, + #[serde(default)] + pub outputs: HashMap, + #[serde(default)] + pub config: HashMap, +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum BoardIoKind { @@ -230,6 +258,8 @@ pub struct SystemManifest { #[serde(default)] pub external_devices: Vec, #[serde(default)] + pub cosim_models: Vec, + #[serde(default)] pub board_io: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub debug_uart: Option, @@ -298,6 +328,35 @@ impl SystemManifest { let f = std::fs::File::open(path)?; serde_yaml::from_reader(f).context("Failed to parse System Manifest") } + + pub fn validate_cosim_models(&self) -> Vec { + let mut issues = Vec::new(); + + for (index, model) in self.cosim_models.iter().enumerate() { + let location = format!("cosim_models[{index}]"); + if model.id.trim().is_empty() { + issues.push(format!("{location}.id must be a non-empty identifier")); + } + if model.step_ns == 0 { + issues.push(format!("{location}.step_ns must be greater than zero")); + } + if matches!( + model.adapter, + CosimAdapter::ExternalProcess | CosimAdapter::Fmi + ) && model + .model + .as_deref() + .is_none_or(|path| path.trim().is_empty()) + { + issues.push(format!( + "{location}.model is required for {:?} adapters", + model.adapter + )); + } + } + + issues + } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] diff --git a/crates/config/tests/config_tests.rs b/crates/config/tests/config_tests.rs index ebe0e17b5..0c74edf1a 100644 --- a/crates/config/tests/config_tests.rs +++ b/crates/config/tests/config_tests.rs @@ -4,7 +4,7 @@ // This software is released under the MIT License. // See the LICENSE file in the project root for full license information. -use labwired_config::ChipDescriptor; +use labwired_config::{ChipDescriptor, CosimAdapter, SystemManifest}; #[test] fn test_old_yaml_still_parses() { @@ -53,3 +53,75 @@ peripherals: assert_eq!(desc.peripherals[0].size, Some("1KB".to_string())); assert_eq!(desc.peripherals[0].irq, Some(37)); } + +#[test] +fn system_manifest_parses_cosim_models() { + let yaml = r#" +name: "plant-demo" +chip: "chips/stm32f103.yaml" +cosim_models: + - id: "plant_model" + adapter: "external_process" + model: "./models/plant.jsonl" + step_ns: 10000 + inputs: + rem0_enable: "gpio.rem0" + rem1_enable: "gpio.rem1" + outputs: + v_out: "scope.channel_a" + i_out: "meter.output_current" + config: + protocol: "jsonl" +external_devices: [] +"#; + + let manifest: SystemManifest = serde_yaml::from_str(yaml).unwrap(); + + assert_eq!(manifest.cosim_models.len(), 1); + let model = &manifest.cosim_models[0]; + assert_eq!(model.id, "plant_model"); + assert_eq!(model.adapter, CosimAdapter::ExternalProcess); + assert_eq!(model.model.as_deref(), Some("./models/plant.jsonl")); + assert_eq!(model.step_ns, 10_000); + assert_eq!(model.inputs["rem0_enable"], "gpio.rem0"); + assert_eq!(model.outputs["v_out"], "scope.channel_a"); + assert_eq!( + model.config["protocol"], + serde_yaml::Value::String("jsonl".to_string()) + ); +} + +#[test] +fn system_manifest_rejects_incomplete_cosim_model() { + let yaml = r#" +name: "bad-cosim" +chip: "chips/stm32f103.yaml" +cosim_models: + - id: "" + adapter: "fmi" + step_ns: 0 +external_devices: [] +"#; + + let manifest: SystemManifest = serde_yaml::from_str(yaml).unwrap(); + let issues = manifest.validate_cosim_models(); + + assert!( + issues + .iter() + .any(|issue| issue.contains("cosim_models[0].id")), + "expected missing id validation issue, got {issues:?}" + ); + assert!( + issues + .iter() + .any(|issue| issue.contains("cosim_models[0].model")), + "expected missing model validation issue, got {issues:?}" + ); + assert!( + issues + .iter() + .any(|issue| issue.contains("cosim_models[0].step_ns")), + "expected invalid step validation issue, got {issues:?}" + ); +} diff --git a/crates/core/src/bus/mod.rs b/crates/core/src/bus/mod.rs index 6f9f281e3..7dd3dc234 100644 --- a/crates/core/src/bus/mod.rs +++ b/crates/core/src/bus/mod.rs @@ -2574,6 +2574,7 @@ mod tests { serde_yaml::Value::Number(0x53.into()), ); let manifest = SystemManifest { + cosim_models: Vec::new(), walk_deleted: false, schema_version: "1.0".to_string(), name: "adxl345-test".to_string(), @@ -2643,6 +2644,7 @@ mod tests { serde_yaml::Value::Number(0x76.into()), ); let manifest = SystemManifest { + cosim_models: Vec::new(), walk_deleted: false, schema_version: "1.0".to_string(), name: "esp32c3-bmp280-test".to_string(), @@ -2750,6 +2752,7 @@ mod tests { serde_yaml::Value::Number(25.0.into()), ); let manifest = SystemManifest { + cosim_models: Vec::new(), walk_deleted: false, schema_version: "1.0".to_string(), name: "esp32c3-mlx90640-test".to_string(), @@ -3419,6 +3422,7 @@ peripherals: chip: "unused".to_string(), memory_overrides: std::collections::HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: Vec::new(), debug_uart: None, peripherals: Vec::new(), @@ -3550,6 +3554,7 @@ peripherals: config: std::collections::HashMap, ) -> labwired_config::SystemManifest { labwired_config::SystemManifest { + cosim_models: Vec::new(), walk_deleted: false, schema_version: "1.0".to_string(), name: "adxl345-test".to_string(), @@ -4963,6 +4968,7 @@ mod pin_map_tests { name: "pinmap-test".to_string(), chip: path.to_string_lossy().to_string(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/crates/core/src/cosim/external_process.rs b/crates/core/src/cosim/external_process.rs new file mode 100644 index 000000000..2d2754c1b --- /dev/null +++ b/crates/core/src/cosim/external_process.rs @@ -0,0 +1,145 @@ +use crate::cosim::{CosimAdapter, CosimSignalValue, CosimSignals, CosimStep, CosimStepResult}; +use crate::{SimResult, SimulationError}; +use serde_json::{Map, Value}; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; + +pub struct ExternalProcessCosimAdapter { + child: Child, + stdin: ChildStdin, + stdout: BufReader, +} + +impl ExternalProcessCosimAdapter { + pub fn spawn(program: &str, args: &[&str]) -> SimResult { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|err| { + SimulationError::Other(format!("failed to spawn co-sim process '{program}': {err}")) + })?; + + let stdin = child.stdin.take().ok_or_else(|| { + SimulationError::Other(format!( + "failed to open stdin for co-sim process '{program}'" + )) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + SimulationError::Other(format!( + "failed to open stdout for co-sim process '{program}'" + )) + })?; + + Ok(Self { + child, + stdin, + stdout: BufReader::new(stdout), + }) + } +} + +impl Drop for ExternalProcessCosimAdapter { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl CosimAdapter for ExternalProcessCosimAdapter { + fn step(&mut self, step: CosimStep) -> SimResult { + let request = step_to_json(step); + serde_json::to_writer(&mut self.stdin, &request).map_err(|err| { + SimulationError::Other(format!("failed to encode co-sim step request: {err}")) + })?; + self.stdin.write_all(b"\n").map_err(|err| { + SimulationError::Other(format!("failed to write co-sim step newline: {err}")) + })?; + self.stdin.flush().map_err(|err| { + SimulationError::Other(format!("failed to flush co-sim step request: {err}")) + })?; + + let mut line = String::new(); + let bytes = self.stdout.read_line(&mut line).map_err(|err| { + SimulationError::Other(format!("failed to read co-sim step response: {err}")) + })?; + if bytes == 0 { + return Err(SimulationError::Other( + "co-sim process exited before returning a step response".to_string(), + )); + } + + let value: Value = serde_json::from_str(&line).map_err(|err| { + SimulationError::Other(format!("failed to decode co-sim step response: {err}")) + })?; + json_to_step_result(value) + } +} + +fn step_to_json(step: CosimStep) -> Value { + let mut root = Map::new(); + root.insert("time_ns".to_string(), Value::from(step.time_ns)); + root.insert("dt_ns".to_string(), Value::from(step.dt_ns)); + root.insert("inputs".to_string(), signals_to_json(step.inputs)); + Value::Object(root) +} + +fn signals_to_json(signals: CosimSignals) -> Value { + let mut map = Map::new(); + for (key, value) in signals { + map.insert(key, signal_to_json(value)); + } + Value::Object(map) +} + +fn signal_to_json(value: CosimSignalValue) -> Value { + match value { + CosimSignalValue::Bool(value) => Value::Bool(value), + CosimSignalValue::I64(value) => Value::from(value), + CosimSignalValue::F64(value) => Value::from(value), + CosimSignalValue::Text(value) => Value::String(value), + } +} + +fn json_to_step_result(value: Value) -> SimResult { + let object = value.as_object().ok_or_else(|| { + SimulationError::Other("co-sim response must be a JSON object".to_string()) + })?; + let outputs = object + .get("outputs") + .and_then(|value| value.as_object()) + .ok_or_else(|| { + SimulationError::Other( + "co-sim response must contain object field 'outputs'".to_string(), + ) + })?; + + let mut result = CosimSignals::new(); + for (key, value) in outputs { + result.insert(key.clone(), json_to_signal(value)?); + } + + Ok(CosimStepResult { outputs: result }) +} + +fn json_to_signal(value: &Value) -> SimResult { + match value { + Value::Bool(value) => Ok(CosimSignalValue::Bool(*value)), + Value::Number(value) => { + if let Some(value) = value.as_i64() { + Ok(CosimSignalValue::I64(value)) + } else if let Some(value) = value.as_f64() { + Ok(CosimSignalValue::F64(value)) + } else { + Err(SimulationError::Other(format!( + "unsupported co-sim numeric value '{value}'" + ))) + } + } + Value::String(value) => Ok(CosimSignalValue::Text(value.clone())), + _ => Err(SimulationError::Other(format!( + "unsupported co-sim signal value '{value}'" + ))), + } +} diff --git a/crates/core/src/cosim/mod.rs b/crates/core/src/cosim/mod.rs index 8b872eed6..315d87c2c 100644 --- a/crates/core/src/cosim/mod.rs +++ b/crates/core/src/cosim/mod.rs @@ -4,10 +4,71 @@ // This software is released under the MIT License. // See the LICENSE file in the project root for full license information. +mod external_process; +mod registry; pub mod shm; +pub use external_process::ExternalProcessCosimAdapter; +pub use registry::{ + build_cosim_adapter, CosimModelStep, CosimRoutedModelStep, CosimRunner, CosimRunnerModel, +}; + use crate::{Peripheral, PeripheralTickResult, SimResult}; use std::any::Any; +use std::collections::BTreeMap; + +/// Scalar value exchanged at a co-simulation boundary. +#[derive(Debug, Clone, PartialEq)] +pub enum CosimSignalValue { + Bool(bool), + I64(i64), + F64(f64), + Text(String), +} + +pub type CosimSignals = BTreeMap; + +/// One deterministic handoff from LabWired into an external model. +#[derive(Debug, Clone, PartialEq)] +pub struct CosimStep { + pub time_ns: u64, + pub dt_ns: u64, + pub inputs: CosimSignals, +} + +/// Outputs produced by an external model after a co-simulation step. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CosimStepResult { + pub outputs: CosimSignals, +} + +/// Runtime contract implemented by external-process, FMI, or in-process +/// adapters. LabWired owns time advancement; adapters consume one bounded +/// step and return observable values. +pub trait CosimAdapter: Send { + fn step(&mut self, step: CosimStep) -> SimResult; +} + +/// Deterministic adapter used by tests and manifest dry-runs before a real +/// external simulator is wired in. +#[derive(Debug, Clone)] +pub struct StaticCosimAdapter { + outputs: CosimSignals, +} + +impl StaticCosimAdapter { + pub fn new(outputs: CosimSignals) -> Self { + Self { outputs } + } +} + +impl CosimAdapter for StaticCosimAdapter { + fn step(&mut self, _step: CosimStep) -> SimResult { + Ok(CosimStepResult { + outputs: self.outputs.clone(), + }) + } +} /// A peripheral that proxies its operations to an external process via IPC. /// This is used for high-performance co-simulation with RTL models (e.g. Verilator). diff --git a/crates/core/src/cosim/registry.rs b/crates/core/src/cosim/registry.rs new file mode 100644 index 000000000..bc20f9c08 --- /dev/null +++ b/crates/core/src/cosim/registry.rs @@ -0,0 +1,237 @@ +use crate::cosim::{ + CosimAdapter, CosimSignalValue, CosimSignals, CosimStep, CosimStepResult, + ExternalProcessCosimAdapter, StaticCosimAdapter, +}; +use crate::{SimResult, SimulationError}; +use labwired_config::{CosimAdapter as ManifestCosimAdapter, CosimModelConfig}; +use serde_yaml::Value; +use std::path::{Path, PathBuf}; + +pub fn build_cosim_adapter(config: &CosimModelConfig) -> SimResult> { + build_cosim_adapter_with_base(config, Path::new(".")) +} + +pub fn build_cosim_adapter_with_base( + config: &CosimModelConfig, + base_dir: &Path, +) -> SimResult> { + match config.adapter { + ManifestCosimAdapter::Mock => Ok(Box::new(StaticCosimAdapter::new(mock_outputs(config)?))), + ManifestCosimAdapter::ExternalProcess => { + let model = config.model.as_deref().ok_or_else(|| { + SimulationError::Other(format!( + "co-sim model '{}' uses external_process but has no model path", + config.id + )) + })?; + let model = resolve_model_path(model, base_dir); + let model = model.to_string_lossy(); + let (program, args) = external_process_command(&model); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + Ok(Box::new(ExternalProcessCosimAdapter::spawn( + &program, &arg_refs, + )?)) + } + ManifestCosimAdapter::Fmi => Err(SimulationError::NotImplemented( + "co-sim adapter 'fmi' is declared but FMI import is not wired yet".to_string(), + )), + } +} + +fn resolve_model_path(model: &str, base_dir: &Path) -> PathBuf { + let path = Path::new(model); + if path.is_absolute() { + path.to_path_buf() + } else { + base_dir.join(path) + } +} + +fn external_process_command(model: &str) -> (String, Vec) { + if Path::new(model).extension().and_then(|ext| ext.to_str()) == Some("py") { + ("python3".to_string(), vec![model.to_string()]) + } else { + (model.to_string(), Vec::new()) + } +} + +fn mock_outputs(config: &CosimModelConfig) -> SimResult { + let outputs = config.config.get("outputs").ok_or_else(|| { + SimulationError::Other(format!( + "mock co-sim model '{}' requires config.outputs", + config.id + )) + })?; + let map = outputs.as_mapping().ok_or_else(|| { + SimulationError::Other(format!( + "mock co-sim model '{}'.config.outputs must be a mapping", + config.id + )) + })?; + + let mut signals = CosimSignals::new(); + for (key, value) in map { + let key = key.as_str().ok_or_else(|| { + SimulationError::Other(format!( + "mock co-sim model '{}'.config.outputs keys must be strings", + config.id + )) + })?; + signals.insert(key.to_string(), yaml_to_signal(value)?); + } + Ok(signals) +} + +fn yaml_to_signal(value: &Value) -> SimResult { + match value { + Value::Bool(value) => Ok(CosimSignalValue::Bool(*value)), + Value::Number(value) => { + if let Some(value) = value.as_i64() { + Ok(CosimSignalValue::I64(value)) + } else if let Some(value) = value.as_f64() { + Ok(CosimSignalValue::F64(value)) + } else { + Err(SimulationError::Other(format!( + "unsupported co-sim numeric value '{value:?}'" + ))) + } + } + Value::String(value) => Ok(CosimSignalValue::Text(value.clone())), + _ => Err(SimulationError::Other(format!( + "unsupported co-sim signal value '{value:?}'" + ))), + } +} + +pub struct CosimRunnerModel { + config: CosimModelConfig, + adapter: Box, + next_step_ns: u64, +} + +impl CosimRunnerModel { + pub fn new(config: CosimModelConfig, adapter: Box) -> Self { + let next_step_ns = config.step_ns; + Self { + config, + adapter, + next_step_ns, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CosimModelStep { + pub model_id: String, + pub result: CosimStepResult, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CosimRoutedModelStep { + pub model_id: String, + pub outputs: CosimSignals, +} + +#[derive(Default)] +pub struct CosimRunner { + models: Vec, +} + +impl CosimRunner { + pub fn new(models: Vec) -> Self { + Self { models } + } + + pub fn from_configs(configs: &[CosimModelConfig]) -> SimResult { + Self::from_configs_with_base(configs, Path::new(".")) + } + + pub fn from_configs_with_base( + configs: &[CosimModelConfig], + base_dir: &Path, + ) -> SimResult { + let mut models = Vec::new(); + for config in configs { + models.push(CosimRunnerModel::new( + config.clone(), + build_cosim_adapter_with_base(config, base_dir)?, + )); + } + Ok(Self::new(models)) + } + + pub fn step_until( + &mut self, + time_ns: u64, + inputs: CosimSignals, + ) -> SimResult> { + let mut results = Vec::new(); + for model in &mut self.models { + while time_ns >= model.next_step_ns { + let step_time = model.next_step_ns; + let result = model.adapter.step(CosimStep { + time_ns: step_time, + dt_ns: model.config.step_ns, + inputs: inputs.clone(), + })?; + results.push(CosimModelStep { + model_id: model.config.id.clone(), + result, + }); + model.next_step_ns = model.next_step_ns.saturating_add(model.config.step_ns); + } + } + Ok(results) + } + + pub fn step_until_with_signals( + &mut self, + time_ns: u64, + signals: &mut CosimSignals, + ) -> SimResult> { + let mut results = Vec::new(); + for model in &mut self.models { + while time_ns >= model.next_step_ns { + let step_time = model.next_step_ns; + let inputs = routed_inputs(&model.config, signals); + let result = model.adapter.step(CosimStep { + time_ns: step_time, + dt_ns: model.config.step_ns, + inputs, + })?; + let outputs = route_outputs(&model.config, result.outputs, signals); + results.push(CosimRoutedModelStep { + model_id: model.config.id.clone(), + outputs, + }); + model.next_step_ns = model.next_step_ns.saturating_add(model.config.step_ns); + } + } + Ok(results) + } +} + +fn routed_inputs(config: &CosimModelConfig, signals: &CosimSignals) -> CosimSignals { + let mut inputs = CosimSignals::new(); + for (model_signal, source_path) in &config.inputs { + if let Some(value) = signals.get(source_path) { + inputs.insert(model_signal.clone(), value.clone()); + } + } + inputs +} + +fn route_outputs( + config: &CosimModelConfig, + outputs: CosimSignals, + signals: &mut CosimSignals, +) -> CosimSignals { + let mut routed = CosimSignals::new(); + for (model_signal, value) in outputs { + if let Some(target_path) = config.outputs.get(&model_signal) { + signals.insert(target_path.clone(), value.clone()); + routed.insert(target_path.clone(), value); + } + } + routed +} diff --git a/crates/core/src/system/xtensa/mod.rs b/crates/core/src/system/xtensa/mod.rs index 059920c0e..e79c33123 100644 --- a/crates/core/src/system/xtensa/mod.rs +++ b/crates/core/src/system/xtensa/mod.rs @@ -519,6 +519,7 @@ mod tests { serde_yaml::Value::String("GPIO5".to_string()), ); let manifest = SystemManifest { + cosim_models: Vec::new(), walk_deleted: false, schema_version: "1.0".to_string(), name: "test-esp32-epaper".to_string(), @@ -591,6 +592,7 @@ mod tests { serde_yaml::Value::String("GPIO10".to_string()), ); let manifest = SystemManifest { + cosim_models: Vec::new(), walk_deleted: false, schema_version: "1.0".to_string(), name: "test-esp32s3-epaper".to_string(), @@ -643,6 +645,7 @@ mod tests { use labwired_config::{ExternalDevice, SystemManifest}; let manifest = SystemManifest { + cosim_models: Vec::new(), walk_deleted: false, schema_version: "1.0".to_string(), name: "test".to_string(), diff --git a/crates/core/src/tests/integration.rs b/crates/core/src/tests/integration.rs index 842d5e3a7..e6d2a4a6d 100644 --- a/crates/core/src/tests/integration.rs +++ b/crates/core/src/tests/integration.rs @@ -443,6 +443,7 @@ pub mod integration_tests { chip: "test-chip".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: Vec::new(), debug_uart: None, peripherals: Vec::new(), @@ -618,6 +619,7 @@ pub mod integration_tests { chip: "test-chip-2".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: Vec::new(), debug_uart: None, peripherals: Vec::new(), @@ -681,6 +683,7 @@ pub mod integration_tests { chip: "test-chip-3".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: Vec::new(), debug_uart: None, peripherals: Vec::new(), @@ -739,6 +742,7 @@ pub mod integration_tests { chip: "test-chip-gpio-v2".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: Vec::new(), debug_uart: None, peripherals: Vec::new(), @@ -803,6 +807,7 @@ pub mod integration_tests { chip: "test-chip-uart-v2".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: Vec::new(), debug_uart: None, peripherals: Vec::new(), @@ -955,6 +960,7 @@ pub mod integration_tests { chip: "test-chip-two-uarts".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: Vec::new(), debug_uart: Some("uart1".to_string()), peripherals: Vec::new(), @@ -1014,6 +1020,7 @@ pub mod integration_tests { chip: "test-chip-rcc-v2".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: Vec::new(), debug_uart: None, peripherals: Vec::new(), @@ -1075,6 +1082,7 @@ pub mod integration_tests { chip: "test-chip-rcc-f4".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: Vec::new(), debug_uart: None, peripherals: Vec::new(), @@ -1136,6 +1144,7 @@ pub mod integration_tests { chip: "test-chip-gpio-v2-alias".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: Vec::new(), debug_uart: None, peripherals: Vec::new(), @@ -2322,6 +2331,7 @@ pub mod integration_tests { chip: "esp32c3-timg-test".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: Vec::new(), debug_uart: None, peripherals: Vec::new(), diff --git a/crates/core/tests/chip_conformance.rs b/crates/core/tests/chip_conformance.rs index a322fc41f..2d7f19584 100644 --- a/crates/core/tests/chip_conformance.rs +++ b/crates/core/tests/chip_conformance.rs @@ -272,6 +272,7 @@ fn dummy_manifest(path: &str) -> SystemManifest { name: "chip-conformance".to_string(), chip: path.to_string(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/crates/core/tests/cosim_contract.rs b/crates/core/tests/cosim_contract.rs new file mode 100644 index 000000000..fa0e51380 --- /dev/null +++ b/crates/core/tests/cosim_contract.rs @@ -0,0 +1,325 @@ +use labwired_core::cosim::{ + CosimAdapter, CosimSignalValue, CosimStep, CosimStepResult, StaticCosimAdapter, +}; +use std::collections::BTreeMap; + +#[test] +fn static_cosim_adapter_returns_configured_outputs_for_a_step() { + let mut outputs = BTreeMap::new(); + outputs.insert("plant_value".to_string(), CosimSignalValue::F64(48.0)); + outputs.insert("plant_ready".to_string(), CosimSignalValue::Bool(true)); + let mut adapter = StaticCosimAdapter::new(outputs.clone()); + + let mut inputs = BTreeMap::new(); + inputs.insert( + "controller_enable".to_string(), + CosimSignalValue::Bool(true), + ); + + let result = adapter + .step(CosimStep { + time_ns: 20_000, + dt_ns: 10_000, + inputs, + }) + .expect("static co-sim step should succeed"); + + assert_eq!(result, CosimStepResult { outputs }); +} + +#[test] +fn external_process_adapter_round_trips_one_jsonl_step() { + use labwired_core::cosim::ExternalProcessCosimAdapter; + + let script = std::env::current_dir() + .unwrap() + .join("tests/fixtures/cosim_echo.py"); + let mut adapter = + ExternalProcessCosimAdapter::spawn("python3", &[script.to_string_lossy().as_ref()]) + .expect("spawn echo adapter"); + + let mut inputs = BTreeMap::new(); + inputs.insert( + "controller_enable".to_string(), + CosimSignalValue::Bool(true), + ); + + let result = adapter + .step(CosimStep { + time_ns: 10_000, + dt_ns: 10_000, + inputs, + }) + .expect("step should round trip"); + + assert_eq!(result.outputs["ack"], CosimSignalValue::Bool(true)); + assert_eq!(result.outputs["time_ns"], CosimSignalValue::I64(10_000)); +} + +#[test] +fn discrete_plant_sums_enabled_channels() { + use labwired_core::cosim::ExternalProcessCosimAdapter; + + let script = std::env::current_dir() + .unwrap() + .join("../../examples/cosim-plant-demo/models/discrete-plant.py"); + let mut adapter = + ExternalProcessCosimAdapter::spawn("python3", &[script.to_string_lossy().as_ref()]) + .expect("spawn discrete plant adapter"); + + let mut inputs = BTreeMap::new(); + for index in 0..12 { + inputs.insert( + format!("channel{index}_enabled"), + CosimSignalValue::Bool(true), + ); + } + + let result = adapter + .step(CosimStep { + time_ns: 10_000, + dt_ns: 10_000, + inputs, + }) + .expect("discrete plant step should return outputs"); + + assert_eq!(result.outputs["v_out"], CosimSignalValue::F64(12.0)); + assert_eq!(result.outputs["active_channels"], CosimSignalValue::I64(12)); +} + +#[test] +fn discrete_plant_excludes_disabled_channels() { + use labwired_core::cosim::ExternalProcessCosimAdapter; + + let script = std::env::current_dir() + .unwrap() + .join("../../examples/cosim-plant-demo/models/discrete-plant.py"); + let mut adapter = + ExternalProcessCosimAdapter::spawn("python3", &[script.to_string_lossy().as_ref()]) + .expect("spawn discrete plant adapter"); + + let mut inputs = BTreeMap::new(); + for index in 0..12 { + inputs.insert( + format!("channel{index}_enabled"), + CosimSignalValue::Bool(true), + ); + } + inputs.insert( + "disabled_channels".to_string(), + CosimSignalValue::Text("[\"channel11\", 0]".to_string()), + ); + + let result = adapter + .step(CosimStep { + time_ns: 20_000, + dt_ns: 10_000, + inputs, + }) + .expect("discrete plant step should return outputs"); + + assert_eq!(result.outputs["v_out"], CosimSignalValue::F64(10.0)); + assert_eq!(result.outputs["active_channels"], CosimSignalValue::I64(10)); +} + +#[test] +fn cosim_registry_builds_mock_adapter_from_manifest_config() { + use labwired_config::{CosimAdapter as ManifestCosimAdapter, CosimModelConfig}; + use labwired_core::cosim::build_cosim_adapter; + use std::collections::HashMap; + + let config = CosimModelConfig { + id: "mock_plant".to_string(), + adapter: ManifestCosimAdapter::Mock, + model: None, + step_ns: 10_000, + inputs: HashMap::new(), + outputs: HashMap::new(), + config: HashMap::from([( + "outputs".to_string(), + serde_yaml::from_str( + r#" +plant_value: 48.0 +active_channels: 12 +plant_ready: true +label: generic-plant +"#, + ) + .unwrap(), + )]), + }; + + let mut adapter = build_cosim_adapter(&config).expect("build mock adapter"); + let result = adapter + .step(CosimStep { + time_ns: 10_000, + dt_ns: 10_000, + inputs: BTreeMap::new(), + }) + .expect("mock step should succeed"); + + assert_eq!(result.outputs["plant_value"], CosimSignalValue::F64(48.0)); + assert_eq!(result.outputs["active_channels"], CosimSignalValue::I64(12)); + assert_eq!(result.outputs["plant_ready"], CosimSignalValue::Bool(true)); + assert_eq!( + result.outputs["label"], + CosimSignalValue::Text("generic-plant".to_string()) + ); +} + +#[test] +fn cosim_runner_steps_models_at_configured_boundaries() { + use labwired_config::{CosimAdapter as ManifestCosimAdapter, CosimModelConfig}; + use labwired_core::cosim::{CosimRunner, CosimRunnerModel}; + use std::collections::HashMap; + + let config = CosimModelConfig { + id: "mock_plant".to_string(), + adapter: ManifestCosimAdapter::Mock, + model: None, + step_ns: 10, + inputs: HashMap::new(), + outputs: HashMap::new(), + config: HashMap::from([( + "outputs".to_string(), + serde_yaml::from_str("plant_value: 1.0").unwrap(), + )]), + }; + let adapter = labwired_core::cosim::build_cosim_adapter(&config).expect("build mock adapter"); + let mut runner = CosimRunner::new(vec![CosimRunnerModel::new(config, adapter)]); + + assert!(runner.step_until(9, BTreeMap::new()).unwrap().is_empty()); + + let first = runner.step_until(10, BTreeMap::new()).unwrap(); + assert_eq!(first.len(), 1); + assert_eq!(first[0].model_id, "mock_plant"); + assert_eq!( + first[0].result.outputs["plant_value"], + CosimSignalValue::F64(1.0) + ); + + assert!(runner.step_until(19, BTreeMap::new()).unwrap().is_empty()); + assert_eq!(runner.step_until(20, BTreeMap::new()).unwrap().len(), 1); +} + +#[test] +fn cosim_runner_maps_signal_paths_into_model_inputs_and_outputs() { + use labwired_config::{CosimAdapter as ManifestCosimAdapter, CosimModelConfig}; + use labwired_core::cosim::{CosimRunner, CosimRunnerModel}; + use std::collections::HashMap; + + struct GenericPlantAdapter; + + impl CosimAdapter for GenericPlantAdapter { + fn step(&mut self, step: CosimStep) -> labwired_core::SimResult { + let enabled = matches!( + step.inputs.get("controller_enable"), + Some(CosimSignalValue::Bool(true)) + ); + let load = match step.inputs.get("load_torque_nm") { + Some(CosimSignalValue::F64(value)) => *value, + _ => 0.0, + }; + let speed = if enabled { 1000.0 - load * 10.0 } else { 0.0 }; + Ok(CosimStepResult { + outputs: BTreeMap::from([ + ("shaft_speed_rpm".to_string(), CosimSignalValue::F64(speed)), + ("plant_ready".to_string(), CosimSignalValue::Bool(enabled)), + ]), + }) + } + } + + let config = CosimModelConfig { + id: "generic_plant".to_string(), + adapter: ManifestCosimAdapter::Mock, + model: None, + step_ns: 10, + inputs: HashMap::from([ + ( + "controller_enable".to_string(), + "control.enable".to_string(), + ), + ( + "load_torque_nm".to_string(), + "plant.load.torque_nm".to_string(), + ), + ]), + outputs: HashMap::from([ + ( + "shaft_speed_rpm".to_string(), + "observables.shaft_speed_rpm".to_string(), + ), + ( + "plant_ready".to_string(), + "observables.plant_ready".to_string(), + ), + ]), + config: HashMap::new(), + }; + let mut runner = CosimRunner::new(vec![CosimRunnerModel::new( + config, + Box::new(GenericPlantAdapter), + )]); + + let mut signals = BTreeMap::from([ + ("control.enable".to_string(), CosimSignalValue::Bool(true)), + ( + "plant.load.torque_nm".to_string(), + CosimSignalValue::F64(12.5), + ), + ]); + + let routed = runner.step_until_with_signals(10, &mut signals).unwrap(); + + assert_eq!(routed.len(), 1); + assert_eq!( + routed[0].outputs["observables.shaft_speed_rpm"], + CosimSignalValue::F64(875.0) + ); + assert_eq!( + signals["observables.plant_ready"], + CosimSignalValue::Bool(true) + ); + assert_eq!( + signals["observables.shaft_speed_rpm"], + CosimSignalValue::F64(875.0) + ); +} + +#[test] +fn manifest_builds_runner_with_manifest_relative_model_path() { + use labwired_config::SystemManifest; + use labwired_core::cosim::CosimRunner; + + let manifest_path = std::env::current_dir() + .unwrap() + .join("../../examples/cosim-plant-demo/system.yaml"); + let manifest = SystemManifest::from_file(&manifest_path).unwrap(); + let mut runner = CosimRunner::from_configs_with_base( + &manifest.cosim_models, + manifest_path.parent().unwrap(), + ) + .expect("build plant-demo runner"); + + let mut signals = BTreeMap::new(); + for index in 0..12 { + signals.insert( + format!("plant.channels.{index}.enabled"), + CosimSignalValue::Bool(true), + ); + } + signals.insert( + "scenario.disabled_channels".to_string(), + CosimSignalValue::Text("[\"channel11\", 0]".to_string()), + ); + + let routed = runner + .step_until_with_signals(10_000, &mut signals) + .unwrap(); + + assert_eq!(routed.len(), 1); + assert_eq!(signals["plant.active_channels"], CosimSignalValue::I64(10)); + assert_eq!(signals["plant.output.voltage"], CosimSignalValue::F64(10.0)); + assert_eq!(signals["plant.output.current"], CosimSignalValue::F64(10.0)); +} diff --git a/crates/core/tests/fixtures/cosim_echo.py b/crates/core/tests/fixtures/cosim_echo.py new file mode 100644 index 000000000..b585314fc --- /dev/null +++ b/crates/core/tests/fixtures/cosim_echo.py @@ -0,0 +1,18 @@ +import json +import sys + + +for line in sys.stdin: + msg = json.loads(line) + sys.stdout.write( + json.dumps( + { + "outputs": { + "ack": True, + "time_ns": msg["time_ns"], + } + } + ) + + "\n" + ) + sys.stdout.flush() diff --git a/crates/core/tests/flash_h5_ops.rs b/crates/core/tests/flash_h5_ops.rs index 711048c51..6d031bc0c 100644 --- a/crates/core/tests/flash_h5_ops.rs +++ b/crates/core/tests/flash_h5_ops.rs @@ -30,6 +30,7 @@ fn h563_machine() -> Machine { name: "flash-h5-ops".to_string(), chip: path.to_string_lossy().to_string(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/crates/core/tests/h563_conformance.rs b/crates/core/tests/h563_conformance.rs index eb01680a1..d24e2714d 100644 --- a/crates/core/tests/h563_conformance.rs +++ b/crates/core/tests/h563_conformance.rs @@ -27,6 +27,7 @@ fn h563_bus() -> labwired_core::bus::SystemBus { name: "h563-conformance".to_string(), chip: path.to_string_lossy().to_string(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/crates/core/tests/kw41z_clock_boot.rs b/crates/core/tests/kw41z_clock_boot.rs index 18b473d29..d166ec023 100644 --- a/crates/core/tests/kw41z_clock_boot.rs +++ b/crates/core/tests/kw41z_clock_boot.rs @@ -42,6 +42,7 @@ fn kw41z_bus() -> SystemBus { name: "kw41z-clock-boot".to_string(), chip: path.to_string_lossy().to_string(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/crates/core/tests/nrf5340_clock_boot.rs b/crates/core/tests/nrf5340_clock_boot.rs index 2bc1c0a21..86b04094f 100644 --- a/crates/core/tests/nrf5340_clock_boot.rs +++ b/crates/core/tests/nrf5340_clock_boot.rs @@ -46,6 +46,7 @@ fn nrf5340_bus() -> SystemBus { name: "nrf5340-clock-boot".to_string(), chip: path.to_string_lossy().to_string(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/crates/core/tests/pin_map_resolution.rs b/crates/core/tests/pin_map_resolution.rs index 24d3712f7..e44c3840f 100644 --- a/crates/core/tests/pin_map_resolution.rs +++ b/crates/core/tests/pin_map_resolution.rs @@ -12,6 +12,7 @@ fn bus_for(chip_file: &str) -> SystemBus { name: "pinmap".to_string(), chip: path.to_string_lossy().to_string(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/crates/core/tests/register_compliance.rs b/crates/core/tests/register_compliance.rs index 03d3a8b12..a0fb4b21a 100644 --- a/crates/core/tests/register_compliance.rs +++ b/crates/core/tests/register_compliance.rs @@ -51,6 +51,7 @@ fn validate_chip(path: &PathBuf) -> anyhow::Result<()> { name: "test-bench".to_string(), chip: path.to_string_lossy().to_string(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/crates/core/tests/register_coverage.rs b/crates/core/tests/register_coverage.rs index 365d6bd54..3f8f956c1 100644 --- a/crates/core/tests/register_coverage.rs +++ b/crates/core/tests/register_coverage.rs @@ -129,6 +129,7 @@ fn dummy_manifest(path: &str) -> labwired_config::SystemManifest { name: "coverage".to_string(), chip: path.to_string(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/crates/core/tests/uart_parity_ratchet.rs b/crates/core/tests/uart_parity_ratchet.rs index f6232f945..e5592447c 100644 --- a/crates/core/tests/uart_parity_ratchet.rs +++ b/crates/core/tests/uart_parity_ratchet.rs @@ -33,6 +33,7 @@ fn dummy_manifest(path: &str) -> SystemManifest { name: "uart-parity".into(), chip: path.into(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], peripherals: vec![], memory_overrides: Default::default(), diff --git a/crates/dap/src/adapter.rs b/crates/dap/src/adapter.rs index 9ad5e32fc..464d08afb 100644 --- a/crates/dap/src/adapter.rs +++ b/crates/dap/src/adapter.rs @@ -1138,6 +1138,7 @@ mod tests { chip: "test-chip".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: vec![labwired_config::BoardIoBinding { id: "led".to_string(), kind: labwired_config::BoardIoKind::Led, @@ -1196,6 +1197,7 @@ mod tests { chip: "test-chip".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: vec![ labwired_config::BoardIoBinding { id: "led".to_string(), @@ -1267,6 +1269,7 @@ mod tests { chip: "test-chip".to_string(), memory_overrides: HashMap::new(), external_devices: Vec::new(), + cosim_models: Vec::new(), board_io: vec![labwired_config::BoardIoBinding { id: "led".to_string(), kind: labwired_config::BoardIoKind::Led, diff --git a/crates/hw-oracle/tests/esp32c3_reset_conformance.rs b/crates/hw-oracle/tests/esp32c3_reset_conformance.rs index 136e9db7a..5d7302dfd 100644 --- a/crates/hw-oracle/tests/esp32c3_reset_conformance.rs +++ b/crates/hw-oracle/tests/esp32c3_reset_conformance.rs @@ -189,6 +189,7 @@ fn build_sim_bus() -> SystemBus { name: "esp32c3-reset-conformance".to_string(), chip: chip_path.to_string_lossy().to_string(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/crates/hw-oracle/tests/foreign_firmware_probe.rs b/crates/hw-oracle/tests/foreign_firmware_probe.rs index 182302c20..4b65ef05d 100644 --- a/crates/hw-oracle/tests/foreign_firmware_probe.rs +++ b/crates/hw-oracle/tests/foreign_firmware_probe.rs @@ -32,6 +32,7 @@ fn probe_foreign_firmware() { name: "foreign-probe".to_string(), chip: chip_path.to_string_lossy().to_string(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/crates/hw-oracle/tests/h563_mmio_diff.rs b/crates/hw-oracle/tests/h563_mmio_diff.rs index 29065db46..4ace07ad8 100644 --- a/crates/hw-oracle/tests/h563_mmio_diff.rs +++ b/crates/hw-oracle/tests/h563_mmio_diff.rs @@ -992,6 +992,7 @@ fn build_sim_bus() -> SystemBus { name: "h563-mmio-diff".to_string(), chip: chip_path.to_string_lossy().to_string(), external_devices: vec![], + cosim_models: Vec::new(), board_io: vec![], debug_uart: None, peripherals: vec![], diff --git a/docs/cosimulation_plugins.md b/docs/cosimulation_plugins.md new file mode 100644 index 000000000..6c4481b3e --- /dev/null +++ b/docs/cosimulation_plugins.md @@ -0,0 +1,63 @@ +# Co-Simulation Plugins + +LabWired owns deterministic firmware execution, board topology, traces, and +fault orchestration. Physical plant models can be declared as co-simulation +models and stepped through an adapter. + +The first supported manifest shape is: + +```yaml +cosim_models: + - id: "plant_model" + adapter: "external_process" # external_process | fmi | mock + model: "./models/mock-plant.py" + step_ns: 10000 + inputs: + controller_enable: "control.enable" + load_torque_nm: "plant.load.torque_nm" + outputs: + shaft_speed_rpm: "observables.shaft_speed_rpm" + plant_ready: "observables.plant_ready" + config: + protocol: "jsonl" +``` + +`external_process` and `fmi` adapters require `model`. `step_ns` must be +greater than zero. The `mock` adapter is intended for deterministic tests and +dry-runs; its static outputs are declared under `config.outputs` so the top-level +`outputs` map can stay dedicated to LabWired signal routing. + +The core runtime now exposes a small co-sim registry and runner: + +- `build_cosim_adapter(config)` constructs `mock` and `external_process` + adapters from a `CosimModelConfig`. +- `adapter: fmi` intentionally returns a clear unsupported error until the FMI + import path is selected. +- `CosimRunner::step_until(time_ns, inputs)` steps models only at their + configured `step_ns` boundaries. It is deliberately independent from the bus + for now; the next integration layer should map real firmware/topology signals + into the runner inputs and route returned outputs into traces/UI observables. +- `CosimRunner::step_until_with_signals(time_ns, signals)` applies the manifest + `inputs` and `outputs` maps against a signal store. For example, + `controller_enable: control.enable` feeds the model-local + `controller_enable` input from the `control.enable` store path, and + `shaft_speed_rpm: observables.shaft_speed_rpm` writes the model output back + to `observables.shaft_speed_rpm`. +- `CosimRunner::from_configs_with_base(configs, base_dir)` resolves relative + external model paths against the manifest directory, so example manifests can + keep local `./models/...` references. + +This contract is intentionally domain-neutral. A model can represent a motor, +thermal plant, hydraulic system, sensor array, battery pack, power stage, or +any other external process as long as it consumes named inputs and returns named +outputs. + +The `examples/cosim-plant-demo` manifest exercises this generic contract with a +reduced-order discrete plant: per-channel enabled/disabled states, a +scenario-driven `disabled_channels` list, and voltage/current/active-channel +observables. Higher-fidelity behavior (loss, thermal, semiconductor stress, +grid-compliance validation) belongs in a later adapter-backed model. + +Drive any manifest-declared model from the command line with +`labwired cosim-step --set =`, which builds the +runner from the manifest and prints the routed outputs after stepping. diff --git a/examples/cosim-plant-demo/README.md b/examples/cosim-plant-demo/README.md new file mode 100644 index 000000000..48571b4da --- /dev/null +++ b/examples/cosim-plant-demo/README.md @@ -0,0 +1,33 @@ +# Co-Simulation Plant Demo + +A minimal, domain-neutral example of LabWired's co-simulation surface. It +declares one external-process model in the manifest and routes generic signal +paths into and out of it — nothing chip- or vendor-specific. + +The model (`models/discrete-plant.py`) is a reduced-order discrete plant: it +counts how many of twelve channels are enabled (respecting a `disabled_channels` +scenario list) and reports that as voltage, current, and an active-channel +count. It exists to exercise the contract — manifest parsing, adapter +construction, manifest-relative model loading, and signal routing — not to model +any real device. + +## Run It Through LabWired + +```bash +labwired cosim-step examples/cosim-plant-demo/system.yaml \ + --set plant.channels.0.enabled=true \ + --set plant.channels.1.enabled=true \ + --set 'scenario.disabled_channels=["channel11"]' +``` + +`cosim-step` loads the manifest, validates the `cosim_models`, builds the +runner (resolving `./models/...` relative to the manifest), seeds the signal +store from `--set`, steps each model to its `step_ns` boundary, and prints the +routed outputs. Pass `--json` for machine-readable output. + +## Adapt It + +Swap `models/discrete-plant.py` for any process that reads a JSONL +`{"inputs": {...}}` line on stdin and writes `{"outputs": {...}}`. The model can +represent a motor, thermal plant, battery pack, power stage, or any external +simulator; only the manifest `inputs`/`outputs` maps change. diff --git a/examples/cosim-plant-demo/models/discrete-plant.py b/examples/cosim-plant-demo/models/discrete-plant.py new file mode 100644 index 000000000..42d931082 --- /dev/null +++ b/examples/cosim-plant-demo/models/discrete-plant.py @@ -0,0 +1,50 @@ +import json +import sys + + +CHANNEL_COUNT = 12 + + +def disabled_channels(value): + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + value = [value] + + if not isinstance(value, list): + return set() + + disabled = set() + for item in value: + if isinstance(item, int): + disabled.add(item) + elif isinstance(item, str): + if item.startswith("channel") and item[7:].isdigit(): + disabled.add(int(item[7:])) + elif item.isdigit(): + disabled.add(int(item)) + return disabled + + +def step(inputs): + disabled = disabled_channels(inputs.get("disabled_channels")) + active = 0 + for index in range(CHANNEL_COUNT): + if index in disabled: + continue + if inputs.get(f"channel{index}_enabled") is True: + active += 1 + + return { + "v_out": float(active), + "i_out": float(active), + "active_channels": active, + } + + +for line in sys.stdin: + message = json.loads(line) + outputs = step(message.get("inputs", {})) + sys.stdout.write(json.dumps({"outputs": outputs}) + "\n") + sys.stdout.flush() diff --git a/examples/cosim-plant-demo/system.yaml b/examples/cosim-plant-demo/system.yaml new file mode 100644 index 000000000..ff43d8289 --- /dev/null +++ b/examples/cosim-plant-demo/system.yaml @@ -0,0 +1,29 @@ +name: "cosim-plant-demo" +chip: "../../configs/chips/stm32f103.yaml" +cosim_models: + - id: "discrete_plant" + adapter: "external_process" + model: "./models/discrete-plant.py" + step_ns: 10000 + inputs: + channel0_enabled: "plant.channels.0.enabled" + channel1_enabled: "plant.channels.1.enabled" + channel2_enabled: "plant.channels.2.enabled" + channel3_enabled: "plant.channels.3.enabled" + channel4_enabled: "plant.channels.4.enabled" + channel5_enabled: "plant.channels.5.enabled" + channel6_enabled: "plant.channels.6.enabled" + channel7_enabled: "plant.channels.7.enabled" + channel8_enabled: "plant.channels.8.enabled" + channel9_enabled: "plant.channels.9.enabled" + channel10_enabled: "plant.channels.10.enabled" + channel11_enabled: "plant.channels.11.enabled" + disabled_channels: "scenario.disabled_channels" + outputs: + v_out: "plant.output.voltage" + i_out: "plant.output.current" + active_channels: "plant.active_channels" + config: + protocol: "jsonl" + purpose: "Generic reduced-order discrete plant demonstrating the co-simulation contract" +external_devices: []