Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,4 @@ $RECYCLE.BIN/

# End of https://www.toptal.com/developers/gitignore/api/python,jupyternotebooks,vim,visualstudiocode,pycharm,emacs,linux,macos,windows
boulder/version.py
mixed_reactor_stream.yaml
106 changes: 98 additions & 8 deletions boulder/cantera_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class BoulderPlugins:
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)
mechanism_path_resolver: Optional[Callable[[str], str]] = None


# Global cache to ensure plugins are discovered only once
Expand Down Expand Up @@ -112,7 +113,14 @@ def __init__(
self.mechanism = mechanism or CANTERA_MECHANISM
self.plugins = plugins or get_plugins()
try:
self.gas = ct.Solution(self.mechanism)
# Use plugin mechanism path resolver if available
if self.plugins.mechanism_path_resolver:
resolved_mechanism = self.plugins.mechanism_path_resolver(
self.mechanism
)
else:
resolved_mechanism = self.mechanism
self.gas = ct.Solution(resolved_mechanism)
except Exception as e:
raise ValueError(f"Failed to load mechanism '{self.mechanism}': {e}")
self.reactors: Dict[str, ct.Reactor] = {}
Expand All @@ -137,8 +145,28 @@ def create_reactor(self, reactor_config: Dict[str, Any]) -> ct.Reactor:
reactor_type = reactor_config["type"]
props = reactor_config["properties"]

# Determine mechanism for this node (override allowed via properties.mechanism)
mechanism_override = props.get("mechanism")
if mechanism_override:
try:
# Use plugin mechanism path resolver if available
if self.plugins.mechanism_path_resolver:
resolved_mechanism = self.plugins.mechanism_path_resolver(
mechanism_override
)
else:
resolved_mechanism = mechanism_override
gas_obj = ct.Solution(resolved_mechanism)
except Exception as e: # fail-fast with clear message
raise ValueError(
f"Failed to load per-node mechanism '{mechanism_override}' for node "
f"'{reactor_config.get('id', '<unknown>')}': {e}"
) from e
else:
gas_obj = self.gas

# Set gas state
self.gas.TPX = (
gas_obj.TPX = (
props.get("temperature", 300),
props.get("pressure", 101325),
self.parse_composition(props.get("composition", "N2:1")),
Expand All @@ -148,19 +176,27 @@ def create_reactor(self, reactor_config: Dict[str, Any]) -> ct.Reactor:
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)
reactor = ct.IdealGasReactor(gas_obj)
elif reactor_type == "IdealGasMoleReactor":
reactor = ct.IdealGasMoleReactor(gas_obj)
elif reactor_type == "IdealGasConstPressureReactor":
reactor = ct.IdealGasConstPressureReactor(self.gas)
reactor = ct.IdealGasConstPressureReactor(gas_obj)
elif reactor_type == "IdealGasConstPressureMoleReactor":
reactor = ct.IdealGasConstPressureMoleReactor(self.gas)
reactor = ct.IdealGasConstPressureMoleReactor(gas_obj)
elif reactor_type == "Reservoir":
reactor = ct.Reservoir(self.gas)
reactor = ct.Reservoir(gas_obj)
else:
raise ValueError(f"Unsupported reactor type: {reactor_type}")

# Set the reactor name to match the config ID
reactor.name = reactor_config["id"]

# Track per-node mechanism for downstream tools (e.g., sim2stone)
try:
reactor._boulder_mechanism = mechanism_override or self.mechanism # type: ignore[attr-defined]
except Exception:
pass

# Optional grouping: propagate group name to reactor for downstream tools
props_group = reactor_config.get("properties", {}).get("group")
if props_group is None:
Expand Down Expand Up @@ -201,9 +237,11 @@ def create_connection(self, conn_config: Dict[str, Any]):
# Default MassFlowController implementation
mfc = ct.MassFlowController(source, target)
mfc.mass_flow_rate = float(props.get("mass_flow_rate", 0.1))
flow_device = mfc
elif conn_type == "Valve":
valve = ct.Valve(source, target)
valve.valve_coeff = float(props.get("valve_coeff", 1.0))
flow_device = valve
elif conn_type == "Wall":
# Handle walls as energy connections (e.g., torch power or losses)
# After validation, electric_power_kW is converted to kilowatts if it had units
Expand Down Expand Up @@ -371,11 +409,18 @@ def __init__(
"""
# 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)
# Use plugin mechanism path resolver if available
if self.plugins.mechanism_path_resolver:
resolved_mechanism = self.plugins.mechanism_path_resolver(
self.mechanism
)
else:
resolved_mechanism = self.mechanism
self.gas = ct.Solution(resolved_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] = {}
Expand Down Expand Up @@ -447,6 +492,51 @@ def build_network_and_code(
)
except Exception:
pass
elif typ == "IdealGasConstPressureReactor":
self.code_lines.append(f"{rid} = ct.IdealGasConstPressureReactor(gas)")
self.code_lines.append(f"{rid}.name = '{rid}'")
self.reactors[rid] = ct.IdealGasConstPressureReactor(self.gas)
self.reactors[rid].name = rid
try:
self.reactors[rid].group_name = str(
props.get("group", props.get("group_name", ""))
)
self.code_lines.append(
f"{rid}.group_name = '{props.get('group', props.get('group_name', ''))}'"
)
except Exception:
pass
elif typ == "IdealGasConstPressureMoleReactor":
self.code_lines.append(
f"{rid} = ct.IdealGasConstPressureMoleReactor(gas)"
)
self.code_lines.append(f"{rid}.name = '{rid}'")
self.reactors[rid] = ct.IdealGasConstPressureMoleReactor(self.gas)
self.reactors[rid].name = rid
try:
self.reactors[rid].group_name = str(
props.get("group", props.get("group_name", ""))
)
self.code_lines.append(
f"{rid}.group_name = '{props.get('group', props.get('group_name', ''))}'"
)
except Exception:
pass
elif typ == "IdealGasMoleReactor":
# Available in Cantera 3.x
self.code_lines.append(f"{rid} = ct.IdealGasMoleReactor(gas)")
self.code_lines.append(f"{rid}.name = '{rid}'")
self.reactors[rid] = ct.IdealGasMoleReactor(self.gas) # type: ignore[attr-defined]
self.reactors[rid].name = rid
try:
self.reactors[rid].group_name = str(
props.get("group", props.get("group_name", ""))
)
self.code_lines.append(
f"{rid}.group_name = '{props.get('group', props.get('group_name', ''))}'"
)
except Exception:
pass
elif typ == "Reservoir":
self.code_lines.append(f"{rid} = ct.Reservoir(gas)")
self.code_lines.append(f"{rid}.name = '{rid}'")
Expand Down
16 changes: 11 additions & 5 deletions boulder/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def normalize_config(config: Dict[str, Any]) -> Dict[str, Any]:
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"}
standard_fields = {"id", "metadata", "mechanism"}
type_keys = [k for k in node.keys() if k not in standard_fields]

if type_keys:
Expand All @@ -148,6 +148,11 @@ def normalize_config(config: Dict[str, Any]) -> Dict[str, Any]:
node["properties"] = (
properties if isinstance(properties, dict) else {}
)
# If a node-level mechanism is present, move it into properties for internal use
if "mechanism" in node:
props = node.setdefault("properties", {})
if "mechanism" not in props:
props["mechanism"] = node["mechanism"]

# Normalize connections
if "connections" in normalized:
Expand Down Expand Up @@ -295,10 +300,11 @@ def convert_to_stone_format(config: dict) -> dict:
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", {}),
}
props = dict(node.get("properties", {}) or {})
mech_override = props.pop("mechanism", None)
stone_node = {"id": node["id"], node_type: props}
if mech_override is not None:
stone_node["mechanism"] = mech_override
stone_config["nodes"].append(stone_node)

# Convert connections (same structure in new format)
Expand Down
Loading