//! Rust-level FMU instance: parse-once, re-solve-each-step lifecycle. //! //! The FMI 2.0 Co-Simulation C ABI in [`crate::fmi2`] wraps this struct. The //! lifecycle is: //! //! 1. [`FmuInstance::new`] — parse the model JSON and the IO-map JSON once. //! The model JSON is an Entropyk `ScenarioConfig`; the IO-map JSON declares //! which component parameters are PLC inputs and which result fields are //! PLC outputs (see [`crate::io_map`]). //! 2. `set_real` — the host writes input values (ambient temperature, water //! temperature, EXV opening, setpoints, ...). //! 3. [`FmuInstance::do_step`] — apply the inputs to the already-parsed config //! and re-solve the steady cycle via [`entropyk_cli::run::run_from_config`]. //! 4. `get_real` — the host reads outputs (COP, capacities, power, pressures). //! //! The model JSON is parsed exactly once (at instantiation); each `do_step` //! only mutates boundary parameters and re-solves. The `System` graph is //! currently rebuilt every step — warm-start (reusing the previous state //! vector and the built `System`) is tracked as a TODO in `run_from_config`. use entropyk_cli::config::ScenarioConfig; use entropyk_cli::run::{run_from_config, SimulationResult, SimulationStatus}; use crate::io_map::{FmuIoSpec, IoInput, IoOutput}; /// FMI 2.0 status codes (mirrors `fmi2Status`). #[repr(i32)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FmiStatus { Ok = 0, Warning = 1, Discard = 2, Error = 3, Fatal = 4, Pending = 5, } impl FmiStatus { pub fn as_i32(self) -> i32 { self as i32 } } /// One FMU Co-Simulation instance. Independent and self-contained so the /// exported C ABI can hand the host a raw pointer per instance. pub struct FmuInstance { config: ScenarioConfig, io: FmuIoSpec, n_inputs: usize, n_outputs: usize, input_values: Vec, output_values: Vec, last_status: FmiStatus, last_error: Option, } impl FmuInstance { /// Parse the model JSON and the IO-map JSON. Both are bundled inside the /// `.fmu` (model under `resources/`, IO-map under `resources/fmu_io.json`). pub fn new(config_json: &str, io_json: &str) -> Result { let config = ScenarioConfig::from_json(config_json).map_err(|e| format!("model JSON: {e}"))?; let io: FmuIoSpec = serde_json::from_str(io_json).map_err(|e| format!("IO-map JSON: {e}"))?; let n_inputs = io.inputs.len(); let n_outputs = io.outputs.len(); Ok(Self { config, io, n_inputs, n_outputs, input_values: vec![0.0; n_inputs], output_values: vec![f64::NAN; n_outputs], last_status: FmiStatus::Ok, last_error: None, }) } /// Number of input value references. pub fn n_inputs(&self) -> usize { self.n_inputs } /// Number of output value references. pub fn n_outputs(&self) -> usize { self.n_outputs } /// Input VRs occupy `[0, n_inputs)`; output VRs occupy /// `[n_inputs, n_inputs + n_outputs)`. fn is_input_vr(&self, vr: u32) -> bool { (vr as usize) < self.n_inputs } fn output_index(&self, vr: u32) -> Option { let v = vr as usize; if v >= self.n_inputs && v < self.n_inputs + self.n_outputs { Some(v - self.n_inputs) } else { None } } /// Write an input value. Outputs are read-only. pub fn set_real(&mut self, vr: u32, value: f64) -> FmiStatus { if self.is_input_vr(vr) { self.input_values[vr as usize] = value; FmiStatus::Ok } else { self.last_status = FmiStatus::Error; self.last_error = Some(format!("set_real: VR {vr} is not an input")); FmiStatus::Error } } /// Read any value (input or computed output). pub fn get_real(&self, vr: u32) -> Result { let v = vr as usize; if v < self.n_inputs { return Ok(self.input_values[v]); } if let Some(idx) = self.output_index(vr) { return Ok(self.output_values[idx]); } Err(FmiStatus::Error) } /// Apply the current inputs to the config and re-solve the steady cycle. pub fn do_step(&mut self) -> FmiStatus { // 1. Push input values into the config's component params. for (i, input) in self.io.inputs.iter().enumerate() { let value = self.input_values[i]; if !apply_input(&mut self.config, input, value) { self.last_status = FmiStatus::Warning; self.last_error = Some(format!( "input '{}' -> component '{}' param '{}' not found", input.name, input.component, input.param )); // Keep going: an unbound input is a warning, not fatal. } } // 2. Re-solve. let result: SimulationResult = run_from_config(&self.config); // 3. Extract outputs. match result.status { SimulationStatus::Converged => { self.last_status = FmiStatus::Ok; self.last_error = None; } SimulationStatus::Timeout | SimulationStatus::NonConverged => { self.last_status = FmiStatus::Discard; self.last_error = Some(format!("solver did not converge: {:?}", result.status)); } SimulationStatus::Error => { self.last_status = FmiStatus::Error; self.last_error = result.error.clone(); } } for (idx, out) in self.io.outputs.iter().enumerate() { self.output_values[idx] = extract_output(&result, out); } self.last_status } /// Enter initialization mode: run one cold solve so outputs are valid /// before the host reads them during initialization. pub fn enter_init(&mut self) -> FmiStatus { self.do_step() } pub fn last_error(&self) -> Option<&str> { self.last_error.as_deref() } } /// Write `value` into `config.circuits[*].components[name].params[param]`. fn apply_input(config: &mut ScenarioConfig, input: &IoInput, value: f64) -> bool { for circuit in &mut config.circuits { for comp in &mut circuit.components { if comp.name == input.component { comp.params .insert(input.param.clone(), serde_json::Value::from(value)); return true; } } } false } /// Pull a single output from the simulation result. fn extract_output(result: &SimulationResult, out: &IoOutput) -> f64 { let perf = result.performance.as_ref(); match out.kind.as_str() { "cop" => perf.and_then(|p| p.cop).unwrap_or(f64::NAN), "q_cooling_kw" => perf.and_then(|p| p.q_cooling_kw).unwrap_or(f64::NAN), "q_heating_kw" => perf.and_then(|p| p.q_heating_kw).unwrap_or(f64::NAN), "compressor_power_kw" => perf.and_then(|p| p.compressor_power_kw).unwrap_or(f64::NAN), "pressure_bar" => find_edge(result, out.edge) .map(|e| e.pressure_bar) .unwrap_or(f64::NAN), "enthalpy_kj_kg" => find_edge(result, out.edge) .map(|e| e.enthalpy_kj_kg) .unwrap_or(f64::NAN), "mass_flow_kg_s" => find_edge(result, out.edge) .and_then(|e| e.mass_flow_kg_s) .unwrap_or(f64::NAN), other => { let _ = other; f64::NAN } } } fn find_edge( result: &SimulationResult, edge: Option, ) -> Option<&entropyk_cli::run::StateEntry> { let edge = edge?; result.state.as_ref()?.iter().find(|e| e.edge == edge) }