Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled

Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 16:35:31 +02:00
parent 88620790d6
commit 5bd180b5b8
1363 changed files with 101041 additions and 58547 deletions

View File

@@ -6,6 +6,7 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use crate::error::{CliError, CliResult};
@@ -69,6 +70,16 @@ pub struct ScenarioConfig {
/// endpoints). Resolved and routed into the appropriate circuit during flattening.
#[serde(default)]
pub connections: Vec<EdgeConfig>,
/// Workspace directory containing reusable `.ekmod` module files.
///
/// Each `.ekmod` file is a JSON [`SubsystemTemplate`] whose module name is
/// derived from the filename (e.g. `BaseChiller.ekmod` → `"BaseChiller"`).
/// Modules are loaded before [`Self::flatten_instances`] and injected into
/// every template's `subsystems` map so cross-file references resolve. If
/// absent, the CLI also checks `.entropyk/modules/` relative to the config
/// file as a fallback.
#[serde(default)]
pub workspace: Option<String>,
/// Optional metadata.
#[serde(default)]
pub metadata: Option<HashMap<String, serde_json::Value>>,
@@ -103,6 +114,20 @@ pub struct SubsystemTemplate {
/// endpoint. `connections` reference these as `"{instance}.{external_port}"`.
#[serde(default)]
pub ports: HashMap<String, String>,
/// Nested subsystem templates (for recursive composition). A template can
/// instantiate other templates declared in this map via [`Self::instances`].
/// Flattened recursively at load time — the solver never sees the nesting.
#[serde(default)]
pub subsystems: HashMap<String, SubsystemTemplate>,
/// Instantiations of nested [`Self::subsystems`] templates. Each instance is
/// expanded recursively, with component names prefixed by `"{name}."`.
#[serde(default)]
pub instances: Vec<InstanceConfig>,
/// External connections between nested instances (and/or the template's own
/// atomic components). Endpoints use `"instance.external_port"` or
/// `"component:port"` syntax, resolved during recursive flattening.
#[serde(default)]
pub connections: Vec<EdgeConfig>,
}
/// An instantiation of a [`SubsystemTemplate`].
@@ -479,6 +504,8 @@ impl ScenarioConfig {
let config: Self = serde_json::from_str(&content).map_err(CliError::InvalidConfig)?;
let mut config = config;
// Auto-load workspace modules (.ekmod files) before flattening.
config.load_workspace_auto(Some(path))?;
config.flatten_instances()?;
config.validate()?;
@@ -489,6 +516,9 @@ impl ScenarioConfig {
pub fn from_json(json: &str) -> CliResult<Self> {
let config: Self = serde_json::from_str(json).map_err(CliError::InvalidConfig)?;
let mut config = config;
// For from_json, only explicit `workspace` field is checked (no file
// path context for auto-discovery of .entropyk/modules/).
config.load_workspace_auto::<&std::path::Path>(None)?;
config.flatten_instances()?;
config.validate()?;
Ok(config)
@@ -506,6 +536,74 @@ impl ScenarioConfig {
.unwrap_or_else(|e| format!("{{\"error\":\"failed to serialize schema: {e}\"}}"))
}
/// Load reusable `.ekmod` module files from a workspace directory and merge
/// them into [`Self::subsystems`].
///
/// Each `.ekmod` file is a JSON [`SubsystemTemplate`]; the module name is the
/// filename without extension (e.g. `BaseChiller.ekmod` → `"BaseChiller"`).
/// If a subsystem with the same name already exists in the config, the
/// workspace module is skipped (inline definitions take precedence).
pub fn load_workspace(&mut self, dir: &Path) -> CliResult<()> {
if !dir.is_dir() {
return Err(CliError::Config(format!(
"workspace directory '{}' does not exist or is not a directory",
dir.display()
)));
}
let mut loaded = 0;
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("ekmod") {
continue;
}
let name = path.file_stem().and_then(|s| s.to_str()).ok_or_else(|| {
CliError::Config(format!("invalid .ekmod filename: '{}'", path.display()))
})?;
// Inline subsystems take precedence over workspace files.
if self.subsystems.contains_key(name) {
tracing::debug!(module = name, "workspace module skipped (inline override)");
continue;
}
let json = std::fs::read_to_string(&path)?;
let template: SubsystemTemplate = serde_json::from_str(&json).map_err(|e| {
CliError::Config(format!(
"failed to parse workspace module '{}': {}",
path.display(),
e
))
})?;
self.subsystems.insert(name.to_string(), template);
loaded += 1;
}
if loaded > 0 {
tracing::info!(loaded, dir = ?dir, "Loaded workspace modules");
}
Ok(())
}
/// Auto-discover and load a workspace directory. Checks, in order:
/// 1. The `workspace` field in the config (if set)
/// 2. `.entropyk/modules/` relative to `config_path` (if given)
///
/// Silently skips if neither exists (no workspace = no extra modules).
pub fn load_workspace_auto<P: AsRef<Path>>(&mut self, config_path: Option<P>) -> CliResult<()> {
// 1. Explicit workspace path
let ws_path = self.workspace.clone();
if let Some(ref ws) = ws_path {
return self.load_workspace(Path::new(ws));
}
// 2. Auto-discover .entropyk/modules/ next to the config file
if let Some(p) = config_path {
if let Some(parent) = p.as_ref().parent() {
let auto = parent.join(".entropyk").join("modules");
if auto.is_dir() {
return self.load_workspace(&auto);
}
}
}
Ok(())
}
/// Flatten every [`InstanceConfig`] into concrete circuits.
///
/// Each instance's components are cloned with names prefixed by `"{instance}."`,
@@ -521,6 +619,20 @@ impl ScenarioConfig {
return Ok(());
}
// The top-level `subsystems` map serves as the workspace registry for
// recursive flattening: when a nested template's `instances` reference
// a subsystem by name, the resolver first checks the template's own
// `subsystems` map, then falls back to this top-level registry. This
// lets `.ekmod` files cross-reference each other without cloning.
// Recursively flatten nested templates first: a SubsystemTemplate may
// itself contain subsystems/instances/connections. After this pass,
// every template in self.subsystems is flat (only atomic components).
let workspace = self.subsystems.clone();
for tmpl in self.subsystems.values_mut() {
*tmpl = flatten_template_recursive_with_workspace(tmpl, &workspace)?;
}
// Expand each instance into its target circuit. Collect first (immutable
// borrow of templates), then apply (mutable borrow of circuits).
struct Expansion {
@@ -773,6 +885,118 @@ fn prefix_internal_endpoint(endpoint: &str, prefix: &str) -> String {
}
}
/// Recursively flatten a [`SubsystemTemplate`]: expand nested `instances` into
/// concrete components and edges, producing a flat template with only atomic
/// components. After this pass, the template's `subsystems`/`instances`/
/// `connections` are consumed (emptied) and only `components`/`edges`/`ports`
/// remain. A no-op clone when the template has no nested instances.
///
/// # Recursive composition
///
/// A template can itself instantiate other templates (declared in its own
/// `subsystems` map). Each nested instance is expanded depth-first, with
/// component names prefixed cumulatively (`"parent.child.comp"`). Exposed
/// ports of child instances are resolved to their concrete internal endpoints
/// via the child template's `ports` map, then prefixed.
fn flatten_template_recursive(template: &SubsystemTemplate) -> CliResult<SubsystemTemplate> {
flatten_template_recursive_with_workspace(template, &HashMap::new())
}
fn flatten_template_recursive_with_workspace(
template: &SubsystemTemplate,
workspace: &HashMap<String, SubsystemTemplate>,
) -> CliResult<SubsystemTemplate> {
// Base case: no nested instances to expand.
if template.instances.is_empty() {
return Ok(template.clone());
}
let mut flat_components = template.components.clone();
let mut flat_edges = template.edges.clone();
let mut instance_port_map: HashMap<String, String> = HashMap::new();
for inst in &template.instances {
// Resolve child template: first check local subsystems, then workspace.
let child_template = template
.subsystems
.get(&inst.template)
.or_else(|| workspace.get(&inst.template))
.ok_or_else(|| {
CliError::Config(format!(
"nested instance '{}' references unknown subsystem template '{}'. Available locally: {} | workspace: {}",
inst.name,
inst.template,
template.subsystems.keys().cloned().collect::<Vec<_>>().join(", "),
workspace.keys().cloned().collect::<Vec<_>>().join(", "),
))
})?;
// Recurse: flatten the child template first (depth-first), passing the
// workspace so cross-file references resolve at every nesting level.
let flat_child = flatten_template_recursive_with_workspace(child_template, workspace)?;
// Resolve params: child defaults overlaid by instance overrides.
let mut params = flat_child.params.clone();
for (k, v) in &inst.params {
params.insert(k.clone(), v.clone());
}
let prefix = format!("{}.", inst.name);
// Expand child components with prefix + param substitution.
for comp in &flat_child.components {
let mut c = comp.clone();
c.name = format!("{}{}", prefix, comp.name);
substitute_component_params(&mut c, &params).map_err(|missing| {
CliError::Config(format!(
"nested instance '{}' component '{}' references undefined parameter '${}'",
inst.name, comp.name, missing
))
})?;
flat_components.push(c);
}
// Expand child edges with prefix.
for edge in &flat_child.edges {
flat_edges.push(EdgeConfig {
from: prefix_internal_endpoint(&edge.from, &prefix),
to: prefix_internal_endpoint(&edge.to, &prefix),
});
}
// Map exposed ports: "instance.port" → "prefix.internal_comp:port".
for (port_name, internal_path) in &flat_child.ports {
let resolved = prefix_internal_endpoint(internal_path, &prefix);
instance_port_map.insert(format!("{}.{}", inst.name, port_name), resolved);
}
}
// Resolve the template's own connections (between nested instances and/or
// atomic components). Endpoints referencing "instance.port" are resolved
// via the map above; literal "component:port" endpoints are kept as-is.
for conn in &template.connections {
let from = instance_port_map
.get(&conn.from)
.cloned()
.unwrap_or_else(|| conn.from.clone());
let to = instance_port_map
.get(&conn.to)
.cloned()
.unwrap_or_else(|| conn.to.clone());
flat_edges.push(EdgeConfig { from, to });
}
Ok(SubsystemTemplate {
params: template.params.clone(),
components: flat_components,
edges: flat_edges,
ports: template.ports.clone(),
subsystems: HashMap::new(),
instances: Vec::new(),
connections: Vec::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;