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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user