diff --git a/README.md b/README.md index 8b79c11..7001728 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ simulation: time_step: 0.001 max_time: 10.0 -components: +nodes: - id: reactor1 IdealGasReactor: temperature: 1000 # K diff --git a/boulder/app.py b/boulder/app.py index 4518136..c930202 100644 --- a/boulder/app.py +++ b/boulder/app.py @@ -3,7 +3,11 @@ import dash import dash_bootstrap_components as dbc -from . import callbacks +# Import cantera_converter early to ensure plugins are loaded at app startup +from . import ( + callbacks, + cantera_converter, # noqa: F401 +) from .config import ( get_config_from_path_with_comments, get_initial_config, @@ -12,6 +16,11 @@ from .layout import get_layout from .styles import CYTOSCAPE_STYLESHEET +# Create a single, shared converter instance for the app +# This ensures that the same set of discovered plugins is used everywhere. +CONVERTER = cantera_converter.CanteraConverter() + + # Initialize the Dash app with Bootstrap app = dash.Dash( __name__, diff --git a/boulder/callbacks/graph_callbacks.py b/boulder/callbacks/graph_callbacks.py index 651073a..33bbdd2 100644 --- a/boulder/callbacks/graph_callbacks.py +++ b/boulder/callbacks/graph_callbacks.py @@ -77,11 +77,11 @@ def add_reactor(trigger_data: dict, config: dict) -> Any: "type": trigger_data["type"], "properties": trigger_data["properties"], } - if any(comp["id"] == new_reactor["id"] for comp in config["components"]): + if any(node["id"] == new_reactor["id"] for node in config["nodes"]): return dash.no_update - new_components = [*config.get("components", []), new_reactor] - new_config = {**config, "components": new_components} + new_nodes = [*config.get("nodes", []), new_reactor] + new_config = {**config, "nodes": new_nodes} return new_config # STEP 1: Trigger MFC addition and close modal immediately diff --git a/boulder/callbacks/modal_callbacks.py b/boulder/callbacks/modal_callbacks.py index 00e16c2..edac762 100644 --- a/boulder/callbacks/modal_callbacks.py +++ b/boulder/callbacks/modal_callbacks.py @@ -50,9 +50,9 @@ def update_mfc_options(config: dict) -> tuple[list[dict], list[dict]]: "Reservoir", ] options = [ - {"label": comp["id"], "value": comp["id"]} - for comp in config.get("components", []) - if comp.get("type") in valid_types + {"label": node["id"], "value": node["id"]} + for node in config.get("nodes", []) + if node.get("type") in valid_types ] return options, options @@ -225,7 +225,7 @@ def generate_reactor_id(is_open: bool, config: dict) -> Union[str, Any]: if not is_open: return dash.no_update - existing_ids = [comp.get("id", "") for comp in config.get("components", [])] + existing_ids = [node.get("id", "") for node in config.get("nodes", [])] i = 1 while f"reactor_{i}" in existing_ids: @@ -273,9 +273,9 @@ def set_default_mfc_values(is_open: bool, config: dict) -> tuple: return dash.no_update, dash.no_update, dash.no_update reactor_ids = [ - comp.get("id") - for comp in config.get("components", []) - if comp.get("type") + node.get("id") + for node in config.get("nodes", []) + if node.get("type") in ["IdealGasReactor", "ConstVolReactor", "ConstPReactor", "Reservoir"] ] diff --git a/boulder/callbacks/properties_callbacks.py b/boulder/callbacks/properties_callbacks.py index 22aa5d5..ed3a175 100644 --- a/boulder/callbacks/properties_callbacks.py +++ b/boulder/callbacks/properties_callbacks.py @@ -26,9 +26,9 @@ def show_properties_editable(last_selected, edit_mode, config): if last_selected and last_selected.get("type") == "node": node_id = last_selected["data"]["id"] # Find the latest node data from config - for comp in config["components"]: - if comp["id"] == node_id: - node_data = [comp] + for node in config["nodes"]: + if node["id"] == node_id: + node_data = [node] break elif last_selected and last_selected.get("type") == "edge": edge_id = last_selected["data"]["id"] @@ -267,11 +267,11 @@ def save_properties(n_clicks, node_data, edge_data, config, values, ids): if node_data: data = node_data[0] comp_id = data["id"] - new_components = [] - for comp in config["components"]: - if comp["id"] == comp_id: + new_nodes = [] + for node in config["nodes"]: + if node["id"] == comp_id: # Ensure properties dict exists - props = dict(comp.get("properties", {})) + props = dict(node.get("properties", {})) for v, i in zip(values, ids): key = i["prop"] # Convert to float if key is temperature or pressure @@ -282,10 +282,10 @@ def save_properties(n_clicks, node_data, edge_data, config, values, ids): props[key] = v else: props[key] = v - new_components.append({**comp, "properties": props}) + new_nodes.append({**node, "properties": props}) else: - new_components.append(comp) - return {**config, "components": new_components} + new_nodes.append(node) + return {**config, "nodes": new_nodes} elif edge_data: data = edge_data[0] conn_id = data["id"] diff --git a/boulder/callbacks/simulation_callbacks.py b/boulder/callbacks/simulation_callbacks.py index c88679e..448342c 100644 --- a/boulder/callbacks/simulation_callbacks.py +++ b/boulder/callbacks/simulation_callbacks.py @@ -65,6 +65,7 @@ def handle_mechanism_upload( Output("simulation-error-display", "style"), Output("simulation-results-card", "style"), Output("simulation-data", "data"), + Output("simulation-running", "data", allow_duplicate=True), ], [ Input("run-simulation", "n_clicks"), @@ -87,8 +88,14 @@ def run_simulation( mechanism_select: str, custom_mechanism: str, uploaded_filename: str, - ) -> Tuple[Any, Any, Any, str, Any, Dict[str, str], Dict[str, str], Dict[str, Any]]: - from ..cantera_converter import CanteraConverter, DualCanteraConverter + ) -> Tuple[ + Any, Any, Any, str, Any, Dict[str, str], Dict[str, str], Dict[str, Any], bool + ]: + from ..cantera_converter import ( + CanteraConverter, + DualCanteraConverter, + get_plugins, + ) from ..config import USE_DUAL_CONVERTER from ..utils import apply_theme_to_figure @@ -102,6 +109,7 @@ def run_simulation( {"display": "none"}, {"display": "none"}, {}, + False, ) # Determine the mechanism to use @@ -123,13 +131,16 @@ def run_simulation( try: if USE_DUAL_CONVERTER: - dual_converter = DualCanteraConverter(mechanism=mechanism) + dual_converter = DualCanteraConverter( + mechanism=mechanism, plugins=get_plugins() + ) network, results, code_str = dual_converter.build_network_and_code( config ) else: - single_converter = CanteraConverter(mechanism=mechanism) - network, results = single_converter.build_network(config) + # Build using a fresh converter with discovered plugins + converter = CanteraConverter(mechanism=mechanism, plugins=get_plugins()) + network, results = converter.build_network(config) code_str = "" # Build initial plots from the first available reactor (no strict need) @@ -187,11 +198,14 @@ def run_simulation( {"display": "none"}, {"display": "block"}, simulation_data, + False, ) except Exception as e: message = f"Error during simulation: {str(e)}" print(f"ERROR: {message}") + # IMPORTANT: update simulation-data with a non-empty payload so the + # overlay-clearing callback (listening to simulation-data) fires. return ( go.Figure(), go.Figure(), @@ -200,8 +214,15 @@ def run_simulation( message, {"display": "block", "color": "red"}, {"display": "none"}, - {}, + {"error": message}, + False, ) + finally: + # Safety net: if any future refactor throws before returns, + # the overlay will still be cleared by downstream callback since we + # always return a value in both success and error paths above. + # No-op here intentionally. + ... # Overlay style now handled client-side for zero-lag responsiveness diff --git a/boulder/cantera_converter.py b/boulder/cantera_converter.py index b865735..fb4c03a 100644 --- a/boulder/cantera_converter.py +++ b/boulder/cantera_converter.py @@ -1,6 +1,11 @@ +import importlib import json import logging -from typing import Any, Dict, List, Optional, Tuple +import math +import os +from dataclasses import dataclass, field +from importlib.metadata import entry_points +from typing import Any, Callable, Dict, List, Optional, Tuple, Union import cantera as ct # type: ignore @@ -10,16 +15,98 @@ logger = logging.getLogger(__name__) +# Custom builder/hook types +ReactorBuilder = Callable[ + [Union["CanteraConverter", "DualCanteraConverter"], Dict[str, Any]], ct.Reactor +] +ConnectionBuilder = Callable[ + [Union["CanteraConverter", "DualCanteraConverter"], Dict[str, Any]], ct.FlowDevice +] +PostBuildHook = Callable[ + [Union["CanteraConverter", "DualCanteraConverter"], Dict[str, Any]], None +] + + +@dataclass +class BoulderPlugins: + """A container for discovered Boulder plugins.""" + + reactor_builders: Dict[str, ReactorBuilder] = field(default_factory=dict) + connection_builders: Dict[str, ConnectionBuilder] = field(default_factory=dict) + post_build_hooks: List[PostBuildHook] = field(default_factory=list) + + +# Global cache to ensure plugins are discovered only once +_PLUGIN_CACHE: Optional[BoulderPlugins] = None + + +def get_plugins() -> BoulderPlugins: + """Discover and load all Boulder plugins, returning them in a container. + + This function is idempotent and caches the results. + """ + global _PLUGIN_CACHE + if _PLUGIN_CACHE is not None: + return _PLUGIN_CACHE + + plugins = BoulderPlugins() + + # Discover from entry points + try: + eps = entry_points() + eps_group = getattr(eps, "select", None) + selected = ( + eps_group(group="boulder.plugins") + if eps_group + else eps.get("boulder.plugins", []) + ) + for ep in selected: + try: + plugin_func = ep.load() + if callable(plugin_func): + plugin_func(plugins) + except Exception as e: + logger.warning(f"Failed to load plugin entry point {ep}: {e}") + except Exception as e: + logger.debug(f"Entry point discovery failed: {e}") + + # Discover from environment variable + raw = os.environ.get("BOULDER_PLUGINS", "").strip() + if raw: + for mod_name in [ + m.strip() for m in raw.replace(";", ",").split(",") if m.strip() + ]: + try: + mod = importlib.import_module(mod_name) + registrar = getattr(mod, "register_plugins", None) + if callable(registrar): + registrar(plugins) + except Exception as e: + logger.warning( + f"Failed to import BOULDER_PLUGINS module '{mod_name}': {e}" + ) + + _PLUGIN_CACHE = plugins + return plugins + + class CanteraConverter: - def __init__(self, mechanism: Optional[str] = None) -> None: + def __init__( + self, + mechanism: Optional[str] = None, + plugins: Optional[BoulderPlugins] = None, + ) -> None: # Use provided mechanism or fall back to config default self.mechanism = mechanism or CANTERA_MECHANISM + self.plugins = plugins or get_plugins() try: self.gas = ct.Solution(self.mechanism) except Exception as e: raise ValueError(f"Failed to load mechanism '{self.mechanism}': {e}") self.reactors: Dict[str, ct.Reactor] = {} + self.reactor_meta: Dict[str, Dict[str, Any]] = {} self.connections: Dict[str, ct.FlowDevice] = {} + self.walls: Dict[str, Any] = {} self.network: ct.ReactorNet = None self.last_network: ct.ReactorNet = ( None # Store the last successfully built network @@ -45,8 +132,15 @@ def create_reactor(self, reactor_config: Dict[str, Any]) -> ct.Reactor: self.parse_composition(props.get("composition", "N2:1")), ) - if reactor_type == "IdealGasReactor": + # Custom builder extension point + if reactor_type in self.plugins.reactor_builders: + reactor = self.plugins.reactor_builders[reactor_type](self, reactor_config) + elif reactor_type == "IdealGasReactor": reactor = ct.IdealGasReactor(self.gas) + elif reactor_type == "IdealGasConstPressureReactor": + reactor = ct.IdealGasConstPressureReactor(self.gas) + elif reactor_type == "IdealGasConstPressureMoleReactor": + reactor = ct.IdealGasConstPressureMoleReactor(self.gas) elif reactor_type == "Reservoir": reactor = ct.Reservoir(self.gas) else: @@ -68,24 +162,50 @@ def create_reactor(self, reactor_config: Dict[str, Any]) -> ct.Reactor: return reactor - def create_connection(self, conn_config: Dict[str, Any]) -> ct.FlowDevice: - """Create a Cantera flow device from configuration.""" + def create_connection(self, conn_config: Dict[str, Any]): + """Create a Cantera connection (flow device or wall) from configuration.""" conn_type = conn_config["type"] props = conn_config["properties"] - source = self.reactors[conn_config["source"]] - target = self.reactors[conn_config["target"]] - - if conn_type == "MassFlowController": - device = ct.MassFlowController(source, target) - device.mass_flow_rate = props.get("mass_flow_rate", 0.1) + # Ensure source/target exist (create placeholder Reservoirs if needed) + src_id = conn_config["source"] + tgt_id = conn_config["target"] + if src_id not in self.reactors: + # Create a benign reservoir for external sources like 'Electricity'/'Losses' + self.gas.TPX = (300, 101325, {"N2": 1.0}) + self.reactors[src_id] = ct.Reservoir(self.gas) + self.reactors[src_id].name = src_id + if tgt_id not in self.reactors: + self.gas.TPX = (300, 101325, {"N2": 1.0}) + self.reactors[tgt_id] = ct.Reservoir(self.gas) + self.reactors[tgt_id].name = tgt_id + source = self.reactors[src_id] + target = self.reactors[tgt_id] + + # Custom builder extension point + if conn_type in self.plugins.connection_builders: + flow_device = self.plugins.connection_builders[conn_type](self, conn_config) + elif conn_type == "MassFlowController": + # Default MassFlowController implementation + mfc = ct.MassFlowController(source, target) + mfc.mass_flow_rate = float(props.get("mass_flow_rate", 0.1)) elif conn_type == "Valve": - device = ct.Valve(source, target) - device.valve_coeff = props.get("valve_coeff", 1.0) + valve = ct.Valve(source, target) + valve.valve_coeff = float(props.get("valve_coeff", 1.0)) + elif conn_type == "Wall": + # Handle walls as energy connections (e.g., torch power or losses) + electric_power_kW = float(props.get("electric_power_kW", 0.0)) + torch_eff = float(props.get("torch_eff", 1.0)) + gen_eff = float(props.get("gen_eff", 1.0)) + # Net heat rate into the target from the source (W) + Q_watts = electric_power_kW * 1e3 * torch_eff * gen_eff + wall = ct.Wall(source, target, A=1.0, Q=Q_watts, name=conn_config["id"]) + self.walls[conn_config["id"]] = wall + return wall else: raise ValueError(f"Unsupported connection type: {conn_type}") - return device + return flow_device def build_network( self, config: Dict[str, Any] @@ -95,19 +215,23 @@ def build_network( self.reactors.clear() self.connections.clear() - # Create reactors - for comp in config["components"]: - if comp["type"] == "IdealGasReactor" or comp["type"] == "Reservoir": - self.reactors[comp["id"]] = self.create_reactor(comp) + # Create reactors (built-ins or via registered custom builders) + for node in config["nodes"]: + r = self.create_reactor(node) + r.name = node["id"] + self.reactors[node["id"]] = r # Create connections for conn in config["connections"]: - if conn["type"] == "MassFlowController" or conn["type"] == "Valve": + if conn["type"] in ("MassFlowController", "Valve"): self.connections[conn["id"]] = self.create_connection(conn) + elif conn["type"] == "Wall": + # Create and track walls separately + self.create_connection(conn) - # Create network - only include IdealGasReactors, not Reservoirs + # Create network - include all Cantera reactors (exclude pure Reservoirs) reactor_list = [ - r for r in self.reactors.values() if isinstance(r, ct.IdealGasReactor) + r for r in self.reactors.values() if not isinstance(r, ct.Reservoir) ] if not reactor_list: raise ValueError("No IdealGasReactors found in the network") @@ -119,6 +243,10 @@ def build_network( self.network.atol = 1e-8 # Relaxed absolute tolerance self.network.max_steps = 10000 # Increase maximum steps + # Apply post-build hooks for custom modifications + for hook in self.plugins.post_build_hooks: + hook(self, config) + # Run simulation with smaller time steps times: List[float] = [] @@ -216,7 +344,11 @@ def load_config(self, filepath: str) -> Dict[str, Any]: class DualCanteraConverter: - def __init__(self, mechanism: Optional[str] = None) -> None: + def __init__( + self, + mechanism: Optional[str] = None, + plugins: Optional[BoulderPlugins] = None, + ) -> None: """Initialize DualCanteraConverter. Executes the Cantera network as before. @@ -229,8 +361,11 @@ def __init__(self, mechanism: Optional[str] = None) -> None: self.gas = ct.Solution(self.mechanism) except Exception as e: raise ValueError(f"Failed to load mechanism '{self.mechanism}': {e}") + self.plugins = plugins or get_plugins() self.reactors: Dict[str, ct.Reactor] = {} + self.reactor_meta: Dict[str, Dict[str, Any]] = {} self.connections: Dict[str, ct.FlowDevice] = {} + self.walls: Dict[str, Any] = {} self.network: ct.ReactorNet = None self.code_lines: List[str] = [] self.last_network: ct.ReactorNet = ( @@ -262,16 +397,29 @@ def build_network_and_code( self.network = None # Reactors - for comp in config["components"]: - rid = comp["id"] - typ = comp["type"] - props = comp["properties"] + for node in config["nodes"]: + rid = node["id"] + typ = node["type"] + props = node["properties"] temp = props.get("temperature", 300) pres = props.get("pressure", 101325) compo = props.get("composition", "N2:1") self.code_lines.append(f"gas.TPX = ({temp}, {pres}, '{compo}')") self.gas.TPX = (temp, pres, self.parse_composition(compo)) - if typ == "IdealGasReactor": + # Plugin-backed custom reactor types + if typ in self.plugins.reactor_builders: + reactor = self.plugins.reactor_builders[typ](self, node) + reactor.name = rid + self.reactors[rid] = reactor + try: + self.reactors[rid].group_name = str( + props.get("group", props.get("group_name", "")) + ) + except Exception: + pass + # Code gen: note plugin usage + self.code_lines.append(f"# Plugin reactor {typ} -> created as '{rid}'") + elif typ == "IdealGasReactor": self.code_lines.append(f"{rid} = ct.IdealGasReactor(gas)") self.code_lines.append(f"{rid}.name = '{rid}'") self.reactors[rid] = ct.IdealGasReactor(self.gas) @@ -310,8 +458,15 @@ def build_network_and_code( src = conn["source"] tgt = conn["target"] props = conn["properties"] - if typ == "MassFlowController": - mfr = props.get("mass_flow_rate", 0.1) + # Plugin-backed custom connections + if typ in self.plugins.connection_builders: + device = self.plugins.connection_builders[typ](self, conn) + self.connections[cid] = device + self.code_lines.append( + f"# Plugin connection {typ} -> created as '{cid}'" + ) + elif typ == "MassFlowController": + mfr = float(props.get("mass_flow_rate", 0.1)) self.code_lines.append(f"{cid} = ct.MassFlowController({src}, {tgt})") self.code_lines.append(f"{cid}.mass_flow_rate = {mfr}") self.connections[cid] = ct.MassFlowController( @@ -319,20 +474,32 @@ def build_network_and_code( ) self.connections[cid].mass_flow_rate = mfr elif typ == "Valve": - coeff = props.get("valve_coeff", 1.0) + coeff = float(props.get("valve_coeff", 1.0)) self.code_lines.append(f"{cid} = ct.Valve({src}, {tgt})") self.code_lines.append(f"{cid}.valve_coeff = {coeff}") self.connections[cid] = ct.Valve(self.reactors[src], self.reactors[tgt]) self.connections[cid].valve_coeff = coeff + elif typ == "Wall": + # Handle walls as energy connections (e.g., torch power or losses) + electric_power_kW = float(props.get("electric_power_kW", 0.0)) + torch_eff = float(props.get("torch_eff", 1.0)) + gen_eff = float(props.get("gen_eff", 1.0)) + Q_watts = electric_power_kW * 1e3 * torch_eff * gen_eff + self.code_lines.append( + f"{cid} = ct.Wall({src}, {tgt}, A=1.0, Q={Q_watts}, name='{cid}')" + ) + wall = ct.Wall( + self.reactors[src], self.reactors[tgt], A=1.0, Q=Q_watts, name=cid + ) + self.walls[cid] = wall + # Note: Walls are not flow devices, so we track them separately else: self.code_lines.append(f"# Unsupported connection type: {typ}") raise ValueError(f"Unsupported connection type: {typ}") - # ReactorNet + # ReactorNet (include all non-Reservoir reactors) reactor_ids = [ - comp["id"] - for comp in config["components"] - if comp["type"] == "IdealGasReactor" + rid for rid, r in self.reactors.items() if not isinstance(r, ct.Reservoir) ] self.code_lines.append(f"network = ct.ReactorNet([{', '.join(reactor_ids)}])") self.network = ct.ReactorNet([self.reactors[rid] for rid in reactor_ids]) @@ -343,6 +510,10 @@ def build_network_and_code( self.network.atol = 1e-8 self.network.max_steps = 10000 + # Apply post-build hooks from plugins + for hook in self.plugins.post_build_hooks: + hook(self, config) + # Simulation loop (example) self.code_lines.append( """# Run the simulation\nfor t in range(0, 10, 1):\n network.advance(t)\n """ @@ -368,42 +539,32 @@ def build_network_and_code( for t in range(0, 10, 1): try: self.network.advance(t) - times.append(t) - for reactor in reactor_list: - reactor_id = getattr(reactor, "name", "") or str(id(reactor)) - sol_arrays[reactor_id].append( - T=reactor.thermo.T, P=reactor.thermo.P, X=reactor.thermo.X - ) - reactors_series[reactor_id]["T"].append(reactor.thermo.T) - reactors_series[reactor_id]["P"].append(reactor.thermo.P) - for species_name, x_value in zip( - self.gas.species_names, reactor.thermo.X - ): - reactors_series[reactor_id]["X"][species_name].append( - float(x_value) - ) except Exception as e: - logger.warning(f"Warning at t={t}: {str(e)}") - if times: - times.append(t) - for reactor in reactor_list: - reactor_id = getattr(reactor, "name", "") or str(id(reactor)) - last_idx = -1 - last_T = reactors_series[reactor_id]["T"][last_idx] - last_P = reactors_series[reactor_id]["P"][last_idx] - last_X = [ - reactors_series[reactor_id]["X"][s][last_idx] - for s in self.gas.species_names - ] - sol_arrays[reactor_id].append(T=last_T, P=last_P, X=last_X) - reactors_series[reactor_id]["T"].append(last_T) - reactors_series[reactor_id]["P"].append(last_P) - for species_name, x_value in zip( - self.gas.species_names, last_X - ): - reactors_series[reactor_id]["X"][species_name].append( - float(x_value) - ) + # Fail fast for Dual converter so the GUI shows the error immediately + raise RuntimeError(f"Cantera advance failed at t={t}s: {e}") from e + + times.append(t) + for reactor in reactor_list: + reactor_id = getattr(reactor, "name", "") or str(id(reactor)) + T = reactor.thermo.T + P = reactor.thermo.P + X_vec = reactor.thermo.X + # Detect non-finite states early and fail fast + if not ( + math.isfinite(T) + and math.isfinite(P) + and all(math.isfinite(float(x)) for x in X_vec) + ): + raise RuntimeError( + f"Non-finite state detected at t={t}s for reactor '{reactor_id}'" + ) + sol_arrays[reactor_id].append(T=T, P=P, X=X_vec) + reactors_series[reactor_id]["T"].append(T) + reactors_series[reactor_id]["P"].append(P) + for species_name, x_value in zip(self.gas.species_names, X_vec): + reactors_series[reactor_id]["X"][species_name].append( + float(x_value) + ) results: Dict[str, Any] = { "time": times, "reactors": reactors_series, diff --git a/boulder/cli.py b/boulder/cli.py index 426e5b3..007575a 100644 --- a/boulder/cli.py +++ b/boulder/cli.py @@ -57,6 +57,9 @@ def main(argv: list[str] | None = None) -> None: if args.config: os.environ["BOULDER_CONFIG_PATH"] = args.config + # Import cantera_converter early to ensure plugins are loaded at app startup + from . import cantera_converter # noqa: F401 + # Import after environment is set so app initialization can read it from .app import run_server diff --git a/boulder/config.py b/boulder/config.py index 66c2303..2c31739 100644 --- a/boulder/config.py +++ b/boulder/config.py @@ -64,35 +64,88 @@ def load_config_file_with_comments(config_path: str): def normalize_config(config: Dict[str, Any]) -> Dict[str, Any]: """Normalize configuration from YAML with 🪨 STONE standard to internal format. - The 🪨 STONE standard uses component types as keys: - - id: reactor1 - IdealGasReactor: - temperature: 1000 - - Converts to internal format: - - id: reactor1 - type: IdealGasReactor - properties: - temperature: 1000 + The 🪨 STONE standard format: + - nodes: list of components (reactors, reservoirs, etc.) + - connections: list of connections between nodes + - phases: chemistry/phase configuration (e.g., gas mechanisms) + - settings: simulation-level settings + - metadata: optional configuration metadata + + Converted to the internal format used by converters: + - nodes: list with { id, type, properties } + - connections: list with { id, type, properties, source, target } + - simulation: dict with mechanism selections and settings + + Example + ------- + + 🪨 STONE format:: + + nodes: + - id: reactor1 + IdealGasReactor: + temperature: 1000 + + Internal format:: + + nodes: + - id: reactor1 + type: IdealGasReactor + properties: + temperature: 1000 """ normalized = config.copy() - # Normalize components - if "components" in normalized: - for component in normalized["components"]: - if "type" not in component: + # Require new STONE schema keys + if isinstance(normalized, dict): + if "nodes" not in normalized: + raise ValueError( + "STONE format required: top-level 'nodes' missing. " + "Please update your YAML configuration to use the new STONE schema with 'nodes', " + "'phases', and 'settings'." + ) + # Merge phases/settings into simulation + phases = normalized.pop("phases", None) + settings = normalized.pop("settings", None) + if phases or settings: + sim = dict(normalized.get("simulation", {}) or {}) + if phases and isinstance(phases, dict): + sim["phases"] = phases + # Flatten gas mechanisms for downstream consumers + gas = ( + phases.get("gas", {}) + if isinstance(phases.get("gas", {}), dict) + else {} + ) + mech = gas.get("mechanism") + mech_reac = gas.get("mechanism_reac") + mech_torch = gas.get("mechanism_torch") + if mech is not None: + sim.setdefault("mechanism", mech) + if mech_reac is not None: + sim.setdefault("mechanism_reac", mech_reac) + if mech_torch is not None: + sim.setdefault("mechanism_torch", mech_torch) + if settings and isinstance(settings, dict): + sim.update(settings) + normalized["simulation"] = sim + + # Normalize nodes + if "nodes" in normalized: + for node in normalized["nodes"]: + if "type" not in node: # Find the type key (anything that's not id, metadata, etc.) standard_fields = {"id", "metadata"} - type_keys = [k for k in component.keys() if k not in standard_fields] + type_keys = [k for k in node.keys() if k not in standard_fields] if type_keys: type_name = type_keys[0] # Use the first type key found - properties = component[type_name] + properties = node[type_name] # Remove the type key and add type + properties - del component[type_name] - component["type"] = type_name - component["properties"] = ( + del node[type_name] + node["type"] = type_name + node["properties"] = ( properties if isinstance(properties, dict) else {} ) @@ -200,32 +253,46 @@ def get_config_from_path_with_comments(config_path: str) -> tuple[Dict[str, Any] def convert_to_stone_format(config: dict) -> dict: - """Convert internal format back to YAML with 🪨 STONE standard for file saving.""" + """Convert internal format back to new STONE schema for file saving.""" stone_config = {} - # Copy metadata and simulation sections as-is + # Copy metadata section as-is if "metadata" in config: stone_config["metadata"] = config["metadata"] - if "simulation" in config: - stone_config["simulation"] = config["simulation"] - - # Convert components - if "components" in config: - stone_config["components"] = [] - for component in config["components"]: - # Build component with id first, then type - component_type = component.get("type", "IdealGasReactor") - stone_component = { - "id": component["id"], - component_type: component.get("properties", {}), + + # Extract phases and settings from simulation section + simulation = config.get("simulation", {}) + if simulation: + # Extract phases information + if "phases" in simulation: + stone_config["phases"] = simulation["phases"] + + # Extract settings (everything except phases and mechanism info) + settings = {} + for key, value in simulation.items(): + if key not in ["phases", "mechanism", "mechanism_reac", "mechanism_torch"]: + settings[key] = value + + if settings: + stone_config["settings"] = settings + + # Convert nodes to STONE format + if "nodes" in config: + stone_config["nodes"] = [] + for node in config["nodes"]: + # Build node with id first, then type as key containing properties + node_type = node.get("type", "IdealGasReactor") + stone_node = { + "id": node["id"], + node_type: node.get("properties", {}), } - stone_config["components"].append(stone_component) + stone_config["nodes"].append(stone_node) - # Convert connections + # Convert connections (same structure in new format) if "connections" in config: stone_config["connections"] = [] for connection in config["connections"]: - # Build connection with id first, then type, then source/target + # Build connection with id first, then type as key, then source/target connection_type = connection.get("type", "MassFlowController") stone_connection = { "id": connection["id"], @@ -392,7 +459,7 @@ def _update_yaml_array_preserving_comments(original_array, new_array): def _update_yaml_item_preserving_comments(original_item, new_item): """Update a single YAML item while preserving its STONE format structure. - This function handles the specific case of components and connections + This function handles the specific case of nodes and connections which have a special structure in STONE format. """ from ruamel.yaml.comments import CommentedMap diff --git a/boulder/utils.py b/boulder/utils.py index 1aa878c..9b4c77b 100644 --- a/boulder/utils.py +++ b/boulder/utils.py @@ -18,8 +18,8 @@ def config_to_cyto_elements(config: Dict[str, Any]) -> List[Dict[str, Any]]: created_groups: set[str] = set() # Add nodes (reactors) - for component in config.get("components", []): - properties = component.get("properties", {}) + for node in config.get("nodes", []): + properties = node.get("properties", {}) # Determine group (if any) from properties group_name = ( @@ -45,9 +45,9 @@ def config_to_cyto_elements(config: Dict[str, Any]) -> List[Dict[str, Any]]: ) node_data: Dict[str, Any] = { - "id": component["id"], - "label": component["id"], - "type": component["type"], + "id": node["id"], + "label": node["id"], + "type": node["type"], "properties": properties, } diff --git a/configs/README.md b/configs/README.md index 63011f3..bdbc2d4 100644 --- a/configs/README.md +++ b/configs/README.md @@ -13,7 +13,7 @@ **Traditional YAML format:** ```yaml -components: +nodes: - id: reactor1 type: IdealGasReactor properties: @@ -24,7 +24,7 @@ components: **YAML with 🪨 STONE standard:** ```yaml -components: +nodes: - id: reactor1 IdealGasReactor: temperature: 1000 # K @@ -57,7 +57,7 @@ simulation: relative_tolerance: 1.0e-6 absolute_tolerance: 1.0e-9 -components: +nodes: - id: component_id ComponentType: property1: value1 @@ -78,7 +78,7 @@ connections: #### IdealGasReactor ```yaml -components: +nodes: - id: reactor1 IdealGasReactor: temperature: 1000 # K @@ -90,7 +90,7 @@ components: #### Reservoir ```yaml -components: +nodes: - id: inlet Reservoir: temperature: 300 # K @@ -140,7 +140,7 @@ simulation: max_time: 10.0 solver: "CVODE_BDF" -components: +nodes: - id: reactor1 IdealGasReactor: temperature: 1000 # K @@ -162,7 +162,7 @@ connections: ### 📁 sample_configs2.yaml -Extended configuration with multiple components: +Extended configuration with multiple nodes: ```yaml metadata: @@ -170,7 +170,7 @@ metadata: description: "Multi-component reactor system with different flow controllers" version: "2.0" -components: +nodes: - id: reactor1 IdealGasReactor: temperature: 1200 # K @@ -213,7 +213,7 @@ metadata: description: "Complex multi-reactor network with interconnected streams" version: "3.0" -components: +nodes: - id: reactor1 IdealGasReactor: temperature: 1100 # K @@ -262,7 +262,7 @@ metadata: simulation: mechanism: "gri30.yaml" -components: +nodes: - id: res_in Reservoir: temperature: 300 # K diff --git a/configs/default.yaml b/configs/default.yaml index 37670ae..4a3325f 100644 --- a/configs/default.yaml +++ b/configs/default.yaml @@ -3,15 +3,18 @@ metadata: description: "Simple configuration with one reactor and one reservoir" version: "1.0" -simulation: - mechanism: "gri30.yaml" +phases: + gas: + mechanism: "gri30.yaml" + +settings: time_step: 0.001 # s max_time: 10.0 # s solver: "CVODE_BDF" relative_tolerance: 1.0e-6 absolute_tolerance: 1.0e-9 -components: +nodes: - id: reactor1 IdealGasReactor: temperature: 1000 # K diff --git a/configs/grouped_nodes.yaml b/configs/grouped_nodes.yaml index 730ce89..6d4451d 100644 --- a/configs/grouped_nodes.yaml +++ b/configs/grouped_nodes.yaml @@ -3,10 +3,11 @@ metadata: description: "Two reactors solved together, displayed under a common group" version: "1.0" -simulation: - mechanism: "gri30.yaml" +phases: + gas: + mechanism: "gri30.yaml" -components: +nodes: - id: res_in Reservoir: temperature: 300 # K diff --git a/tests/test_unit.py b/tests/test_unit.py index 372bd2d..57594b3 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -19,20 +19,20 @@ def test_get_initial_config_structure(self): config = get_initial_config() assert isinstance(config, dict) - assert "components" in config + assert "nodes" in config assert "connections" in config - assert isinstance(config["components"], list) + assert isinstance(config["nodes"], list) assert isinstance(config["connections"], list) def test_get_initial_config_components(self): """Test initial config components have required fields.""" config = get_initial_config() - for component in config["components"]: - assert "id" in component - assert "type" in component - assert "properties" in component - assert isinstance(component["properties"], dict) + for node in config["nodes"]: + assert "id" in node + assert "type" in node + assert "properties" in node + assert isinstance(node["properties"], dict) @pytest.mark.unit @@ -41,14 +41,14 @@ class TestBoulderUtils: def test_config_to_cyto_elements_empty_config(self): """Test config conversion with empty config.""" - config = {"components": [], "connections": []} + config = {"nodes": [], "connections": []} elements = config_to_cyto_elements(config) assert elements == [] def test_config_to_cyto_elements_with_connection(self): """Test config conversion with reactor and connection.""" config = { - "components": [ + "nodes": [ {"id": "reactor1", "type": "IdealGasReactor", "properties": {}}, {"id": "reactor2", "type": "IdealGasReactor", "properties": {}}, ], @@ -109,23 +109,19 @@ def test_add_reactor_callback_logic(self): def test_duplicate_reactor_detection(self): """Test duplicate reactor ID detection logic.""" config = { - "components": [ + "nodes": [ {"id": "existing-reactor", "type": "IdealGasReactor", "properties": {}} ] } # Test adding reactor with existing ID new_reactor_id = "existing-reactor" - has_duplicate = any( - comp["id"] == new_reactor_id for comp in config["components"] - ) + has_duplicate = any(node["id"] == new_reactor_id for node in config["nodes"]) assert has_duplicate is True # Test adding reactor with new ID new_reactor_id = "new-reactor" - has_duplicate = any( - comp["id"] == new_reactor_id for comp in config["components"] - ) + has_duplicate = any(node["id"] == new_reactor_id for node in config["nodes"]) assert has_duplicate is False def test_mfc_validation_logic(self): @@ -171,19 +167,19 @@ def test_json_config_validation(self): """Test JSON configuration validation.""" # Test valid JSON valid_json = ( - '{"components": [{"id": "r1", "type": "IdealGasReactor", "properties": {}}], ' + '{"nodes": [{"id": "r1", "type": "IdealGasReactor", "properties": {}}], ' '"connections": []}' ) try: parsed = json.loads(valid_json) - assert "components" in parsed + assert "nodes" in parsed assert "connections" in parsed - assert len(parsed["components"]) == 1 + assert len(parsed["nodes"]) == 1 except json.JSONDecodeError: pytest.fail("Valid JSON should parse successfully") # Test invalid JSON - invalid_json = '{"components": [}, "connections": []}' + invalid_json = '{"nodes": [}, "connections": []}' with pytest.raises(json.JSONDecodeError): json.loads(invalid_json) diff --git a/tests/test_yaml_comment_system.py b/tests/test_yaml_comment_system.py index 2d8325c..af1fb5e 100644 --- a/tests/test_yaml_comment_system.py +++ b/tests/test_yaml_comment_system.py @@ -46,7 +46,7 @@ def sample_yaml_with_comments(self): dt: 0.01 # seconds - integration time step # Reactor components with detailed comments and units -components: +nodes: - id: "reactor1" # Primary combustion chamber - high temperature operation IdealGasReactor: @@ -126,8 +126,8 @@ def test_load_yaml_string_with_comments(self, sample_yaml_with_comments): # Verify the data structure is loaded correctly assert result["metadata"]["name"] == "Test Configuration" - assert result["components"][0]["id"] == "reactor1" - assert result["components"][0]["IdealGasReactor"]["temperature"] == 1000.0 + assert result["nodes"][0]["id"] == "reactor1" + assert result["nodes"][0]["IdealGasReactor"]["temperature"] == 1000.0 assert result["connections"][0]["id"] == "mfc1" assert result["connections"][0]["MassFlowController"]["mass_flow_rate"] == 0.001 @@ -142,7 +142,7 @@ def test_yaml_to_string_with_comments(self, sample_yaml_with_comments): # Verify it's a valid YAML string with substantial content assert isinstance(result, str) assert "metadata:" in result - assert "components:" in result + assert "nodes:" in result assert "connections:" in result assert len(result) > 100 @@ -159,7 +159,7 @@ def test_update_yaml_preserving_comments(self, sample_yaml_with_comments): "version": "2.0", }, "simulation": {"end_time": 2.0, "dt": 0.02}, - "components": [ + "nodes": [ { "id": "reactor1", "IdealGasReactor": { @@ -178,17 +178,15 @@ def test_update_yaml_preserving_comments(self, sample_yaml_with_comments): assert updated_data["metadata"]["name"] == "Updated Configuration" assert updated_data["metadata"]["version"] == "2.0" assert updated_data["simulation"]["end_time"] == 2.0 - assert updated_data["components"][0]["IdealGasReactor"]["temperature"] == 1100.0 + assert updated_data["nodes"][0]["IdealGasReactor"]["temperature"] == 1100.0 def test_preserves_numeric_types(self, sample_yaml_with_comments): """Test that numeric types are preserved correctly.""" data = load_yaml_string_with_comments(sample_yaml_with_comments) # Check that numbers are loaded as proper types - assert isinstance( - data["components"][0]["IdealGasReactor"]["temperature"], float - ) - assert isinstance(data["components"][0]["IdealGasReactor"]["pressure"], float) + assert isinstance(data["nodes"][0]["IdealGasReactor"]["temperature"], float) + assert isinstance(data["nodes"][0]["IdealGasReactor"]["pressure"], float) assert isinstance(data["simulation"]["end_time"], float) assert isinstance(data["simulation"]["dt"], float) @@ -198,8 +196,8 @@ def test_preserves_string_types(self, sample_yaml_with_comments): # Check that strings are loaded correctly assert isinstance(data["metadata"]["name"], str) - assert isinstance(data["components"][0]["id"], str) - assert isinstance(data["components"][0]["IdealGasReactor"]["composition"], str) + assert isinstance(data["nodes"][0]["id"], str) + assert isinstance(data["nodes"][0]["IdealGasReactor"]["composition"], str) class TestYAMLCommentRoundTrip: @@ -216,7 +214,7 @@ def sample_yaml_with_comments(self): simulation: end_time: 1.0 # seconds -components: +nodes: - id: "test_reactor" IdealGasReactor: temperature: 1200.0 # K @@ -232,16 +230,16 @@ def test_yaml_to_internal_to_stone_roundtrip(self, sample_yaml_with_comments): internal_config = normalize_config(loaded_data) # Verify internal format is correct - assert internal_config["components"][0]["type"] == "IdealGasReactor" - assert "properties" in internal_config["components"][0] - assert internal_config["components"][0]["properties"]["temperature"] == 1200.0 + assert internal_config["nodes"][0]["type"] == "IdealGasReactor" + assert "properties" in internal_config["nodes"][0] + assert internal_config["nodes"][0]["properties"]["temperature"] == 1200.0 # Convert back to STONE format stone_config = convert_to_stone_format(internal_config) # Verify STONE format - assert "IdealGasReactor" in stone_config["components"][0] - assert stone_config["components"][0]["id"] == "test_reactor" + assert "IdealGasReactor" in stone_config["nodes"][0] + assert stone_config["nodes"][0]["id"] == "test_reactor" # Convert to YAML string yaml_string = yaml_to_string_with_comments(stone_config) @@ -286,47 +284,36 @@ def test_comment_preservation_with_updates(self, sample_yaml_with_comments): assert "1300" in result_yaml # Updated temperature assert "Updated Round Trip Test" in result_yaml - def test_stone_format_integration_with_comments(self, sample_yaml_with_comments): - """Test integration between STONE format and comment preservation.""" + def test_stone_format_round_trip_with_comments(self, sample_yaml_with_comments): + """Test that STONE format configurations survive round-trip processing with comment preservation. + + Expectation: After converting STONE→internal→STONE→comment preservation→YAML→reload, + the final configuration should contain the original reactor data with correct values. + """ from boulder.config import normalize_config - # Load original YAML with comments + # Simulate the full application workflow for config processing original_data = load_yaml_string_with_comments(sample_yaml_with_comments) - - # Convert to internal format (as the app does) internal_config = normalize_config(original_data) - - # Convert back to STONE format (for editing) stone_config = convert_to_stone_format(internal_config) - - # Update preserving original structure updated_data = _update_yaml_preserving_comments(original_data, stone_config) - - # Convert back to YAML string final_yaml = yaml_to_string_with_comments(updated_data) + final_data = load_yaml_string_with_comments(final_yaml) - # Verify content is preserved - assert "test_reactor" in final_yaml - assert "1200" in final_yaml + # Single expectation: The test_reactor should exist with correct temperature and pressure values + assert "nodes" in final_data, "Final data should have nodes section" + assert len(final_data["nodes"]) > 0, "Should have at least one node" - # Load the final result to verify it's valid - final_data = load_yaml_string_with_comments(final_yaml) + node = final_data["nodes"][0] + assert node["id"] == "test_reactor", "Node ID should be preserved" + + # After round-trip processing, we expect internal format (type and properties) + assert node["type"] == "IdealGasReactor", "Node type should be IdealGasReactor" + assert "properties" in node, "Node should have properties section" - # Check both possible formats for robustness - if "IdealGasReactor" in final_data["components"][0]: - # STONE format - assert ( - final_data["components"][0]["IdealGasReactor"]["temperature"] == 1200.0 - ) - elif "type" in final_data["components"][0]: - # Internal format (current behavior) - assert final_data["components"][0]["type"] == "IdealGasReactor" - assert final_data["components"][0]["properties"]["temperature"] == 1200.0 - else: - # Unexpected format - pytest.fail( - f"Unexpected component format: {list(final_data['components'][0].keys())}" - ) + reactor_data = node["properties"] + assert reactor_data["temperature"] == 1200.0, "Temperature should be preserved" + assert reactor_data["pressure"] == 101325.0, "Pressure should be preserved" class TestYAMLCommentIntegration: @@ -341,7 +328,7 @@ def test_initial_config_loading_with_comments(self): assert len(original_yaml) > 0 # Verify the original YAML contains expected sections - if "components:" in original_yaml: + if "nodes:" in original_yaml: assert "metadata:" in original_yaml except FileNotFoundError: @@ -355,7 +342,7 @@ def test_file_upload_simulation(self): sample_yaml = """# Test upload metadata: name: "Upload Test" -components: +nodes: - id: "upload_reactor" IdealGasReactor: temperature: 900.0 # K @@ -374,7 +361,7 @@ def test_file_upload_simulation(self): # Verify the data loaded correctly assert decoded["metadata"]["name"] == "Upload Test" - assert decoded["components"][0]["IdealGasReactor"]["temperature"] == 900.0 + assert decoded["nodes"][0]["IdealGasReactor"]["temperature"] == 900.0 # Verify we can convert back with comments preserved yaml_output = yaml_to_string_with_comments(decoded) @@ -426,7 +413,7 @@ def test_comment_preservation_edge_cases(self): empty_sections_yaml = """# Config with empty sections metadata: name: "Empty Sections" -components: [] # no components +nodes: [] # no nodes connections: [] # no connections """ @@ -439,7 +426,7 @@ def test_comment_preservation_edge_cases(self): def test_units_preservation_examples(self): """Test preservation of various unit formats in comments.""" units_yaml = """# Configuration with various unit formats -components: +nodes: - id: "test" IdealGasReactor: temperature: 1000.0 # K (Kelvin) @@ -455,7 +442,7 @@ def test_units_preservation_examples(self): # Verify numeric values are preserved correctly reloaded = load_yaml_string_with_comments(result) - reactor_props = reloaded["components"][0]["IdealGasReactor"] + reactor_props = reloaded["nodes"][0]["IdealGasReactor"] assert reactor_props["temperature"] == 1000.0 assert reactor_props["pressure"] == 101325 @@ -485,7 +472,7 @@ def test_file_round_trip(self, tmp_path): dt: 0.01 # seconds - time step # Reactor components with detailed comments -components: +nodes: - id: "file_reactor" # Ideal gas reactor for file testing IdealGasReactor: @@ -505,7 +492,7 @@ def test_file_round_trip(self, tmp_path): # Verify the data was loaded correctly assert loaded_data["metadata"]["name"] == "File Test Configuration" - assert loaded_data["components"][0]["IdealGasReactor"]["temperature"] == 1000.0 + assert loaded_data["nodes"][0]["IdealGasReactor"]["temperature"] == 1000.0 if __name__ == "__main__":