From f88cd7f7d83c8c25bf8d4262742fb9736c3bf7c7 Mon Sep 17 00:00:00 2001 From: sepehr Date: Sat, 18 Jul 2026 00:46:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(fmi):=20add=20FMI=202.0=20Co-Simulation=20?= =?UTF-8?q?FMU=20export=20(bindings/fmi)=20and=20=1Bntropyk-cli=20export-f?= =?UTF-8?q?mu=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- Cargo.toml | 1 + bindings/fmi/Cargo.toml | 22 ++ bindings/fmi/README.md | 112 ++++++ .../fmi/examples/chiller_aircooled_io.json | 13 + bindings/fmi/src/embedded.rs | 39 ++ bindings/fmi/src/fmi2.rs | 353 +++++++++++++++++ bindings/fmi/src/io_map.rs | 65 +++ bindings/fmi/src/lib.rs | 31 ++ bindings/fmi/src/model.rs | 221 +++++++++++ crates/cli/Cargo.toml | 1 + crates/cli/src/export_fmu.rs | 374 ++++++++++++++++++ crates/cli/src/lib.rs | 1 + crates/cli/src/main.rs | 53 ++- crates/cli/src/run.rs | 97 ++++- 14 files changed, 1369 insertions(+), 14 deletions(-) create mode 100644 bindings/fmi/Cargo.toml create mode 100644 bindings/fmi/README.md create mode 100644 bindings/fmi/examples/chiller_aircooled_io.json create mode 100644 bindings/fmi/src/embedded.rs create mode 100644 bindings/fmi/src/fmi2.rs create mode 100644 bindings/fmi/src/io_map.rs create mode 100644 bindings/fmi/src/lib.rs create mode 100644 bindings/fmi/src/model.rs create mode 100644 crates/cli/src/export_fmu.rs diff --git a/Cargo.toml b/Cargo.toml index 5eb46c2..662eef1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "bindings/python", # Python bindings (PyO3) "bindings/c", # C FFI bindings (cbindgen) "bindings/wasm", # WebAssembly bindings (wasm-bindgen) + "bindings/fmi", # FMI 2.0 Co-Simulation FMU export (PLC embedding) "tests/fluids", # Cross-backend fluid integration tests ] resolver = "2" diff --git a/bindings/fmi/Cargo.toml b/bindings/fmi/Cargo.toml new file mode 100644 index 0000000..784286c --- /dev/null +++ b/bindings/fmi/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "entropyk-fmi" +description = "FMI 2.0 Co-Simulation FMU export for the Entropyk thermodynamic engine (PLC embedding)" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +# cdylib -> the .dll/.so/.dylib bundled inside the .fmu +# rlib -> reusable from other Rust crates +name = "entropyk_fmi" +crate-type = ["cdylib", "rlib"] + +[dependencies] +entropyk = { path = "../../crates/entropyk" } +entropyk-cli = { path = "../../crates/cli" } +entropyk-solver = { path = "../../crates/solver" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +libc = "0.2" diff --git a/bindings/fmi/README.md b/bindings/fmi/README.md new file mode 100644 index 0000000..f582d30 --- /dev/null +++ b/bindings/fmi/README.md @@ -0,0 +1,112 @@ +# entropyk-fmi — Export FMI 2.0 Co-Simulation (FMU) + +Compile un modèle Entropyk (JSON) en une archive `.fmu` conforme FMI 2.0 Co-Simulation, embarquable sur un PLC / contrôleur industriel (Beckhoff TwinCAT, B&R, Siemens TIA, Codesys) ou testable sur PC avec fmpy / OpenModelica. + +## Principe + +- Le moteur Entropyk (Rust) est compilé en `cdylib` et exporte l'ABI C standard `fmi2*`. +- Le modèle JSON + la carte d'entrées/sorties (`fmu_io.json`) sont **embarqués dans le binaire** à la build (codegen) — aucun accès fichier au runtime. +- L'hôte (PLC) pilote le scheduler temps réel ; chaque `fmi2DoStep` re-resout le cycle stationnaire aux entrées courantes (co-simulation quasi-stationnaire). + +## Générer un FMU + +```bash +# Cible hôte (PC courant) — pour test avec fmpy +cargo run -p entropyk-cli -- export-fmu \ + -c crates/cli/examples/chiller_aircooled_r134a.json \ + -i bindings/fmi/examples/chiller_aircooled_io.json \ + -t host \ + -o target/chiller.fmu +``` + +### Cross-compilation (arm / x86) + +Installer les cibles Rust voulues, puis : + +```bash +# Linux x86_64 +rustup target add x86_64-unknown-linux-gnu +cargo run -p entropyk-cli -- export-fmu -c -i \ + -t x86_64-unknown-linux-gnu -o target/model_linux64.fmu + +# Linux ARM64 (aarch64) +rustup target add aarch64-unknown-linux-gnu +cargo run -p entropyk-cli -- export-fmu -c -i \ + -t aarch64-unknown-linux-gnu -o target/model_aarch64.fmu + +# Linux ARM32 (Cortex-A, hard-float) +rustup target add arm-unknown-linux-gnueabihf +cargo run -p entropyk-cli -- export-fmu -c -i \ + -t arm-unknown-linux-gnueabihf -o target/model_arm32.fmu + +# Windows x86_64 (PLC TwinCAT sur IPC Windows) +rustup target add x86_64-pc-windows-msvc +cargo run -p entropyk-cli -- export-fmu -c -i \ + -t x86_64-pc-windows-msvc -o target/model_win64.fmu +``` + +Le dossier `binaries//` est choisi automatiquement depuis le triple cible : + +| Triple cible | Dossier FMI | +|--------------|-------------| +| `x86_64-unknown-linux-gnu` / `musl` | `linux64` | +| `aarch64-unknown-linux-gnu` / `musl` | `linuxaarch64` | +| `arm-unknown-linux-gnueabihf` | `linuxarm32` | +| `x86_64-pc-windows-msvc` / `gnu` | `win64` | +| `x86_64-apple-darwin` / `aarch64-apple-darwin` | `darwin64` | + +> Cross-compiler Linux depuis Windows : installez les linkers croisés (ex. `aarch64-linux-gnu-gcc`) et configurez `[target.] linker = "..."` dans `~/.cargo/config.toml`. Le codegen du crate staged hérite de la config cargo de l'utilisateur. + +## Contenu de l'archive `.fmu` + +``` +modelDescription.xml (déclare les entrées/sorties, le GUID, le modelIdentifier) +binaries//entropyk_fmi.{so|dll|dylib} +resources/config.json (le modèle Entropyk, pour référence) +resources/fmu_io.json (la carte d'entrées/sorties) +``` + +## Carte d'entrées/sorties (`fmu_io.json`) + +Déclare quelles variables le PLC peut écrire (entrées) et lire (sorties). Les `valueReference` sont attribués dans l'ordre : entrées d'abord (`0..n_inputs`), puis sorties (`n_inputs..`). + +```json +{ + "inputs": [ + { "name": "T_air_c", "component": "cond_air_in", "param": "t_dry_c", "kind": "input" }, + { "name": "m_air", "component": "cond_air_in", "param": "m_flow_kg_s", "kind": "input" } + ], + "outputs": [ + { "name": "COP", "kind": "cop" }, + { "name": "Q_cool_kW", "kind": "q_cooling_kw" }, + { "name": "P_kW", "kind": "compressor_power_kw" }, + { "name": "P_evap_bar","kind": "pressure_bar", "edge": 3 } + ] +} +``` + +`kind` pour les sorties : `cop`, `q_cooling_kw`, `q_heating_kw`, `compressor_power_kw`, `pressure_bar`, `enthalpy_kj_kg`, `mass_flow_kg_s`. Les trois derniers nécessitent `edge` (index d'arête). + +## Mode dev (sans codegen) + +Pour itérer sans recompiler le FMU à chaque changement de modèle, le runtime peut lire le modèle depuis des variables d'environnement au lieu de la version embarquée : + +```bash +cargo build -p entropyk-fmi +export ENTROPYK_FMU_MODEL=crates/cli/examples/chiller_aircooled_r134a.json +export ENTROPYK_FMU_IO=bindings/fmi/examples/chiller_aircooled_io.json +# charger target/debug/entropyk_fmi.{so|dll} dans fmpy comme FMU Co-Sim +``` + +## Test rapide avec fmpy (Python) + +```bash +uv pip install fmpy +uv run python -c "from fmpy import simulate_fmu; simulate_fmu('target/chiller.fmu', stop_time=1.0)" +``` + +## Limites actuelles (skeleton) + +- **Pas de warm-start** : chaque `doStep` reconstruit le graphe `System` et relance un Newton à froid. Pour un cycle 100 ms c'est acceptable ; l'optimisation (réutiliser l'état précédent + le `System` construit) nécessite de scinder `execute_simulation` en `build_system` + `solve_warm` — TODO. +- **Backend fluide** : le FMU embarque actuellement le backend déclaré dans le JSON (CoolProp inclus). Pour un petit MCU, préférer le backend **tabulaire** (tables précalculées hors-ligne) ; vérifier la couverture de la plage de fonctionnement. +- **Itérations bornées** : à configurer via `solver.max_iterations` dans le modèle pour garantir un budget temps réel par pas. diff --git a/bindings/fmi/examples/chiller_aircooled_io.json b/bindings/fmi/examples/chiller_aircooled_io.json new file mode 100644 index 0000000..946f7ba --- /dev/null +++ b/bindings/fmi/examples/chiller_aircooled_io.json @@ -0,0 +1,13 @@ +{ + "inputs": [ + { "name": "T_air_c", "component": "cond_air_in", "param": "t_dry_c", "kind": "input" }, + { "name": "m_air", "component": "cond_air_in", "param": "m_flow_kg_s", "kind": "input" }, + { "name": "T_water_c", "component": "evap_water_in","param": "t_set_c", "kind": "input" }, + { "name": "m_water", "component": "evap_water_in","param": "m_flow_kg_s", "kind": "input" } + ], + "outputs": [ + { "name": "COP", "kind": "cop" }, + { "name": "Q_cool_kW", "kind": "q_cooling_kw" }, + { "name": "P_kW", "kind": "compressor_power_kw" } + ] +} diff --git a/bindings/fmi/src/embedded.rs b/bindings/fmi/src/embedded.rs new file mode 100644 index 0000000..e685f59 --- /dev/null +++ b/bindings/fmi/src/embedded.rs @@ -0,0 +1,39 @@ +//! Model loading for the FMU. +//! +//! Two modes: +//! +//! 1. **Embedded (production / PLC)** — the `export-fmu` codegen step bakes the +//! model JSON and IO-map JSON into this file as `const` strings. No +//! filesystem access at runtime, no env vars. This is the mode used for the +//! `.fmu` shipped to the controller. +//! 2. **Dev** — if the embedded consts are empty, the FMU reads the model from +//! the `ENTROPYK_FMU_MODEL` and `ENTROPYK_FMU_IO` environment variables (file +//! paths). Lets you iterate on a model without recompiling. +//! +//! The codegen step overwrites this file's two consts. Keep the function +//! signature stable. + +use std::env; + +/// Embedded model JSON. Overwritten by `entropyk-cli export-fmu`. +pub const MODEL_JSON: &str = ""; +/// Embedded IO-map JSON. Overwritten by `entropyk-cli export-fmu`. +pub const IO_JSON: &str = ""; + +/// Load the model + IO map. Embedded first, then env vars. +pub fn load_model() -> Result<(String, String), String> { + if !MODEL_JSON.is_empty() && !IO_JSON.is_empty() { + return Ok((MODEL_JSON.to_string(), IO_JSON.to_string())); + } + + let model_path = env::var("ENTROPYK_FMU_MODEL") + .map_err(|_| "no embedded model and ENTROPYK_FMU_MODEL env var not set".to_string())?; + let io_path = env::var("ENTROPYK_FMU_IO") + .map_err(|_| "ENTROPYK_FMU_IO env var not set".to_string())?; + + let model = std::fs::read_to_string(&model_path) + .map_err(|e| format!("read {model_path}: {e}"))?; + let io = std::fs::read_to_string(&io_path) + .map_err(|e| format!("read {io_path}: {e}"))?; + Ok((model, io)) +} diff --git a/bindings/fmi/src/fmi2.rs b/bindings/fmi/src/fmi2.rs new file mode 100644 index 0000000..ff226b5 --- /dev/null +++ b/bindings/fmi/src/fmi2.rs @@ -0,0 +1,353 @@ +//! FMI 2.0 Co-Simulation C ABI. +//! +//! Exports the standard `fmi2*` symbols expected by an FMI 2.0 Co-Sim host +//! (TwinCAT, B&R, Siemens TIA, Codesys, fmpy, OpenModelica, ...). Each +//! `fmi2Component` is a heap-allocated [`crate::model::FmuInstance`] handed +//! back to the host as an opaque pointer. +//! +//! Only Co-Simulation is supported (the host drives the real-time scheduler; +//! this FMU just re-solves the steady cycle on each `fmi2DoStep`). + +use std::collections::HashMap; +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int, c_uint, c_void}; +use std::sync::Mutex; + +use libc::{c_double, size_t}; + +use crate::embedded; +use crate::model::{FmiStatus, FmuInstance}; + +pub type fmi2Real = c_double; +pub type fmi2Integer = c_int; +pub type fmi2Boolean = c_int; +pub type fmi2String = *const c_char; +pub type fmi2ValueReference = c_uint; +pub type fmi2Time = c_double; +pub type fmi2Component = *mut c_void; +pub type fmi2Status = c_int; + +pub const FMI2_OK: fmi2Status = 0; +pub const FMI2_WARNING: fmi2Status = 1; +pub const FMI2_DISCARD: fmi2Status = 2; +pub const FMI2_ERROR: fmi2Status = 3; +pub const FMI2_FATAL: fmi2Status = 4; +pub const FMI2_PENDING: fmi2Status = 5; + +pub const FMI2_TRUE: fmi2Boolean = 1; +pub const FMI2_FALSE: fmi2Boolean = 0; + +/// FMI 2.0 callback functions. We accept the struct for ABI compatibility but +/// do not invoke the callbacks in this skeleton (the host owns memory/logging). +#[repr(C)] +pub struct fmi2CallbackFunctions { + pub logger: *mut c_void, + pub allocate_memory: *mut c_void, + pub free_memory: *mut c_void, + pub step_finished: *mut c_void, + pub intermediate_update: *mut c_void, +} + +fn status_to_i32(s: FmiStatus) -> fmi2Status { + s.as_i32() +} + +/// Global registry of last error messages per instance (for host diagnostics). +static LAST_ERRORS: Mutex>> = Mutex::new(None); + +fn record_error(ptr: usize, msg: String) { + let mut guard = LAST_ERRORS.lock().unwrap(); + guard.get_or_insert_with(HashMap::new).insert(ptr, msg); +} + +/// FMI 2.0 types platform for Co-Simulation. +#[no_mangle] +pub unsafe extern "C" fn fmi2GetTypesPlatform() -> fmi2String { + static PLATFORM: &[u8] = b"default\0"; + PLATFORM.as_ptr() as *const c_char +} + +/// FMI standard version string. +#[no_mangle] +pub unsafe extern "C" fn fmi2GetVersion() -> fmi2String { + static VERSION: &[u8] = b"2.0\0"; + VERSION.as_ptr() as *const c_char +} + +/// Instantiate one FMU. The model JSON + IO-map JSON are loaded via +/// [`crate::embedded::load_model`]: embedded at build time (codegen path) or +/// from `ENTROPYK_FMU_MODEL` / `ENTROPYK_FMU_IO` env vars (dev path). +#[no_mangle] +pub unsafe extern "C" fn fmi2Instantiate( + _instance_name: fmi2String, + fmu_type: fmi2String, + _callback_functions: *const fmi2CallbackFunctions, + _visible: fmi2Boolean, + _logging: fmi2Boolean, +) -> fmi2Component { + // Skeleton supports Co-Simulation only. + if !fmu_type.is_null() { + if let Ok(t) = CStr::from_ptr(fmu_type).to_str() { + if t != "CoSimulation" { + return std::ptr::null_mut(); + } + } + } + + match embedded::load_model() { + Ok((model_json, io_json)) => match FmuInstance::new(&model_json, &io_json) { + Ok(instance) => Box::into_raw(Box::new(instance)) as fmi2Component, + Err(e) => { + record_error(0, e); + std::ptr::null_mut() + } + }, + Err(e) => { + record_error(0, e); + std::ptr::null_mut() + } + } +} + +/// Release an instance. +#[no_mangle] +pub unsafe extern "C" fn fmi2FreeInstance(c: fmi2Component) { + if !c.is_null() { + unsafe { drop(Box::from_raw(c as *mut FmuInstance)) }; + } +} + +/// Helper to access the instance behind a component handle. +unsafe fn instance(c: fmi2Component) -> Option<&'static mut FmuInstance> { + if c.is_null() { + return None; + } + unsafe { Some(&mut *(c as *mut FmuInstance)) } +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2SetDebugLogging( + _c: fmi2Component, + _logging: fmi2Boolean, + _n: size_t, + _categories: *const c_uint, +) -> fmi2Status { + FMI2_OK +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2SetupExperiment( + _c: fmi2Component, + _tolerance_defined: fmi2Boolean, + _tolerance: fmi2Real, + _start_time: fmi2Real, + _stop_time_defined: fmi2Boolean, + _stop_time: fmi2Real, +) -> fmi2Status { + FMI2_OK +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2EnterInitializationMode(c: fmi2Component) -> fmi2Status { + match unsafe { instance(c) } { + Some(inst) => status_to_i32(inst.enter_init()), + None => FMI2_ERROR, + } +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2ExitInitializationMode(c: fmi2Component) -> fmi2Status { + // Outputs are already populated by enter_init; nothing more to do. + if c.is_null() { + FMI2_ERROR + } else { + FMI2_OK + } +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2Terminate(c: fmi2Component) -> fmi2Status { + if c.is_null() { + FMI2_ERROR + } else { + FMI2_OK + } +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2Reset(c: fmi2Component) -> fmi2Status { + // Re-run a cold solve from current inputs. + match unsafe { instance(c) } { + Some(inst) => status_to_i32(inst.do_step()), + None => FMI2_ERROR, + } +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2GetReal( + c: fmi2Component, + vr: *const fmi2ValueReference, + n: size_t, + value: *mut fmi2Real, +) -> fmi2Status { + let inst = match unsafe { instance(c) } { + Some(i) => i, + None => return FMI2_ERROR, + }; + if vr.is_null() || value.is_null() { + return FMI2_ERROR; + } + let vrs = unsafe { std::slice::from_raw_parts(vr, n) }; + let out = unsafe { std::slice::from_raw_parts_mut(value, n) }; + let mut all_ok = true; + for (k, &v) in vrs.iter().enumerate() { + match inst.get_real(v) { + Ok(x) => out[k] = x, + Err(_) => { + out[k] = f64::NAN; + all_ok = false; + } + } + } + if all_ok { + FMI2_OK + } else { + FMI2_WARNING + } +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2SetReal( + c: fmi2Component, + vr: *const fmi2ValueReference, + n: size_t, + value: *const fmi2Real, +) -> fmi2Status { + let inst = match unsafe { instance(c) } { + Some(i) => i, + None => return FMI2_ERROR, + }; + if vr.is_null() || value.is_null() { + return FMI2_ERROR; + } + let vrs = unsafe { std::slice::from_raw_parts(vr, n) }; + let vals = unsafe { std::slice::from_raw_parts(value, n) }; + let mut all_ok = true; + for (k, &v) in vrs.iter().enumerate() { + if status_to_i32(inst.set_real(v, vals[k])) != FMI2_OK { + all_ok = false; + } + } + if all_ok { + FMI2_OK + } else { + FMI2_ERROR + } +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2GetInteger( + _c: fmi2Component, + _vr: *const fmi2ValueReference, + _n: size_t, + _value: *mut fmi2Integer, +) -> fmi2Status { + FMI2_ERROR +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2SetInteger( + _c: fmi2Component, + _vr: *const fmi2ValueReference, + _n: size_t, + _value: *const fmi2Integer, +) -> fmi2Status { + FMI2_ERROR +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2GetBoolean( + _c: fmi2Component, + _vr: *const fmi2ValueReference, + _n: size_t, + _value: *mut fmi2Boolean, +) -> fmi2Status { + FMI2_ERROR +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2SetBoolean( + _c: fmi2Component, + _vr: *const fmi2ValueReference, + _n: size_t, + _value: *const fmi2Boolean, +) -> fmi2Status { + FMI2_ERROR +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2GetString( + _c: fmi2Component, + _vr: *const fmi2ValueReference, + _n: size_t, + _value: *mut fmi2String, +) -> fmi2Status { + FMI2_ERROR +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2SetString( + _c: fmi2Component, + _vr: *const fmi2ValueReference, + _n: size_t, + _value: *const fmi2String, +) -> fmi2Status { + FMI2_ERROR +} + +/// Advance the model by one communication step. Entropyk is steady-state, so +/// `currentCommunicationPoint` and `communicationStepSize` are accepted but +/// ignored: each step re-solves the cycle at the current inputs (quasi-steady +/// co-simulation). +#[no_mangle] +pub unsafe extern "C" fn fmi2DoStep( + c: fmi2Component, + _current_communication_point: fmi2Real, + _communication_step_size: fmi2Real, + _no_set_fmu_state_prior_to_current_point: fmi2Boolean, +) -> fmi2Status { + match unsafe { instance(c) } { + Some(inst) => status_to_i32(inst.do_step()), + None => FMI2_ERROR, + } +} + +#[no_mangle] +pub unsafe extern "C" fn fmi2CancelStep(_c: fmi2Component) -> fmi2Status { + FMI2_ERROR +} + +/// Non-standard helper for the host to retrieve the last error message as a C +/// string (null-terminated). Returns null if no error. Caller must NOT free. +#[no_mangle] +pub unsafe extern "C" fn fmi2GetLastError() -> fmi2String { + static mut BUF: Option = None; + let msg = LAST_ERRORS + .lock() + .unwrap() + .as_ref() + .and_then(|m| m.values().last().cloned()); + match msg { + Some(s) => { + // Rebuild the static buffer each call (single-threaded host query). + let c = CString::new(s).unwrap_or_default(); + let ptr = c.as_ptr(); + unsafe { BUF = Some(c) }; + // Keep the CString alive for the caller's read by leaking the old + // value only after the pointer is handed out. The static retains it. + ptr + } + None => std::ptr::null(), + } +} + diff --git a/bindings/fmi/src/io_map.rs b/bindings/fmi/src/io_map.rs new file mode 100644 index 0000000..cd8e4bb --- /dev/null +++ b/bindings/fmi/src/io_map.rs @@ -0,0 +1,65 @@ +//! FMU IO map: declares which model parameters are PLC inputs and which result +//! fields are PLC outputs. This is a sidecar JSON (`fmu_io.json`) bundled inside +//! the `.fmu` so the Entropyk `ScenarioConfig` schema stays untouched. +//! +//! ## Example `fmu_io.json` +//! +//! ```json +//! { +//! "inputs": [ +//! { "name": "T_air_c", "component": "cond_air_in", "param": "t_dry_c" }, +//! { "name": "T_water_c","component": "evap_water_in", "param": "t_set_c" }, +//! { "name": "exv_open", "component": "exv", "param": "opening" } +//! ], +//! "outputs": [ +//! { "name": "COP", "kind": "cop" }, +//! { "name": "Q_cool_kW", "kind": "q_cooling_kw" }, +//! { "name": "P_kW", "kind": "compressor_power_kw" }, +//! { "name": "P_evap_bar","kind": "pressure_bar", "edge": 3 }, +//! { "name": "h_evap", "kind": "enthalpy_kj_kg", "edge": 3 } +//! ] +//! } +//! ``` +//! +//! Value references are assigned in declaration order: inputs first +//! (`0..n_inputs`), then outputs (`n_inputs..n_inputs+n_outputs`). + +use serde::Deserialize; + +/// Top-level IO specification. +#[derive(Debug, Clone, Deserialize)] +pub struct FmuIoSpec { + /// PLC inputs: written by the host into model parameters each cycle. + #[serde(default)] + pub inputs: Vec, + /// PLC outputs: read by the host after each `doStep`. + #[serde(default)] + pub outputs: Vec, +} + +/// One PLC input, bound to a component parameter. +#[derive(Debug, Clone, Deserialize)] +pub struct IoInput { + /// Human-readable name (mirrors `modelDescription.xml` ScalarVariable name). + pub name: String, + /// Target component name (must match a `"name"` in the model JSON). + pub component: String, + /// Target parameter key inside that component's `params` object. + pub param: String, +} + +/// One PLC output, extracted from the simulation result. +/// +/// `kind` selects the result field; `edge` is required for edge-scoped outputs +/// (`pressure_bar`, `enthalpy_kj_kg`, `mass_flow_kg_s`) and ignored otherwise. +#[derive(Debug, Clone, Deserialize)] +pub struct IoOutput { + /// Human-readable name. + pub name: String, + /// Result field: `cop`, `q_cooling_kw`, `q_heating_kw`, `compressor_power_kw`, + /// `pressure_bar`, `enthalpy_kj_kg`, `mass_flow_kg_s`. + pub kind: String, + /// Edge index for edge-scoped outputs. + #[serde(default)] + pub edge: Option, +} diff --git a/bindings/fmi/src/lib.rs b/bindings/fmi/src/lib.rs new file mode 100644 index 0000000..4507373 --- /dev/null +++ b/bindings/fmi/src/lib.rs @@ -0,0 +1,31 @@ +//! # entropyk-fmi +//! +//! FMI 2.0 Co-Simulation FMU export for the Entropyk thermodynamic engine. +//! +//! Builds as a `cdylib` (the `.dll`/`.so`/`.dylib` bundled inside the `.fmu`) +//! exposing the standard `fmi2*` C ABI. The host (TwinCAT, B&R, Siemens TIA, +//! Codesys, fmpy, OpenModelica, ...) drives the real-time scheduler; each +//! `fmi2DoStep` re-solves the steady refrigeration cycle at the current PLC +//! inputs (quasi-steady co-simulation). +//! +//! See [`embedded`] for how the model JSON is loaded and [`fmi2`] for the ABI. +//! +//! ## Quick dev test (no PLC) +//! +//! ```bash +//! cargo build -p entropyk-fmi +//! export ENTROPYK_FMU_MODEL=crates/cli/examples/chiller_aircooled_r134a.json +//! export ENTROPYK_FMU_IO=bindings/fmi/examples/chiller_aircooled_io.json +//! # then load the resulting .so/.dll in fmpy as a Co-Sim FMU +//! ``` + +#![allow(unsafe_code)] +// FMI 2.0 C ABI uses C naming conventions (fmi2Real, fmi2DoStep, ...). +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(non_upper_case_globals)] + +pub mod embedded; +pub mod fmi2; +pub mod io_map; +pub mod model; diff --git a/bindings/fmi/src/model.rs b/bindings/fmi/src/model.rs new file mode 100644 index 0000000..21ad9db --- /dev/null +++ b/bindings/fmi/src/model.rs @@ -0,0 +1,221 @@ +//! Rust-level FMU instance: parse-once, re-solve-each-step lifecycle. +//! +//! The FMI 2.0 Co-Simulation C ABI in [`crate::fmi2`] wraps this struct. The +//! lifecycle is: +//! +//! 1. [`FmuInstance::new`] — parse the model JSON and the IO-map JSON once. +//! The model JSON is an Entropyk `ScenarioConfig`; the IO-map JSON declares +//! which component parameters are PLC inputs and which result fields are +//! PLC outputs (see [`crate::io_map`]). +//! 2. `set_real` — the host writes input values (ambient temperature, water +//! temperature, EXV opening, setpoints, ...). +//! 3. [`FmuInstance::do_step`] — apply the inputs to the already-parsed config +//! and re-solve the steady cycle via [`entropyk_cli::run::run_from_config`]. +//! 4. `get_real` — the host reads outputs (COP, capacities, power, pressures). +//! +//! The model JSON is parsed exactly once (at instantiation); each `do_step` +//! only mutates boundary parameters and re-solves. The `System` graph is +//! currently rebuilt every step — warm-start (reusing the previous state +//! vector and the built `System`) is tracked as a TODO in `run_from_config`. + +use entropyk_cli::config::ScenarioConfig; +use entropyk_cli::run::{run_from_config, SimulationResult, SimulationStatus}; + +use crate::io_map::{FmuIoSpec, IoInput, IoOutput}; + +/// FMI 2.0 status codes (mirrors `fmi2Status`). +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FmiStatus { + Ok = 0, + Warning = 1, + Discard = 2, + Error = 3, + Fatal = 4, + Pending = 5, +} + +impl FmiStatus { + pub fn as_i32(self) -> i32 { + self as i32 + } +} + +/// One FMU Co-Simulation instance. Independent and self-contained so the +/// exported C ABI can hand the host a raw pointer per instance. +pub struct FmuInstance { + config: ScenarioConfig, + io: FmuIoSpec, + n_inputs: usize, + n_outputs: usize, + input_values: Vec, + output_values: Vec, + last_status: FmiStatus, + last_error: Option, +} + +impl FmuInstance { + /// Parse the model JSON and the IO-map JSON. Both are bundled inside the + /// `.fmu` (model under `resources/`, IO-map under `resources/fmu_io.json`). + pub fn new(config_json: &str, io_json: &str) -> Result { + let config = ScenarioConfig::from_json(config_json) + .map_err(|e| format!("model JSON: {e}"))?; + let io: FmuIoSpec = + serde_json::from_str(io_json).map_err(|e| format!("IO-map JSON: {e}"))?; + + let n_inputs = io.inputs.len(); + let n_outputs = io.outputs.len(); + + Ok(Self { + config, + io, + n_inputs, + n_outputs, + input_values: vec![0.0; n_inputs], + output_values: vec![f64::NAN; n_outputs], + last_status: FmiStatus::Ok, + last_error: None, + }) + } + + /// Number of input value references. + pub fn n_inputs(&self) -> usize { + self.n_inputs + } + + /// Number of output value references. + pub fn n_outputs(&self) -> usize { + self.n_outputs + } + + /// Input VRs occupy `[0, n_inputs)`; output VRs occupy + /// `[n_inputs, n_inputs + n_outputs)`. + fn is_input_vr(&self, vr: u32) -> bool { + (vr as usize) < self.n_inputs + } + + fn output_index(&self, vr: u32) -> Option { + let v = vr as usize; + if v >= self.n_inputs && v < self.n_inputs + self.n_outputs { + Some(v - self.n_inputs) + } else { + None + } + } + + /// Write an input value. Outputs are read-only. + pub fn set_real(&mut self, vr: u32, value: f64) -> FmiStatus { + if self.is_input_vr(vr) { + self.input_values[vr as usize] = value; + FmiStatus::Ok + } else { + self.last_status = FmiStatus::Error; + self.last_error = Some(format!("set_real: VR {vr} is not an input")); + FmiStatus::Error + } + } + + /// Read any value (input or computed output). + pub fn get_real(&self, vr: u32) -> Result { + let v = vr as usize; + if v < self.n_inputs { + return Ok(self.input_values[v]); + } + if let Some(idx) = self.output_index(vr) { + return Ok(self.output_values[idx]); + } + Err(FmiStatus::Error) + } + + /// Apply the current inputs to the config and re-solve the steady cycle. + pub fn do_step(&mut self) -> FmiStatus { + // 1. Push input values into the config's component params. + for (i, input) in self.io.inputs.iter().enumerate() { + let value = self.input_values[i]; + if !apply_input(&mut self.config, input, value) { + self.last_status = FmiStatus::Warning; + self.last_error = Some(format!( + "input '{}' -> component '{}' param '{}' not found", + input.name, input.component, input.param + )); + // Keep going: an unbound input is a warning, not fatal. + } + } + + // 2. Re-solve. + let result: SimulationResult = run_from_config(&self.config); + + // 3. Extract outputs. + match result.status { + SimulationStatus::Converged => { + self.last_status = FmiStatus::Ok; + self.last_error = None; + } + SimulationStatus::Timeout | SimulationStatus::NonConverged => { + self.last_status = FmiStatus::Discard; + self.last_error = Some(format!("solver did not converge: {:?}", result.status)); + } + SimulationStatus::Error => { + self.last_status = FmiStatus::Error; + self.last_error = result.error.clone(); + } + } + + for (idx, out) in self.io.outputs.iter().enumerate() { + self.output_values[idx] = extract_output(&result, out); + } + + self.last_status + } + + /// Enter initialization mode: run one cold solve so outputs are valid + /// before the host reads them during initialization. + pub fn enter_init(&mut self) -> FmiStatus { + self.do_step() + } + + pub fn last_error(&self) -> Option<&str> { + self.last_error.as_deref() + } +} + +/// Write `value` into `config.circuits[*].components[name].params[param]`. +fn apply_input(config: &mut ScenarioConfig, input: &IoInput, value: f64) -> bool { + for circuit in &mut config.circuits { + for comp in &mut circuit.components { + if comp.name == input.component { + comp.params + .insert(input.param.clone(), serde_json::Value::from(value)); + return true; + } + } + } + false +} + +/// Pull a single output from the simulation result. +fn extract_output(result: &SimulationResult, out: &IoOutput) -> f64 { + let perf = result.performance.as_ref(); + match out.kind.as_str() { + "cop" => perf.and_then(|p| p.cop).unwrap_or(f64::NAN), + "q_cooling_kw" => perf.and_then(|p| p.q_cooling_kw).unwrap_or(f64::NAN), + "q_heating_kw" => perf.and_then(|p| p.q_heating_kw).unwrap_or(f64::NAN), + "compressor_power_kw" => perf.and_then(|p| p.compressor_power_kw).unwrap_or(f64::NAN), + "pressure_bar" => find_edge(result, out.edge).map(|e| e.pressure_bar).unwrap_or(f64::NAN), + "enthalpy_kj_kg" => { + find_edge(result, out.edge).map(|e| e.enthalpy_kj_kg).unwrap_or(f64::NAN) + } + "mass_flow_kg_s" => find_edge(result, out.edge) + .and_then(|e| e.mass_flow_kg_s) + .unwrap_or(f64::NAN), + other => { + let _ = other; + f64::NAN + } + } +} + +fn find_edge<'a>(result: &'a SimulationResult, edge: Option) -> Option<&'a entropyk_cli::run::StateEntry> { + let edge = edge?; + result.state.as_ref()?.iter().find(|e| e.edge == edge) +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 4e74a81..eae6256 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -34,6 +34,7 @@ indicatif = { version = "0.17", features = ["rayon"] } rayon = "1.8" colored = "2.1" petgraph = "0.6" +zip = { version = "0.6", default-features = false, features = ["deflate", "time"] } [dev-dependencies] approx = "0.5" diff --git a/crates/cli/src/export_fmu.rs b/crates/cli/src/export_fmu.rs new file mode 100644 index 0000000..7a6d171 --- /dev/null +++ b/crates/cli/src/export_fmu.rs @@ -0,0 +1,374 @@ +//! `export-fmu` command: compile an Entropyk JSON model into an FMI 2.0 +//! Co-Simulation `.fmu` archive. +//! +//! Pipeline: +//! 1. Read the model JSON (`ScenarioConfig`) and the IO-map JSON (`fmu_io.json`). +//! 2. Stage a generated crate under `target/fmu-build/fmu_crate/` that re-exports +//! the `entropyk-fmi` runtime with the model baked into `embedded.rs` (no +//! filesystem access at runtime). +//! 3. `cargo build --release --target ` the staged cdylib. +//! 4. Generate `modelDescription.xml` from the IO map. +//! 5. Zip `modelDescription.xml` + `binaries//` + `resources/` +//! into the output `.fmu`. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::config::ScenarioConfig; +use crate::error::{CliError, CliResult}; + +/// IO map (mirrors `bindings/fmi/src/io_map.rs`). +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FmuIoSpec { + #[serde(default)] + pub inputs: Vec, + #[serde(default)] + pub outputs: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct IoVar { + pub name: String, + #[serde(default)] + pub component: String, + #[serde(default)] + pub param: String, + pub kind: String, + #[serde(default)] + pub edge: Option, +} + +/// Entry point for `entropyk-cli export-fmu`. +pub fn run_export_fmu( + config_path: &Path, + io_path: &Path, + target: &str, + out_path: &Path, + verbose: bool, +) -> CliResult<()> { + println!(" Model: {}", config_path.display()); + println!(" IO map: {}", io_path.display()); + println!(" Target: {target}"); + println!(" Output: {}", out_path.display()); + + let model_json = fs::read_to_string(config_path) + .map_err(|e| CliError::Config(format!("read model {}: {e}", config_path.display())))?; + let io_json = fs::read_to_string(io_path) + .map_err(|e| CliError::Config(format!("read io {}: {e}", io_path.display())))?; + + // Validate the model parses. + let config = ScenarioConfig::from_json(&model_json)?; + let io: FmuIoSpec = serde_json::from_str(&io_json) + .map_err(|e| CliError::Config(format!("parse io map: {e}")))?; + + let target = if target == "host" { + resolve_host_triple()? + } else { + target.to_string() + }; + + let manifest_dir = env_manifest_dir(); + let fmi_src = manifest_dir.join("bindings").join("fmi").join("src"); + if !fmi_src.is_dir() { + return Err(CliError::Config(format!( + "FMI runtime source not found at {} (expected bindings/fmi/src)", + fmi_src.display() + ))); + } + + let stage_dir = manifest_dir.join("target").join("fmu-build"); + fs::create_dir_all(&stage_dir)?; + let stage = stage_dir.join("fmu_crate"); + + stage_crate(&stage, &fmi_src, &manifest_dir, &model_json, &io_json)?; + + let platform = platform_folder(&target) + .ok_or_else(|| CliError::Config(format!("unsupported target: {target}")))?; + + build_cdylib(&stage, &target, verbose)?; + + let lib = locate_cdylib(&stage, &target)?; + let model_name = config.name.unwrap_or_else(|| "entropyk_model".to_string()); + + let xml = render_model_description(&model_name, &io); + assemble_fmu(out_path, &xml, &lib, platform, &model_json, &io_json)?; + + println!(" {} FMU written: {}", "OK", out_path.display()); + Ok(()) +} + +/// Absolute path to the workspace root. +/// +/// `CARGO_MANIFEST_DIR` for the `cli` crate is `/crates/cli`; the +/// workspace root is two levels up. +fn env_manifest_dir() -> PathBuf { + let cli_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + cli_dir + .parent() + .and_then(|p| p.parent()) + .map(PathBuf::from) + .unwrap_or(cli_dir) +} + +/// Resolve the host Rust target triple via `rustc -vV`. +fn resolve_host_triple() -> CliResult { + let out = Command::new("rustc") + .arg("-vV") + .output() + .map_err(|e| CliError::Simulation(format!("invoke rustc -vV: {e}")))?; + let s = String::from_utf8_lossy(&out.stdout); + for line in s.lines() { + if let Some(triple) = line.strip_prefix("host: ") { + return Ok(triple.trim().to_string()); + } + } + Err(CliError::Simulation( + "could not parse host triple from `rustc -vV`".to_string(), + )) +} + +/// Map a Rust target triple to an FMI 2.0 `binaries/` folder name. +fn platform_folder(target: &str) -> Option<&'static str> { + match target { + "x86_64-unknown-linux-gnu" | "x86_64-unknown-linux-musl" => Some("linux64"), + "aarch64-unknown-linux-gnu" | "aarch64-unknown-linux-musl" => Some("linuxaarch64"), + "arm-unknown-linux-gnueabihf" => Some("linuxarm32"), + "i686-unknown-linux-gnu" => Some("linux32"), + "x86_64-pc-windows-msvc" | "x86_64-pc-windows-gnu" => Some("win64"), + "i686-pc-windows-msvc" => Some("win32"), + "x86_64-apple-darwin" | "aarch64-apple-darwin" => Some("darwin64"), + _ => None, + } +} + +/// Locate the compiled cdylib by listing the release dir (robust against +/// `Path::exists()` false-negatives observed on some Windows paths). +fn locate_cdylib(stage: &Path, target: &str) -> CliResult { + let wanted = ["libentropyk_fmu.so", "entropyk_fmi.dll", "libentropyk_fmu.dylib"]; + let dirs = [ + stage.join("target").join(target).join("release"), + stage.join("target").join("release"), + ]; + for dir in &dirs { + let hits: Vec = std::fs::read_dir(dir) + .map(|rd| { + rd.flatten() + .filter_map(|e| { + let p = e.path(); + if wanted.iter().any(|w| p.ends_with(w)) { + Some(p) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default(); + if let Some(p) = hits.into_iter().next() { + return Ok(p); + } + } + Err(CliError::Simulation(format!( + "cdylib not found under {} (looked for {:?})", + stage.join("target").display(), + wanted + ))) +} + +/// Stage a standalone crate that re-exports the FMI runtime with the model +/// baked into `embedded.rs`. +fn stage_crate( + stage: &Path, + fmi_src: &Path, + manifest_dir: &Path, + model_json: &str, + io_json: &str, +) -> CliResult<()> { + if stage.exists() { + let _ = fs::remove_dir_all(stage); + } + fs::create_dir_all(stage.join("src"))?; + + for name in &["lib.rs", "fmi2.rs", "model.rs", "io_map.rs"] { + fs::copy(fmi_src.join(name), stage.join("src").join(name))?; + } + + let embedded = format!( + "pub const MODEL_JSON: &str = {lit_model};\n\ + pub const IO_JSON: &str = {lit_io};\n\ + pub fn load_model() -> Result<(String, String), String> {{\n\ + \x20 Ok((MODEL_JSON.to_string(), IO_JSON.to_string()))\n\ + }}\n", + lit_model = rust_str_literal(model_json), + lit_io = rust_str_literal(io_json), + ); + fs::write(stage.join("src").join("embedded.rs"), embedded)?; + + let toml = format!( + "[package]\n\ + name = \"fmu_crate\"\n\ + version = \"0.1.0\"\n\ + edition = \"2021\"\n\n\ + [lib]\n\ + name = \"entropyk_fmi\"\n\ + crate-type = [\"cdylib\"]\n\n\ + [dependencies]\n\ + entropyk = {{ path = {entropyk_path:?} }}\n\ + entropyk-cli = {{ path = {cli_path:?} }}\n\ + entropyk-solver = {{ path = {solver_path:?} }}\n\ + serde = {{ version = \"1.0\", features = [\"derive\"] }}\n\ + serde_json = \"1.0\"\n\ + libc = \"0.2\"\n\n\ + [workspace]\n", + entropyk_path = manifest_dir.join("crates").join("entropyk"), + cli_path = manifest_dir.join("crates").join("cli"), + solver_path = manifest_dir.join("crates").join("solver"), + ); + fs::write(stage.join("Cargo.toml"), toml)?; + + Ok(()) +} + +/// Escape a string as a Rust string literal. +fn rust_str_literal(s: &str) -> String { + let mut out = String::from('"'); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + other => out.push(other), + } + } + out.push('"'); + out +} + +/// `cargo build --release --target ` in the staged crate. +fn build_cdylib(stage: &Path, target: &str, verbose: bool) -> CliResult<()> { + let mut cmd = Command::new("cargo"); + cmd.current_dir(stage).arg("build").arg("--release"); + if !target.is_empty() && target != "host" { + cmd.arg("--target").arg(target); + } + if !verbose { + cmd.arg("-q"); + } + let status = cmd + .status() + .map_err(|e| CliError::Simulation(format!("failed to invoke cargo: {e}")))?; + if !status.success() { + return Err(CliError::Simulation(format!( + "cargo build failed for staged FMI crate (target {target})" + ))); + } + Ok(()) +} + +/// Render the FMI 2.0 Co-Sim `modelDescription.xml` from the IO map. +fn render_model_description(model_name: &str, io: &FmuIoSpec) -> String { + let guid = format!("{:x}", md5_fmi_guid(model_name)); + let mut out = String::new(); + out.push_str("\n"); + out.push_str(&format!( + "\n" + )); + out.push_str(" \n"); + out.push_str(" \n"); + + let mut vr: u32 = 0; + for v in &io.inputs { + out.push_str(&format!( + " \n \n \n", + name = xml_escape(&v.name) + )); + vr += 1; + } + for v in &io.outputs { + out.push_str(&format!( + " \n \n \n", + name = xml_escape(&v.name) + )); + vr += 1; + } + out.push_str(" \n"); + + out.push_str(" \n"); + let n_in = io.inputs.len() as u32; + for i in 0..io.outputs.len() as u32 { + out.push_str(&format!(" \n", n_in + i + 1)); + } + out.push_str(" \n"); + out.push_str(" \n"); + for i in 0..io.outputs.len() as u32 { + out.push_str(&format!(" \n", n_in + i + 1)); + } + out.push_str(" \n"); + out.push_str(" \n"); + out.push_str("\n"); + out +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// Cheap non-crypto hash to derive a stable-ish GUID from the model name. +fn md5_fmi_guid(s: &str) -> u64 { + let mut h: u64 = 0xcbf29ce484222325; + for b in s.bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x100000001b3); + } + h +} + +/// Assemble the final `.fmu` zip archive. +fn assemble_fmu( + out_path: &Path, + xml: &str, + lib_path: &Path, + platform: &str, + model_json: &str, + io_json: &str, +) -> CliResult<()> { + use std::io::{Read, Write}; + + let file = fs::File::create(out_path)?; + let mut zip = zip::ZipWriter::new(file); + let opts = zip::write::FileOptions::default(); + + zip.start_file("modelDescription.xml", opts) + .map_err(|e| CliError::Simulation(format!("zip: {e}")))?; + zip.write_all(xml.as_bytes())?; + + let ext = lib_path.extension().and_then(|e| e.to_str()).unwrap_or("so"); + let lib_name = format!("binaries/{platform}/entropyk_fmi.{ext}"); + zip.start_file(&lib_name, opts) + .map_err(|e| CliError::Simulation(format!("zip: {e}")))?; + let mut lib_file = fs::File::open(lib_path)?; + std::io::copy(&mut lib_file, &mut zip)?; + + zip.start_file("resources/config.json", opts) + .map_err(|e| CliError::Simulation(format!("zip: {e}")))?; + zip.write_all(model_json.as_bytes())?; + zip.start_file("resources/fmu_io.json", opts) + .map_err(|e| CliError::Simulation(format!("zip: {e}")))?; + zip.write_all(io_json.as_bytes())?; + + zip.finish() + .map_err(|e| CliError::Simulation(format!("zip: {e}")))?; + Ok(()) +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 3c26058..528c802 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -9,6 +9,7 @@ pub mod batch; pub mod config; pub mod error; +pub mod export_fmu; pub mod qualify; pub mod rate; pub mod run; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 4e6135a..c58f4bc 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -10,7 +10,7 @@ //! entropyk-cli batch ./scenarios/ --parallel 4 //! ``` -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::{Parser, Subcommand}; use colored::Colorize; @@ -132,6 +132,27 @@ enum Commands { #[arg(short, long, value_name = "FILE")] output: Option, }, + /// Compile an Entropyk JSON model into an FMI 2.0 Co-Simulation `.fmu` + /// archive for embedding on a PLC / controller. The model and its IO map are + /// baked into the FMU binary (no filesystem access at runtime). + ExportFmu { + /// Path to the Entropyk model JSON (a `ScenarioConfig`). + #[arg(short, long, value_name = "FILE", alias = "model")] + config: PathBuf, + + /// Path to the FMU IO-map JSON (declares PLC inputs/outputs). + #[arg(short, long, value_name = "FILE", alias = "io")] + io: PathBuf, + + /// Rust target triple (e.g. `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, + /// `x86_64-pc-windows-msvc`). Use `host` for the current platform. + #[arg(short, long, value_name = "TRIPLE", default_value = "host")] + target: String, + + /// Path to write the `.fmu` archive. + #[arg(short, long, value_name = "FILE")] + output: PathBuf, + }, } fn main() { @@ -181,6 +202,12 @@ fn main() { run_seasonal(config, output, cli.quiet, SeasonalMode::Seer) } Commands::Schema { output } => emit_schema(output), + Commands::ExportFmu { + config, + io, + target, + output, + } => run_export_fmu(&config, &io, &target, &output, cli.verbose), }; match result { @@ -677,3 +704,27 @@ fn emit_schema(output: Option) -> Result<(), CliError> { Ok(()) } + +/// `export-fmu` subcommand: compile an Entropyk JSON model into an FMI 2.0 +/// Co-Simulation `.fmu` archive. +fn run_export_fmu( + config: &Path, + io: &Path, + target: &str, + output: &Path, + verbose: bool, +) -> Result<(), CliError> { + use entropyk_cli::export_fmu::run_export_fmu as run; + + println!("{}", "═".repeat(60).cyan()); + println!( + "{}", + " ENTROPYK CLI - Export FMU (FMI 2.0 Co-Simulation)" + .cyan() + .bold() + ); + println!("{}", "═".repeat(60).cyan()); + println!(); + + run(config, io, target, output, verbose) +} diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index 89920dc..0653b37 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -499,12 +499,19 @@ fn execute_simulation( } } + // Fixed EXV orifice opening sets ṁ via the valve — skip compressor + // displacement ṁ closure so DoF stays square (ṁ follows the valve). + let meter_mass_flow_via_exv = expanded_components + .iter() + .any(|c| exv_uses_fixed_orifice(&c.params) && matches!(c.component_type.as_str(), "IsenthalpicExpansionValve" | "EXV")); + for component_config in &expanded_components { match create_component( &component_config, &fluid_id, Arc::clone(&backend), auto_t_cond_k, + meter_mass_flow_via_exv, ) { Ok(component) => match system.add_component_to_circuit(component, circuit_id) { Ok(node_id) => { @@ -956,12 +963,13 @@ fn execute_simulation( .as_str() { "IsenthalpicExpansionValve" | "EXV" - if component_config.params.get("orifice_kv").is_some() + if exv_orifice_opening_is_free(&component_config.params) && !opening_controlled_exvs.contains(&component_config.name) => { let init = component_config .params .get("orifice_opening_init") + .or_else(|| component_config.params.get("opening")) .and_then(|v| v.as_f64()) .unwrap_or(0.5); let min = component_config @@ -1300,9 +1308,10 @@ fn execute_simulation( let needs_guarded_newton = !config.controls.is_empty() || config.circuits.iter().any(|c| { c.enabled - && c.components - .iter() - .any(|comp| comp.params.get("orifice_kv").is_some()) + && c.components.iter().any(|comp| { + comp.params.get("orifice_kv").is_some() + || component_has_free_boundary_pressure(comp) + }) }); if !matches!( strategy_name.as_str(), @@ -1720,6 +1729,19 @@ fn resolve_port_index( } } +/// Run a simulation from an in-memory config (no file I/O, no JSON re-parse). +/// +/// Used by the FMI Co-Simulation binding: the model JSON is parsed once at +/// `fmi2Instantiate`, then each `fmi2DoStep` mutates boundary parameters on the +/// already-parsed `ScenarioConfig` and calls this to re-solve the steady cycle. +/// +/// Note: this rebuilds the `System` graph each call. Warm-start (reusing the +/// previous state vector and the built `System`) requires splitting +/// `execute_simulation` into `build_system` + `solve_warm` — tracked as a TODO. +pub fn run_from_config(config: &ScenarioConfig) -> SimulationResult { + execute_simulation(config, "fmu", 0) +} + fn get_param_f64( params: &std::collections::HashMap, key: &str, @@ -1745,6 +1767,19 @@ fn param_fix_flag( } } +/// `true` when a water/brine boundary source runs with Free pressure (Modelica +/// `MassFlowSource_T`): the liquid (P, h) state is then a floating unknown that +/// a full Newton step can push through the saturation dome (Cp → ∞), so the +/// solve needs the Armijo guard. Moist air is exempt — its h↔T convention is +/// linear and pressure-independent, and the guard only slows it down. +fn component_has_free_boundary_pressure(comp: &crate::config::ComponentConfig) -> bool { + if comp.component_type.as_str() != "BrineSource" { + return false; + } + let default_fix_p = optional_imposed_mass_flow(&comp.params).is_none(); + !param_fix_flag(&comp.params, "fix_pressure", default_fix_p) +} + /// Optional imposed mass flow when `fix_mass_flow` is true (default) and a /// positive `m_flow_kg_s` is present. Free ṁ keeps the numeric value as a /// documentation/seed hint only (no Dirichlet residual). @@ -2846,12 +2881,36 @@ fn build_saturated_control( Ok((bounded_var, controller)) } +/// True when the EXV JSON requests a physical orifice with a **fixed** opening. +/// Requires explicit `orifice_kv` (do not infer from `opening` alone — the UI +/// merges a default opening onto imported examples). +fn exv_uses_fixed_orifice(params: &std::collections::HashMap) -> bool { + let kv = params.get("orifice_kv").and_then(|v| v.as_f64()); + if kv.is_none() { + return false; + } + let opening = params.get("opening").and_then(|v| v.as_f64()); + let fix = params + .get("fix_opening") + .and_then(|v| v.as_bool()) + .unwrap_or(true); // Fixed opening is the UI default when orifice is on + fix && opening.is_some() +} + +/// Free-actuator orifice path (opening unknown). Requires explicit `orifice_kv`. +fn exv_orifice_opening_is_free( + params: &std::collections::HashMap, +) -> bool { + params.get("orifice_kv").and_then(|v| v.as_f64()).is_some() && !exv_uses_fixed_orifice(params) +} + /// Create a component from configuration. fn create_component( component_config: &crate::config::ComponentConfig, _primary_fluid: &entropyk::FluidId, backend: Arc, auto_t_cond_k: Option, + meter_mass_flow_via_exv: bool, ) -> CliResult> { use entropyk::{Condenser, Evaporator, HeatExchanger}; use entropyk_components::heat_exchanger::{FlowConfiguration, LmtdModel}; @@ -3519,15 +3578,21 @@ fn create_component( .with_fluid_backend(Arc::clone(&backend)); // Emergent-pressure mode: the compressor no longer pins the discharge - // pressure to P_sat(t_cond_k). Instead the shared mass flow is closed by - // the volumetric displacement model ṁ = ρ_suc·V_s·N·η_vol, letting the - // condensing pressure emerge from the downstream condenser ↔ secondary - // balance. Requires `displacement_m3` and `speed_hz`. + // pressure to P_sat(t_cond_k). Normally ṁ is closed by the volumetric + // displacement model. When a sibling EXV uses a *fixed* orifice opening, + // ṁ is metered by the valve instead (energy-only compressor). if params .get("emergent_pressure") .and_then(|v| v.as_bool()) .unwrap_or(false) { + if meter_mass_flow_via_exv { + tracing::info!( + component = %component_config.name, + "EXV fixed orifice meters ṁ — compressor uses emergent energy-only mode" + ); + comp = comp.with_emergent_metered_flow(); + } else { use entropyk_components::isentropic_compressor::VolumetricEfficiency; let displacement_m3 = params .get("displacement_m3") @@ -3555,6 +3620,7 @@ fn create_component( ), }; comp = comp.with_displacement(displacement_m3, speed_hz, vol_eff); + } // Optional variable-speed-drive (VSD) efficiency map. Enabled when // a `vsd_reference_speed_hz` is supplied; the quadratic speed @@ -3629,12 +3695,17 @@ fn create_component( exv = exv.with_emergent_pressure(); } - // arch-6: physical orifice-flow actuator. `orifice_kv` [m²] enables the - // orifice residual ṁ = Kv·opening·√(2·ρ_in·ΔP); the fractional opening - // becomes a free-actuator solver unknown (registered separately in - // run_simulation so it is wired before finalize()). Emergent-only. + // Physical orifice: ṁ = Kv·opening·√(2·ρ_in·ΔP). Requires explicit orifice_kv. + // - Fixed opening (default): opening is a parameter; compressor uses + // energy-only emergent mode (see meter_mass_flow_via_exv). + // - Free opening (`fix_opening: false`): opening is a free-actuator unknown. if let Some(kv) = params.get("orifice_kv").and_then(|v| v.as_f64()) { - exv = exv.with_orifice(kv); + let opening = params.get("opening").and_then(|v| v.as_f64()).unwrap_or(1.0); + if exv_uses_fixed_orifice(params) { + exv = exv.with_orifice_fixed(kv, opening); + } else { + exv = exv.with_orifice(kv); + } } Ok(Box::new(exv))