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:
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