feat(fmi): add FMI 2.0 Co-Simulation FMU export (bindings/fmi) and ntropyk-cli export-fmu command

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-18 00:46:34 +02:00
parent 329be3856f
commit f88cd7f7d8
14 changed files with 1369 additions and 14 deletions

View File

@@ -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"

22
bindings/fmi/Cargo.toml Normal file
View File

@@ -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"

112
bindings/fmi/README.md Normal file
View File

@@ -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 <model.json> -i <io.json> \
-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 <model.json> -i <io.json> \
-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 <model.json> -i <io.json> \
-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 <model.json> -i <io.json> \
-t x86_64-pc-windows-msvc -o target/model_win64.fmu
```
Le dossier `binaries/<platform>/` 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.<triple>] 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/<platform>/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.

View File

@@ -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" }
]
}

View File

@@ -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))
}

353
bindings/fmi/src/fmi2.rs Normal file
View File

@@ -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<Option<HashMap<usize, String>>> = 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<CString> = 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(),
}
}

View File

@@ -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<IoInput>,
/// PLC outputs: read by the host after each `doStep`.
#[serde(default)]
pub outputs: Vec<IoOutput>,
}
/// 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<usize>,
}

31
bindings/fmi/src/lib.rs Normal file
View File

@@ -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;

221
bindings/fmi/src/model.rs Normal file
View File

@@ -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<f64>,
output_values: Vec<f64>,
last_status: FmiStatus,
last_error: Option<String>,
}
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<Self, String> {
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<usize> {
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<f64, FmiStatus> {
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<usize>) -> Option<&'a entropyk_cli::run::StateEntry> {
let edge = edge?;
result.state.as_ref()?.iter().find(|e| e.edge == edge)
}

View File

@@ -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"

View File

@@ -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 <target>` the staged cdylib.
//! 4. Generate `modelDescription.xml` from the IO map.
//! 5. Zip `modelDescription.xml` + `binaries/<platform>/<lib>` + `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<IoVar>,
#[serde(default)]
pub outputs: Vec<IoVar>,
}
#[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<usize>,
}
/// 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 `<root>/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<String> {
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/<platform>` 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<PathBuf> {
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<PathBuf> = 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 <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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str(&format!(
"<fmiModelDescription fmiVersion=\"2.0\" modelName=\"{model_name}\" guid=\"{{{guid}}}\" \
generationTool=\"entropyk-cli\" variableNamingConvention=\"flat\" \
numberOfEventIndicators=\"0\">\n"
));
out.push_str(" <CoSimulation modelIdentifier=\"entropyk_fmi\" \
canHandleVariableCommunicationStepSize=\"true\" canGetAndSetFMUstate=\"false\" \
canSerializeFMUstate=\"false\"/>\n");
out.push_str(" <ModelVariables>\n");
let mut vr: u32 = 0;
for v in &io.inputs {
out.push_str(&format!(
" <ScalarVariable name=\"{name}\" valueReference=\"{vr}\" \
causality=\"input\" variability=\"continuous\" initial=\"exact\">\n <Real start=\"0.0\"/>\n </ScalarVariable>\n",
name = xml_escape(&v.name)
));
vr += 1;
}
for v in &io.outputs {
out.push_str(&format!(
" <ScalarVariable name=\"{name}\" valueReference=\"{vr}\" \
causality=\"output\" variability=\"continuous\" initial=\"calculated\">\n <Real/>\n </ScalarVariable>\n",
name = xml_escape(&v.name)
));
vr += 1;
}
out.push_str(" </ModelVariables>\n");
out.push_str(" <ModelStructure>\n");
let n_in = io.inputs.len() as u32;
for i in 0..io.outputs.len() as u32 {
out.push_str(&format!(" <Outputs><Unknown index=\"{}\"/></Outputs>\n", n_in + i + 1));
}
out.push_str(" <Derivatives/>\n");
out.push_str(" <InitialUnknowns>\n");
for i in 0..io.outputs.len() as u32 {
out.push_str(&format!(" <Unknown index=\"{}\"/>\n", n_in + i + 1));
}
out.push_str(" </InitialUnknowns>\n");
out.push_str(" </ModelStructure>\n");
out.push_str("</fmiModelDescription>\n");
out
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// 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(())
}

View File

@@ -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;

View File

@@ -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<PathBuf>,
},
/// 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<PathBuf>) -> 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)
}

View File

@@ -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<String, serde_json::Value>,
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<String, serde_json::Value>) -> 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<String, serde_json::Value>,
) -> 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<dyn entropyk_fluids::FluidBackend>,
auto_t_cond_k: Option<f64>,
meter_mass_flow_via_exv: bool,
) -> CliResult<Box<dyn entropyk::Component>> {
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))