Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled
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:
@@ -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, ¶ms).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::*;
|
||||
|
||||
Reference in New Issue
Block a user