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:
@@ -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
22
bindings/fmi/Cargo.toml
Normal 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
112
bindings/fmi/README.md
Normal 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.
|
||||
13
bindings/fmi/examples/chiller_aircooled_io.json
Normal file
13
bindings/fmi/examples/chiller_aircooled_io.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
39
bindings/fmi/src/embedded.rs
Normal file
39
bindings/fmi/src/embedded.rs
Normal 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
353
bindings/fmi/src/fmi2.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
||||
65
bindings/fmi/src/io_map.rs
Normal file
65
bindings/fmi/src/io_map.rs
Normal 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
31
bindings/fmi/src/lib.rs
Normal 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
221
bindings/fmi/src/model.rs
Normal 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)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
374
crates/cli/src/export_fmu.rs
Normal file
374
crates/cli/src/export_fmu.rs
Normal 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('&', "&")
|
||||
.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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user