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

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

View File

@@ -34,6 +34,7 @@ indicatif = { version = "0.17", features = ["rayon"] }
rayon = "1.8"
colored = "2.1"
petgraph = "0.6"
zip = { version = "0.6", default-features = false, features = ["deflate", "time"] }
[dev-dependencies]
approx = "0.5"

View File

@@ -0,0 +1,374 @@
//! `export-fmu` command: compile an Entropyk JSON model into an FMI 2.0
//! Co-Simulation `.fmu` archive.
//!
//! Pipeline:
//! 1. Read the model JSON (`ScenarioConfig`) and the IO-map JSON (`fmu_io.json`).
//! 2. Stage a generated crate under `target/fmu-build/fmu_crate/` that re-exports
//! the `entropyk-fmi` runtime with the model baked into `embedded.rs` (no
//! filesystem access at runtime).
//! 3. `cargo build --release --target <target>` the staged cdylib.
//! 4. Generate `modelDescription.xml` from the IO map.
//! 5. Zip `modelDescription.xml` + `binaries/<platform>/<lib>` + `resources/`
//! into the output `.fmu`.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::ScenarioConfig;
use crate::error::{CliError, CliResult};
/// IO map (mirrors `bindings/fmi/src/io_map.rs`).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct FmuIoSpec {
#[serde(default)]
pub inputs: Vec<IoVar>,
#[serde(default)]
pub outputs: Vec<IoVar>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct IoVar {
pub name: String,
#[serde(default)]
pub component: String,
#[serde(default)]
pub param: String,
pub kind: String,
#[serde(default)]
pub edge: Option<usize>,
}
/// Entry point for `entropyk-cli export-fmu`.
pub fn run_export_fmu(
config_path: &Path,
io_path: &Path,
target: &str,
out_path: &Path,
verbose: bool,
) -> CliResult<()> {
println!(" Model: {}", config_path.display());
println!(" IO map: {}", io_path.display());
println!(" Target: {target}");
println!(" Output: {}", out_path.display());
let model_json = fs::read_to_string(config_path)
.map_err(|e| CliError::Config(format!("read model {}: {e}", config_path.display())))?;
let io_json = fs::read_to_string(io_path)
.map_err(|e| CliError::Config(format!("read io {}: {e}", io_path.display())))?;
// Validate the model parses.
let config = ScenarioConfig::from_json(&model_json)?;
let io: FmuIoSpec = serde_json::from_str(&io_json)
.map_err(|e| CliError::Config(format!("parse io map: {e}")))?;
let target = if target == "host" {
resolve_host_triple()?
} else {
target.to_string()
};
let manifest_dir = env_manifest_dir();
let fmi_src = manifest_dir.join("bindings").join("fmi").join("src");
if !fmi_src.is_dir() {
return Err(CliError::Config(format!(
"FMI runtime source not found at {} (expected bindings/fmi/src)",
fmi_src.display()
)));
}
let stage_dir = manifest_dir.join("target").join("fmu-build");
fs::create_dir_all(&stage_dir)?;
let stage = stage_dir.join("fmu_crate");
stage_crate(&stage, &fmi_src, &manifest_dir, &model_json, &io_json)?;
let platform = platform_folder(&target)
.ok_or_else(|| CliError::Config(format!("unsupported target: {target}")))?;
build_cdylib(&stage, &target, verbose)?;
let lib = locate_cdylib(&stage, &target)?;
let model_name = config.name.unwrap_or_else(|| "entropyk_model".to_string());
let xml = render_model_description(&model_name, &io);
assemble_fmu(out_path, &xml, &lib, platform, &model_json, &io_json)?;
println!(" {} FMU written: {}", "OK", out_path.display());
Ok(())
}
/// Absolute path to the workspace root.
///
/// `CARGO_MANIFEST_DIR` for the `cli` crate is `<root>/crates/cli`; the
/// workspace root is two levels up.
fn env_manifest_dir() -> PathBuf {
let cli_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
cli_dir
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.unwrap_or(cli_dir)
}
/// Resolve the host Rust target triple via `rustc -vV`.
fn resolve_host_triple() -> CliResult<String> {
let out = Command::new("rustc")
.arg("-vV")
.output()
.map_err(|e| CliError::Simulation(format!("invoke rustc -vV: {e}")))?;
let s = String::from_utf8_lossy(&out.stdout);
for line in s.lines() {
if let Some(triple) = line.strip_prefix("host: ") {
return Ok(triple.trim().to_string());
}
}
Err(CliError::Simulation(
"could not parse host triple from `rustc -vV`".to_string(),
))
}
/// Map a Rust target triple to an FMI 2.0 `binaries/<platform>` folder name.
fn platform_folder(target: &str) -> Option<&'static str> {
match target {
"x86_64-unknown-linux-gnu" | "x86_64-unknown-linux-musl" => Some("linux64"),
"aarch64-unknown-linux-gnu" | "aarch64-unknown-linux-musl" => Some("linuxaarch64"),
"arm-unknown-linux-gnueabihf" => Some("linuxarm32"),
"i686-unknown-linux-gnu" => Some("linux32"),
"x86_64-pc-windows-msvc" | "x86_64-pc-windows-gnu" => Some("win64"),
"i686-pc-windows-msvc" => Some("win32"),
"x86_64-apple-darwin" | "aarch64-apple-darwin" => Some("darwin64"),
_ => None,
}
}
/// Locate the compiled cdylib by listing the release dir (robust against
/// `Path::exists()` false-negatives observed on some Windows paths).
fn locate_cdylib(stage: &Path, target: &str) -> CliResult<PathBuf> {
let wanted = ["libentropyk_fmu.so", "entropyk_fmi.dll", "libentropyk_fmu.dylib"];
let dirs = [
stage.join("target").join(target).join("release"),
stage.join("target").join("release"),
];
for dir in &dirs {
let hits: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.filter_map(|e| {
let p = e.path();
if wanted.iter().any(|w| p.ends_with(w)) {
Some(p)
} else {
None
}
})
.collect()
})
.unwrap_or_default();
if let Some(p) = hits.into_iter().next() {
return Ok(p);
}
}
Err(CliError::Simulation(format!(
"cdylib not found under {} (looked for {:?})",
stage.join("target").display(),
wanted
)))
}
/// Stage a standalone crate that re-exports the FMI runtime with the model
/// baked into `embedded.rs`.
fn stage_crate(
stage: &Path,
fmi_src: &Path,
manifest_dir: &Path,
model_json: &str,
io_json: &str,
) -> CliResult<()> {
if stage.exists() {
let _ = fs::remove_dir_all(stage);
}
fs::create_dir_all(stage.join("src"))?;
for name in &["lib.rs", "fmi2.rs", "model.rs", "io_map.rs"] {
fs::copy(fmi_src.join(name), stage.join("src").join(name))?;
}
let embedded = format!(
"pub const MODEL_JSON: &str = {lit_model};\n\
pub const IO_JSON: &str = {lit_io};\n\
pub fn load_model() -> Result<(String, String), String> {{\n\
\x20 Ok((MODEL_JSON.to_string(), IO_JSON.to_string()))\n\
}}\n",
lit_model = rust_str_literal(model_json),
lit_io = rust_str_literal(io_json),
);
fs::write(stage.join("src").join("embedded.rs"), embedded)?;
let toml = format!(
"[package]\n\
name = \"fmu_crate\"\n\
version = \"0.1.0\"\n\
edition = \"2021\"\n\n\
[lib]\n\
name = \"entropyk_fmi\"\n\
crate-type = [\"cdylib\"]\n\n\
[dependencies]\n\
entropyk = {{ path = {entropyk_path:?} }}\n\
entropyk-cli = {{ path = {cli_path:?} }}\n\
entropyk-solver = {{ path = {solver_path:?} }}\n\
serde = {{ version = \"1.0\", features = [\"derive\"] }}\n\
serde_json = \"1.0\"\n\
libc = \"0.2\"\n\n\
[workspace]\n",
entropyk_path = manifest_dir.join("crates").join("entropyk"),
cli_path = manifest_dir.join("crates").join("cli"),
solver_path = manifest_dir.join("crates").join("solver"),
);
fs::write(stage.join("Cargo.toml"), toml)?;
Ok(())
}
/// Escape a string as a Rust string literal.
fn rust_str_literal(s: &str) -> String {
let mut out = String::from('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other => out.push(other),
}
}
out.push('"');
out
}
/// `cargo build --release --target <target>` in the staged crate.
fn build_cdylib(stage: &Path, target: &str, verbose: bool) -> CliResult<()> {
let mut cmd = Command::new("cargo");
cmd.current_dir(stage).arg("build").arg("--release");
if !target.is_empty() && target != "host" {
cmd.arg("--target").arg(target);
}
if !verbose {
cmd.arg("-q");
}
let status = cmd
.status()
.map_err(|e| CliError::Simulation(format!("failed to invoke cargo: {e}")))?;
if !status.success() {
return Err(CliError::Simulation(format!(
"cargo build failed for staged FMI crate (target {target})"
)));
}
Ok(())
}
/// Render the FMI 2.0 Co-Sim `modelDescription.xml` from the IO map.
fn render_model_description(model_name: &str, io: &FmuIoSpec) -> String {
let guid = format!("{:x}", md5_fmi_guid(model_name));
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str(&format!(
"<fmiModelDescription fmiVersion=\"2.0\" modelName=\"{model_name}\" guid=\"{{{guid}}}\" \
generationTool=\"entropyk-cli\" variableNamingConvention=\"flat\" \
numberOfEventIndicators=\"0\">\n"
));
out.push_str(" <CoSimulation modelIdentifier=\"entropyk_fmi\" \
canHandleVariableCommunicationStepSize=\"true\" canGetAndSetFMUstate=\"false\" \
canSerializeFMUstate=\"false\"/>\n");
out.push_str(" <ModelVariables>\n");
let mut vr: u32 = 0;
for v in &io.inputs {
out.push_str(&format!(
" <ScalarVariable name=\"{name}\" valueReference=\"{vr}\" \
causality=\"input\" variability=\"continuous\" initial=\"exact\">\n <Real start=\"0.0\"/>\n </ScalarVariable>\n",
name = xml_escape(&v.name)
));
vr += 1;
}
for v in &io.outputs {
out.push_str(&format!(
" <ScalarVariable name=\"{name}\" valueReference=\"{vr}\" \
causality=\"output\" variability=\"continuous\" initial=\"calculated\">\n <Real/>\n </ScalarVariable>\n",
name = xml_escape(&v.name)
));
vr += 1;
}
out.push_str(" </ModelVariables>\n");
out.push_str(" <ModelStructure>\n");
let n_in = io.inputs.len() as u32;
for i in 0..io.outputs.len() as u32 {
out.push_str(&format!(" <Outputs><Unknown index=\"{}\"/></Outputs>\n", n_in + i + 1));
}
out.push_str(" <Derivatives/>\n");
out.push_str(" <InitialUnknowns>\n");
for i in 0..io.outputs.len() as u32 {
out.push_str(&format!(" <Unknown index=\"{}\"/>\n", n_in + i + 1));
}
out.push_str(" </InitialUnknowns>\n");
out.push_str(" </ModelStructure>\n");
out.push_str("</fmiModelDescription>\n");
out
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// Cheap non-crypto hash to derive a stable-ish GUID from the model name.
fn md5_fmi_guid(s: &str) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for b in s.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
/// Assemble the final `.fmu` zip archive.
fn assemble_fmu(
out_path: &Path,
xml: &str,
lib_path: &Path,
platform: &str,
model_json: &str,
io_json: &str,
) -> CliResult<()> {
use std::io::{Read, Write};
let file = fs::File::create(out_path)?;
let mut zip = zip::ZipWriter::new(file);
let opts = zip::write::FileOptions::default();
zip.start_file("modelDescription.xml", opts)
.map_err(|e| CliError::Simulation(format!("zip: {e}")))?;
zip.write_all(xml.as_bytes())?;
let ext = lib_path.extension().and_then(|e| e.to_str()).unwrap_or("so");
let lib_name = format!("binaries/{platform}/entropyk_fmi.{ext}");
zip.start_file(&lib_name, opts)
.map_err(|e| CliError::Simulation(format!("zip: {e}")))?;
let mut lib_file = fs::File::open(lib_path)?;
std::io::copy(&mut lib_file, &mut zip)?;
zip.start_file("resources/config.json", opts)
.map_err(|e| CliError::Simulation(format!("zip: {e}")))?;
zip.write_all(model_json.as_bytes())?;
zip.start_file("resources/fmu_io.json", opts)
.map_err(|e| CliError::Simulation(format!("zip: {e}")))?;
zip.write_all(io_json.as_bytes())?;
zip.finish()
.map_err(|e| CliError::Simulation(format!("zip: {e}")))?;
Ok(())
}

View File

@@ -9,6 +9,7 @@
pub mod batch;
pub mod config;
pub mod error;
pub mod export_fmu;
pub mod qualify;
pub mod rate;
pub mod run;

View File

@@ -10,7 +10,7 @@
//! entropyk-cli batch ./scenarios/ --parallel 4
//! ```
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand};
use colored::Colorize;
@@ -132,6 +132,27 @@ enum Commands {
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
},
/// Compile an Entropyk JSON model into an FMI 2.0 Co-Simulation `.fmu`
/// archive for embedding on a PLC / controller. The model and its IO map are
/// baked into the FMU binary (no filesystem access at runtime).
ExportFmu {
/// Path to the Entropyk model JSON (a `ScenarioConfig`).
#[arg(short, long, value_name = "FILE", alias = "model")]
config: PathBuf,
/// Path to the FMU IO-map JSON (declares PLC inputs/outputs).
#[arg(short, long, value_name = "FILE", alias = "io")]
io: PathBuf,
/// Rust target triple (e.g. `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`,
/// `x86_64-pc-windows-msvc`). Use `host` for the current platform.
#[arg(short, long, value_name = "TRIPLE", default_value = "host")]
target: String,
/// Path to write the `.fmu` archive.
#[arg(short, long, value_name = "FILE")]
output: PathBuf,
},
}
fn main() {
@@ -181,6 +202,12 @@ fn main() {
run_seasonal(config, output, cli.quiet, SeasonalMode::Seer)
}
Commands::Schema { output } => emit_schema(output),
Commands::ExportFmu {
config,
io,
target,
output,
} => run_export_fmu(&config, &io, &target, &output, cli.verbose),
};
match result {
@@ -677,3 +704,27 @@ fn emit_schema(output: Option<PathBuf>) -> Result<(), CliError> {
Ok(())
}
/// `export-fmu` subcommand: compile an Entropyk JSON model into an FMI 2.0
/// Co-Simulation `.fmu` archive.
fn run_export_fmu(
config: &Path,
io: &Path,
target: &str,
output: &Path,
verbose: bool,
) -> Result<(), CliError> {
use entropyk_cli::export_fmu::run_export_fmu as run;
println!("{}", "".repeat(60).cyan());
println!(
"{}",
" ENTROPYK CLI - Export FMU (FMI 2.0 Co-Simulation)"
.cyan()
.bold()
);
println!("{}", "".repeat(60).cyan());
println!();
run(config, io, target, output, verbose)
}

View File

@@ -499,12 +499,19 @@ fn execute_simulation(
}
}
// Fixed EXV orifice opening sets ṁ via the valve — skip compressor
// displacement ṁ closure so DoF stays square (ṁ follows the valve).
let meter_mass_flow_via_exv = expanded_components
.iter()
.any(|c| exv_uses_fixed_orifice(&c.params) && matches!(c.component_type.as_str(), "IsenthalpicExpansionValve" | "EXV"));
for component_config in &expanded_components {
match create_component(
&component_config,
&fluid_id,
Arc::clone(&backend),
auto_t_cond_k,
meter_mass_flow_via_exv,
) {
Ok(component) => match system.add_component_to_circuit(component, circuit_id) {
Ok(node_id) => {
@@ -956,12 +963,13 @@ fn execute_simulation(
.as_str()
{
"IsenthalpicExpansionValve" | "EXV"
if component_config.params.get("orifice_kv").is_some()
if exv_orifice_opening_is_free(&component_config.params)
&& !opening_controlled_exvs.contains(&component_config.name) =>
{
let init = component_config
.params
.get("orifice_opening_init")
.or_else(|| component_config.params.get("opening"))
.and_then(|v| v.as_f64())
.unwrap_or(0.5);
let min = component_config
@@ -1300,9 +1308,10 @@ fn execute_simulation(
let needs_guarded_newton = !config.controls.is_empty()
|| config.circuits.iter().any(|c| {
c.enabled
&& c.components
.iter()
.any(|comp| comp.params.get("orifice_kv").is_some())
&& c.components.iter().any(|comp| {
comp.params.get("orifice_kv").is_some()
|| component_has_free_boundary_pressure(comp)
})
});
if !matches!(
strategy_name.as_str(),
@@ -1720,6 +1729,19 @@ fn resolve_port_index(
}
}
/// Run a simulation from an in-memory config (no file I/O, no JSON re-parse).
///
/// Used by the FMI Co-Simulation binding: the model JSON is parsed once at
/// `fmi2Instantiate`, then each `fmi2DoStep` mutates boundary parameters on the
/// already-parsed `ScenarioConfig` and calls this to re-solve the steady cycle.
///
/// Note: this rebuilds the `System` graph each call. Warm-start (reusing the
/// previous state vector and the built `System`) requires splitting
/// `execute_simulation` into `build_system` + `solve_warm` — tracked as a TODO.
pub fn run_from_config(config: &ScenarioConfig) -> SimulationResult {
execute_simulation(config, "fmu", 0)
}
fn get_param_f64(
params: &std::collections::HashMap<String, serde_json::Value>,
key: &str,
@@ -1745,6 +1767,19 @@ fn param_fix_flag(
}
}
/// `true` when a water/brine boundary source runs with Free pressure (Modelica
/// `MassFlowSource_T`): the liquid (P, h) state is then a floating unknown that
/// a full Newton step can push through the saturation dome (Cp → ∞), so the
/// solve needs the Armijo guard. Moist air is exempt — its h↔T convention is
/// linear and pressure-independent, and the guard only slows it down.
fn component_has_free_boundary_pressure(comp: &crate::config::ComponentConfig) -> bool {
if comp.component_type.as_str() != "BrineSource" {
return false;
}
let default_fix_p = optional_imposed_mass_flow(&comp.params).is_none();
!param_fix_flag(&comp.params, "fix_pressure", default_fix_p)
}
/// Optional imposed mass flow when `fix_mass_flow` is true (default) and a
/// positive `m_flow_kg_s` is present. Free ṁ keeps the numeric value as a
/// documentation/seed hint only (no Dirichlet residual).
@@ -2846,12 +2881,36 @@ fn build_saturated_control(
Ok((bounded_var, controller))
}
/// True when the EXV JSON requests a physical orifice with a **fixed** opening.
/// Requires explicit `orifice_kv` (do not infer from `opening` alone — the UI
/// merges a default opening onto imported examples).
fn exv_uses_fixed_orifice(params: &std::collections::HashMap<String, serde_json::Value>) -> bool {
let kv = params.get("orifice_kv").and_then(|v| v.as_f64());
if kv.is_none() {
return false;
}
let opening = params.get("opening").and_then(|v| v.as_f64());
let fix = params
.get("fix_opening")
.and_then(|v| v.as_bool())
.unwrap_or(true); // Fixed opening is the UI default when orifice is on
fix && opening.is_some()
}
/// Free-actuator orifice path (opening unknown). Requires explicit `orifice_kv`.
fn exv_orifice_opening_is_free(
params: &std::collections::HashMap<String, serde_json::Value>,
) -> bool {
params.get("orifice_kv").and_then(|v| v.as_f64()).is_some() && !exv_uses_fixed_orifice(params)
}
/// Create a component from configuration.
fn create_component(
component_config: &crate::config::ComponentConfig,
_primary_fluid: &entropyk::FluidId,
backend: Arc<dyn entropyk_fluids::FluidBackend>,
auto_t_cond_k: Option<f64>,
meter_mass_flow_via_exv: bool,
) -> CliResult<Box<dyn entropyk::Component>> {
use entropyk::{Condenser, Evaporator, HeatExchanger};
use entropyk_components::heat_exchanger::{FlowConfiguration, LmtdModel};
@@ -3519,15 +3578,21 @@ fn create_component(
.with_fluid_backend(Arc::clone(&backend));
// Emergent-pressure mode: the compressor no longer pins the discharge
// pressure to P_sat(t_cond_k). Instead the shared mass flow is closed by
// the volumetric displacement model ṁ = ρ_suc·V_s·N·η_vol, letting the
// condensing pressure emerge from the downstream condenser ↔ secondary
// balance. Requires `displacement_m3` and `speed_hz`.
// pressure to P_sat(t_cond_k). Normally ṁ is closed by the volumetric
// displacement model. When a sibling EXV uses a *fixed* orifice opening,
// ṁ is metered by the valve instead (energy-only compressor).
if params
.get("emergent_pressure")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
if meter_mass_flow_via_exv {
tracing::info!(
component = %component_config.name,
"EXV fixed orifice meters ṁ — compressor uses emergent energy-only mode"
);
comp = comp.with_emergent_metered_flow();
} else {
use entropyk_components::isentropic_compressor::VolumetricEfficiency;
let displacement_m3 = params
.get("displacement_m3")
@@ -3555,6 +3620,7 @@ fn create_component(
),
};
comp = comp.with_displacement(displacement_m3, speed_hz, vol_eff);
}
// Optional variable-speed-drive (VSD) efficiency map. Enabled when
// a `vsd_reference_speed_hz` is supplied; the quadratic speed
@@ -3629,12 +3695,17 @@ fn create_component(
exv = exv.with_emergent_pressure();
}
// arch-6: physical orifice-flow actuator. `orifice_kv` [m²] enables the
// orifice residual ṁ = Kv·opening·√(2·ρ_in·ΔP); the fractional opening
// becomes a free-actuator solver unknown (registered separately in
// run_simulation so it is wired before finalize()). Emergent-only.
// Physical orifice: ṁ = Kv·opening·√(2·ρ_in·ΔP). Requires explicit orifice_kv.
// - Fixed opening (default): opening is a parameter; compressor uses
// energy-only emergent mode (see meter_mass_flow_via_exv).
// - Free opening (`fix_opening: false`): opening is a free-actuator unknown.
if let Some(kv) = params.get("orifice_kv").and_then(|v| v.as_f64()) {
exv = exv.with_orifice(kv);
let opening = params.get("opening").and_then(|v| v.as_f64()).unwrap_or(1.0);
if exv_uses_fixed_orifice(params) {
exv = exv.with_orifice_fixed(kv, opening);
} else {
exv = exv.with_orifice(kv);
}
}
Ok(Box::new(exv))