diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..6e5159f --- /dev/null +++ b/.cursorignore @@ -0,0 +1,2 @@ +example_config.yaml # Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) +*.yaml diff --git a/README.md b/README.md index c60f02c..e69358a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A web-based tool for visually constructing and simulating Cantera ReactorNet sys - Support for flow devices (MassFlowController, Valve) - Real-time property editing - Simulation capabilities with time-series plots -- JSON configuration import/export +- YAML configuration files with 🪨 STONE standard (elegant format) ![screenshot](https://private-user-images.githubusercontent.com/16088743/452821416-9d904892-a17c-4c60-8efa-c2aa7abf7da8.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDk0NjYzMDUsIm5iZiI6MTc0OTQ2NjAwNSwicGF0aCI6Ii8xNjA4ODc0My80NTI4MjE0MTYtOWQ5MDQ4OTItYTE3Yy00YzYwLThlZmEtYzJhYTdhYmY3ZGE4LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA2MDklMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwNjA5VDEwNDY0NVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWE5NTAzYzllYjVhODc2Njc1ZWM5N2NiODBkMjMxOWMwNmNjNzcyNDBlMThhY2U1YzlhMmFlZDVhOThhMzQ1ODYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.P-wD297SHbNk1nuTgsBof3vmKukntOBWRnpgi7e774o) @@ -42,37 +42,37 @@ pip install -e . # install in editable mode - Run simulations - View results -## Configuration Format - -The application uses a JSON-based configuration format: - -```json -{ - "components": [ - { - "id": "reactor1", - "type": "IdealGasReactor", - "properties": { - "temperature": 1000, - "pressure": 101325, - "composition": "CH4:1,O2:2,N2:7.52" - } - } - ], - "connections": [ - { - "id": "mfc1", - "type": "MassFlowController", - "source": "res1", - "target": "reactor1", - "properties": { - "mass_flow_rate": 0.1 - } - } - ] -} +## YAML Configuration with 🪨 STONE Standard + +Boulder uses **YAML format with 🪨 STONE standard** (**Structured Type-Oriented Network Expressions**) - an elegant configuration format where component types become keys containing their properties: + +```yaml +metadata: + name: "Reactor Configuration" + version: "1.0" + +simulation: + mechanism: "gri30.yaml" + time_step: 0.001 + max_time: 10.0 + +components: + - id: reactor1 + IdealGasReactor: + temperature: 1000 # K + pressure: 101325 # Pa + composition: "CH4:1,O2:2,N2:7.52" + +connections: + - id: mfc1 + MassFlowController: + mass_flow_rate: 0.1 # kg/s + source: res1 + target: reactor1 ``` +See [`examples/README.md`](examples/README.md) for comprehensive YAML with 🪨 STONE standard documentation and examples. + ## Supported Components ### Reactors diff --git a/boulder/callbacks/clientside_callbacks.py b/boulder/callbacks/clientside_callbacks.py index 2362400..52daa37 100644 --- a/boulder/callbacks/clientside_callbacks.py +++ b/boulder/callbacks/clientside_callbacks.py @@ -5,118 +5,6 @@ def register_callbacks(app) -> None: # type: ignore """Register client-side callbacks.""" - # Custom edge creation from custom event - app.clientside_callback( - """ - function(n_clicks) { - if (!window.cy) return null; - - // Listen for the create-edge event - if (!window._edgeListenerAdded) { - window._edgeListenerAdded = true; - window.addEventListener('create-edge', function(e) { - const { source, target } = e.detail; - // Add the edge to Cytoscape - window.cy.add({ - group: 'edges', - data: { - source: source, - target: target, - label: 'New Edge' // You can customize this - } - }); - }); - } - return null; - } - """, - Output("reactor-graph", "tapEdgeData"), - Input("reactor-graph", "tapNode"), - prevent_initial_call=True, - ) - - # Setup client-side callback to handle edge creation - app.clientside_callback( - """ - function(n_clicks) { - // This is a trigger to create an initial placeholder - return []; - } - """, - Output("hidden-edge-data", "children"), - Input("reactor-graph", "id"), - prevent_initial_call=True, - ) - - # Update the store when an edge is created - app.clientside_callback( - """ - function(n_clicks) { - // Initialize event listener if not done already - if (!window.edgeEventInitialized) { - window.edgeEventInitialized = true; - - document.addEventListener('edgeCreate', function(e) { - if (e && e.detail) { - console.log('Edge creation event received:', e.detail); - // Update the store with new edge data - window.dash_clientside.no_update = false; - return e.detail; - } - return window.dash_clientside.no_update; - }); - } - - // Initially return no update - return window.dash_clientside.no_update; - } - """, - Output("edge-added-store", "data"), - Input("initialization-trigger", "children"), - prevent_initial_call=True, - ) - - # Edgehandles setup - app.clientside_callback( - """ - function(n_intervals) { - if (window.edgehandles_setup_complete) { - return window.dash_clientside.no_update; - } - const cy = ( - document.getElementById('reactor-graph') && - document.getElementById('reactor-graph')._cyreg && - document.getElementById('reactor-graph')._cyreg.cy - ); - if (!cy || typeof cy.edgehandles !== 'function') { - console.log("Waiting for Cytoscape and the .edgehandles() function..."); - return window.dash_clientside.no_update; - } - // --- One-time setup --- - window.boulder_edge_queue = []; - document.addEventListener('boulder_edge_created', e => { - window.boulder_edge_queue.push(e.detail); - }); - const eh = cy.edgehandles({ - preview: true, snap: true, - complete: (sourceNode, targetNode, addedEles) => { - document.dispatchEvent(new CustomEvent('boulder_edge_created', { - detail: { source: sourceNode.id(), target: targetNode.id(), ts: Date.now() } - })); - } - }); - document.addEventListener('keydown', e => { if (e.key === 'Shift') eh.enable(); }); - document.addEventListener('keyup', e => { if (e.key === 'Shift') eh.disable(); }); - eh.disable(); - window.edgehandles_setup_complete = true; - console.log('Edgehandles initialized.'); - return window.dash_clientside.no_update; - } - """, - Output("init-dummy-output", "children"), - Input("init-interval", "n_intervals"), - ) - # Keyboard shortcut for Ctrl+Enter app.clientside_callback( """ @@ -125,9 +13,14 @@ def register_callbacks(app) -> None: # type: ignore window._boulder_keyboard_shortcut = true; document.addEventListener('keydown', function(e) { if (e.ctrlKey && e.key === 'Enter') { - // Check if Add Reactor modal is open + // Check if Add Reactor modal is open and MFC modal is not var addReactorModal = document.getElementById('add-reactor-modal'); - if (addReactorModal && addReactorModal.classList.contains('show')) { + var addMFCModal = document.getElementById('add-mfc-modal'); + if ( + addReactorModal && + addReactorModal.classList.contains('show') && + (!addMFCModal || !addMFCModal.classList.contains('show')) + ) { var btn = document.getElementById('add-reactor'); if (btn && !btn.disabled) btn.click(); } else { diff --git a/boulder/callbacks/config_callbacks.py b/boulder/callbacks/config_callbacks.py index e1905b7..42c405d 100644 --- a/boulder/callbacks/config_callbacks.py +++ b/boulder/callbacks/config_callbacks.py @@ -1,11 +1,58 @@ -"""Callbacks for configuration file handling and JSON editing.""" +"""Callbacks for configuration file handling and YAML editing.""" import base64 -import json import dash +import yaml from dash import Input, Output, State, dcc, html +# Configure YAML to preserve dict order without Python tags +yaml.add_representer( + dict, + lambda dumper, data: dumper.represent_mapping( + "tag:yaml.org,2002:map", data.items() + ), +) + + +def convert_to_stone_format(config: dict) -> dict: + """Convert internal format back to YAML with 🪨 STONE standard for file saving.""" + stone_config = {} + + # Copy metadata and simulation sections 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", {}), + } + stone_config["components"].append(stone_component) + + # Convert connections + if "connections" in config: + stone_config["connections"] = [] + for connection in config["connections"]: + # Build connection with id first, then type, then source/target + connection_type = connection.get("type", "MassFlowController") + stone_connection = { + "id": connection["id"], + connection_type: connection.get("properties", {}), + "source": connection["source"], + "target": connection["target"], + } + stone_config["connections"].append(stone_connection) + + return stone_config + def register_callbacks(app) -> None: # type: ignore """Register config-related callbacks.""" @@ -103,8 +150,22 @@ def handle_config_upload_delete( content_type, content_string = upload_contents.split(",") try: decoded_string = base64.b64decode(content_string).decode("utf-8") - decoded = json.loads(decoded_string) - return decoded, upload_filename + # Only accept YAML files with 🪨 STONE standard + if upload_filename and upload_filename.lower().endswith( + (".yaml", ".yml") + ): + from ..config import normalize_config + + decoded = yaml.safe_load(decoded_string) + # Normalize from YAML with 🪨 STONE standard to internal format + normalized = normalize_config(decoded) + return normalized, upload_filename + else: + print( + "Only YAML format with 🪨 STONE standard (.yaml/.yml) files are supported. Got:" + f" {upload_filename}" + ) + return dash.no_update, "" except Exception as e: print(f"Error processing uploaded file: {e}") return dash.no_update, "" @@ -113,24 +174,29 @@ def handle_config_upload_delete( else: raise dash.exceptions.PreventUpdate - # Separate callback to handle config JSON edit save + # Separate callback to handle config YAML edit save @app.callback( Output("current-config", "data", allow_duplicate=True), - [Input("save-config-json-edit-btn", "n_clicks")], + [Input("save-config-yaml-edit-btn", "n_clicks")], [ - State("config-json-edit-textarea", "value"), + State("config-yaml-edit-textarea", "value"), State("current-config", "data"), ], prevent_initial_call=True, ) - def handle_config_json_edit_save( + def handle_config_yaml_edit_save( save_edit_n_clicks: int, edit_text: str, old_config: dict, ) -> dict: if save_edit_n_clicks: try: - new_config = json.loads(edit_text) + from ..config import normalize_config + + # Parse YAML with 🪨 STONE standard + parsed_config = yaml.safe_load(edit_text) + # Normalize to internal format + new_config = normalize_config(parsed_config) return new_config except Exception: return old_config @@ -138,17 +204,22 @@ def handle_config_json_edit_save( # Callback to render the modal body (view or edit mode) @app.callback( - Output("config-json-modal-body", "children"), - [Input("config-json-edit-mode", "data"), Input("current-config", "data")], + Output("config-yaml-modal-body", "children"), + [Input("config-yaml-edit-mode", "data"), Input("current-config", "data")], ) - def render_config_json_modal_body(edit_mode: bool, config: dict) -> tuple: + def render_config_yaml_modal_body(edit_mode: bool, config: dict) -> tuple: if edit_mode: + # Convert internal format to YAML with 🪨 STONE standard for editing + stone_config = convert_to_stone_format(config) + yaml_content = yaml.dump( + stone_config, default_flow_style=False, indent=2, sort_keys=False + ) return ( html.Div( [ dcc.Textarea( - id="config-json-edit-textarea", - value=json.dumps(config, indent=2), + id="config-yaml-edit-textarea", + value=yaml_content, style={ "width": "100%", "height": "60vh", @@ -159,85 +230,101 @@ def render_config_json_modal_body(edit_mode: bool, config: dict) -> tuple: ), ) else: + # Convert internal format to YAML with 🪨 STONE standard for viewing + stone_config = convert_to_stone_format(config) + yaml_content = yaml.dump( + stone_config, default_flow_style=False, indent=2, sort_keys=False + ) return ( html.Pre( - json.dumps(config, indent=2), + yaml_content, style={"maxHeight": "60vh", "overflowY": "auto"}, ), ) # Callback to handle edit mode switching @app.callback( - Output("config-json-edit-mode", "data"), + Output("config-yaml-edit-mode", "data"), [ - Input("edit-config-json-btn", "n_clicks"), - Input("cancel-config-json-edit-btn", "n_clicks"), - Input("save-config-json-edit-btn", "n_clicks"), + Input("edit-config-yaml-btn", "n_clicks"), + Input("cancel-config-yaml-edit-btn", "n_clicks"), + Input("save-config-yaml-edit-btn", "n_clicks"), + Input("close-config-yaml-modal", "n_clicks"), ], - [State("config-json-edit-mode", "data")], + [State("config-yaml-edit-mode", "data")], prevent_initial_call=True, ) - def toggle_config_json_edit_mode( + def toggle_config_yaml_edit_mode( edit_n: int, cancel_n: int, save_n: int, + close_n: int, edit_mode: bool, ) -> bool: ctx = dash.callback_context if not ctx.triggered: raise dash.exceptions.PreventUpdate trigger = ctx.triggered[0]["prop_id"].split(".")[0] - if trigger == "edit-config-json-btn": + if trigger == "edit-config-yaml-btn": return True - elif trigger in ("cancel-config-json-edit-btn", "save-config-json-edit-btn"): + elif trigger in ( + "cancel-config-yaml-edit-btn", + "save-config-yaml-edit-btn", + "close-config-yaml-modal", + ): return False return edit_mode - # Callback to download config as JSON + # Callback to download config as YAML with 🪨 STONE standard @app.callback( - Output("download-config-json", "data"), - [Input("save-config-json-btn", "n_clicks")], + Output("download-config-yaml", "data"), + [Input("save-config-yaml-btn", "n_clicks")], [State("current-config", "data")], prevent_initial_call=True, ) - def download_config_json(n: int, config: dict): + def download_config_stone(n: int, config: dict): if n: - return dict(content=json.dumps(config, indent=2), filename="config.json") + # Convert from internal format back to YAML with 🪨 STONE standard + stone_config = convert_to_stone_format(config) + yaml_content = yaml.dump( + stone_config, default_flow_style=False, indent=2, sort_keys=False + ) + return dict(content=yaml_content, filename="config.yaml") return dash.no_update @app.callback( - Output("config-json-modal", "is_open"), + Output("config-yaml-modal", "is_open"), [ Input("config-file-name-span", "n_clicks"), - Input("close-config-json-modal", "n_clicks"), + Input("close-config-yaml-modal", "n_clicks"), ], - [State("config-json-modal", "is_open")], + [State("config-yaml-modal", "is_open")], prevent_initial_call=True, ) - def toggle_config_json_modal(open_n: int, close_n: int, is_open: bool) -> bool: - """Toggle the configuration JSON modal.""" + def toggle_config_yaml_modal(open_n: int, close_n: int, is_open: bool) -> bool: + """Toggle the configuration YAML modal.""" ctx = dash.callback_context if not ctx.triggered: return is_open trigger = ctx.triggered[0]["prop_id"].split(".")[0] if trigger == "config-file-name-span" and open_n: return True - elif trigger == "close-config-json-modal" and close_n: + elif trigger == "close-config-yaml-modal" and close_n: return False return is_open # Add a callback to control button visibility @app.callback( [ - Output("save-config-json-btn", "style"), - Output("edit-config-json-btn", "style"), - Output("save-config-json-edit-btn", "style"), - Output("cancel-config-json-edit-btn", "style"), - Output("close-config-json-modal", "style"), + Output("save-config-yaml-btn", "style"), + Output("edit-config-yaml-btn", "style"), + Output("save-config-yaml-edit-btn", "style"), + Output("cancel-config-yaml-edit-btn", "style"), + Output("close-config-yaml-modal", "style"), ], - [Input("config-json-edit-mode", "data")], + [Input("config-yaml-edit-mode", "data")], ) - def set_json_modal_button_visibility(edit_mode: bool): + def set_yaml_modal_button_visibility(edit_mode: bool): if edit_mode: return ( {"display": "none"}, diff --git a/boulder/callbacks/graph_callbacks.py b/boulder/callbacks/graph_callbacks.py index 78f70e3..d8aca1f 100644 --- a/boulder/callbacks/graph_callbacks.py +++ b/boulder/callbacks/graph_callbacks.py @@ -1,6 +1,7 @@ """Callbacks for cytoscape graph interactions.""" -from typing import Any, Dict, List, Tuple, Union +import time +from typing import Any, Dict, List, Tuple import dash from dash import Input, Output, State @@ -20,35 +21,35 @@ def update_graph(config: Dict[str, Any]) -> Tuple[List[Dict[str, Any]]]: return (config_to_cyto_elements(config),) - # Callback to add new reactor + # STEP 1: Trigger reactor addition and close modal immediately @app.callback( - [Output("current-config", "data", allow_duplicate=True)], - [Input("add-reactor", "n_clicks")], + [ + Output("add-reactor-modal", "is_open", allow_duplicate=True), + Output("add-reactor-trigger", "data"), + ], + Input("add-reactor", "n_clicks"), [ State("reactor-id", "value"), State("reactor-type", "value"), State("reactor-temp", "value"), State("reactor-pressure", "value"), State("reactor-composition", "value"), - State("current-config", "data"), ], prevent_initial_call=True, ) - def add_reactor( + def trigger_reactor_addition( n_clicks: int, reactor_id: str, reactor_type: str, temp: float, pressure: float, composition: str, - config: dict, - ) -> Tuple[Union[Dict[str, Any], Any]]: + ) -> Tuple[bool, Dict[str, Any]]: if not all([reactor_id, reactor_type, temp, pressure, composition]): - return (dash.no_update,) - if any(comp["id"] == reactor_id for comp in config["components"]): - return (dash.no_update,) + # Keep modal open for user to complete form + return (True, dash.no_update) - new_reactor = { + payload = { "id": reactor_id, "type": reactor_type, "properties": { @@ -56,92 +57,96 @@ def add_reactor( "pressure": pressure, "composition": composition, }, + "timestamp": time.time(), # Ensures change fires } + return (False, payload) # Close modal, trigger step 2 + + # STEP 2: Update config from trigger + @app.callback( + Output("current-config", "data", allow_duplicate=True), + Input("add-reactor-trigger", "data"), + State("current-config", "data"), + prevent_initial_call=True, + ) + def add_reactor(trigger_data: dict, config: dict) -> Dict[str, Any]: + if not trigger_data: + raise dash.exceptions.PreventUpdate + + new_reactor = { + "id": trigger_data["id"], + "type": trigger_data["type"], + "properties": trigger_data["properties"], + } + if any(comp["id"] == new_reactor["id"] for comp in config["components"]): + # Prevent adding duplicate + return dash.no_update + config["components"].append(new_reactor) - return (config,) + return config - # Callback to add new MFC + # STEP 1: Trigger MFC addition and close modal immediately @app.callback( - [Output("current-config", "data", allow_duplicate=True)], - [Input("add-mfc", "n_clicks")], + [ + Output("add-mfc-modal", "is_open", allow_duplicate=True), + Output("add-mfc-trigger", "data"), + ], + Input("add-mfc", "n_clicks"), [ State("mfc-id", "value"), State("mfc-source", "value"), State("mfc-target", "value"), State("mfc-flow-rate", "value"), - State("current-config", "data"), ], prevent_initial_call=True, ) - def add_mfc( + def trigger_mfc_addition( n_clicks: int, mfc_id: str, source: str, target: str, flow_rate: float, - config: dict, - ) -> Tuple[Union[Dict[str, Any], Any]]: + ) -> Tuple[bool, Dict[str, Any]]: if not all([mfc_id, source, target, flow_rate]): - return (dash.no_update,) - if any( - conn["source"] == source and conn["target"] == target - for conn in config["connections"] - ): - return (dash.no_update,) + return (True, dash.no_update) - new_connection = { + payload = { "id": mfc_id, - "type": "MassFlowController", "source": source, "target": target, - "properties": { - "mass_flow_rate": flow_rate, - }, + "mass_flow_rate": flow_rate, + "timestamp": time.time(), } - config["connections"].append(new_connection) - return (config,) + return (False, payload) - # Handle edge creation from store + # STEP 2: Update config from trigger @app.callback( - [Output("current-config", "data", allow_duplicate=True)], - [Input("edge-added-store", "data")], - [State("current-config", "data")], + Output("current-config", "data", allow_duplicate=True), + Input("add-mfc-trigger", "data"), + State("current-config", "data"), prevent_initial_call=True, ) - def handle_edge_creation(edge_data: dict, config: dict) -> tuple: - if not edge_data: - return (dash.no_update,) - - source_id = edge_data.get("source") - target_id = edge_data.get("target") - - if not source_id or not target_id: - return (dash.no_update,) + def add_mfc(trigger_data: dict, config: dict) -> Dict[str, Any]: + if not trigger_data: + raise dash.exceptions.PreventUpdate - # Check if this edge already exists in the config if any( - conn["source"] == source_id and conn["target"] == target_id + conn["source"] == trigger_data["source"] + and conn["target"] == trigger_data["target"] for conn in config["connections"] ): - return (dash.no_update,) - - # Generate unique ID for the new edge - edge_id = f"mfc_{len(config['connections']) + 1}" - - # Add new connection to config - config["connections"].append( - { - "id": edge_id, - "source": source_id, - "target": target_id, - "type": "MassFlowController", - "properties": { - "mass_flow_rate": 0.001 # Default flow rate - }, - } - ) - - return (config,) + return dash.no_update + + new_connection = { + "id": trigger_data["id"], + "type": "MassFlowController", + "source": trigger_data["source"], + "target": trigger_data["target"], + "properties": { + "mass_flow_rate": trigger_data["mass_flow_rate"], + }, + } + config["connections"].append(new_connection) + return config # Update last-selected-element on selection @app.callback( diff --git a/boulder/callbacks/notification_callbacks.py b/boulder/callbacks/notification_callbacks.py index d410491..5ba843d 100644 --- a/boulder/callbacks/notification_callbacks.py +++ b/boulder/callbacks/notification_callbacks.py @@ -18,52 +18,26 @@ def register_callbacks(app) -> None: # type: ignore Output("notification-toast", "icon"), ], [ - Input("add-reactor", "n_clicks"), - Input("add-mfc", "n_clicks"), Input("upload-config", "contents"), Input("delete-config-file", "n_clicks"), - Input("save-config-json-edit-btn", "n_clicks"), - Input("edge-added-store", "data"), + Input("save-config-yaml-edit-btn", "n_clicks"), Input("run-simulation", "n_clicks"), Input("reactor-graph", "selectedNodeData"), Input("reactor-graph", "selectedEdgeData"), - Input("current-config", "data"), ], [ - State("reactor-id", "value"), - State("reactor-type", "value"), - State("reactor-temp", "value"), - State("reactor-pressure", "value"), - State("reactor-composition", "value"), - State("mfc-id", "value"), - State("mfc-source", "value"), - State("mfc-target", "value"), - State("mfc-flow-rate", "value"), State("upload-config", "filename"), State("current-config", "data"), ], prevent_initial_call=True, ) def notification_handler( - add_reactor_click: int, - add_mfc_click: int, upload_contents: str, delete_config_click: int, save_edit_click: int, - edge_data: dict, run_sim_click: int, selected_node: list, selected_edge: list, - config_data: dict, - reactor_id: str, - reactor_type: str, - reactor_temp: float, - reactor_pressure: float, - reactor_composition: str, - mfc_id: str, - mfc_source: str, - mfc_target: str, - mfc_flow_rate: float, upload_filename: str, config: dict, ): @@ -73,48 +47,6 @@ def notification_handler( raise dash.exceptions.PreventUpdate trigger = ctx.triggered[0]["prop_id"].split(".")[0] - # Add Reactor - if trigger == "add-reactor" and add_reactor_click: - if not all( - [ - reactor_id, - reactor_type, - reactor_temp, - reactor_pressure, - reactor_composition, - ] - ): - return True, "Please fill in all fields", "Error", "danger" - if any(comp["id"] == reactor_id for comp in config["components"]): - return ( - True, - f"Component with ID {reactor_id} already exists", - "Error", - "danger", - ) - return True, f"Added {reactor_type} {reactor_id}", "Success", "success" - - # Add MFC - if trigger == "add-mfc" and add_mfc_click: - if not all([mfc_id, mfc_source, mfc_target, mfc_flow_rate]): - return True, "Please fill in all fields", "Error", "danger" - if any( - conn["source"] == mfc_source and conn["target"] == mfc_target - for conn in config["connections"] - ): - return ( - True, - f"Connection from {mfc_source} to {mfc_target} already exists", - "Error", - "danger", - ) - return ( - True, - f"Added MFC {mfc_id} from {mfc_source} to {mfc_target}", - "Success", - "success", - ) - # Config upload if trigger == "upload-config" and upload_contents: try: @@ -140,7 +72,7 @@ def notification_handler( return True, "Config file removed.", "Success", "success" # Config edit - if trigger == "save-config-json-edit-btn" and save_edit_click: + if trigger == "save-config-yaml-edit-btn" and save_edit_click: return ( True, "✅ Configuration updated from editor.", @@ -148,16 +80,6 @@ def notification_handler( "success", ) - # Edge creation - if trigger == "edge-added-store" and edge_data: - if edge_data and edge_data.get("source") and edge_data.get("target"): - return ( - True, - f"Added connection from {edge_data['source']} to {edge_data['target']}", - "Success", - "success", - ) - # Run simulation if trigger == "run-simulation" and run_sim_click: return True, "Simulation successfully started", "Success", "success" @@ -180,8 +102,4 @@ def notification_handler( "info", ) - # Graph update - if trigger == "current-config": - return True, "Graph updated", "Info", "info" - return False, "", "", "primary" diff --git a/boulder/callbacks/properties_callbacks.py b/boulder/callbacks/properties_callbacks.py index 3efbab6..6ec8445 100644 --- a/boulder/callbacks/properties_callbacks.py +++ b/boulder/callbacks/properties_callbacks.py @@ -39,7 +39,7 @@ def show_properties_editable(last_selected, edit_mode, config): if node_data: data = node_data[0] - properties = data["properties"] + properties = data.get("properties", {}) if edit_mode: fields = [ dbc.Row( @@ -133,7 +133,7 @@ def show_properties_editable(last_selected, edit_mode, config): ) elif edge_data: data = edge_data[0] - properties = data["properties"] + properties = data.get("properties", {}) if edit_mode: fields = [ dbc.Row( @@ -269,6 +269,9 @@ def save_properties(n_clicks, node_data, edge_data, config, values, ids): comp_id = data["id"] for comp in config["components"]: if comp["id"] == comp_id: + # Ensure properties dict exists + if "properties" not in comp: + comp["properties"] = {} for v, i in zip(values, ids): key = i["prop"] # Convert to float if key is temperature or pressure @@ -285,6 +288,9 @@ def save_properties(n_clicks, node_data, edge_data, config, values, ids): conn_id = data["id"] for conn in config["connections"]: if conn["id"] == conn_id: + # Ensure properties dict exists + if "properties" not in conn: + conn["properties"] = {} for v, i in zip(values, ids): key = i["prop"] # Map 'flow_rate' to 'mass_flow_rate' for MassFlowController diff --git a/boulder/callbacks/simulation_callbacks.py b/boulder/callbacks/simulation_callbacks.py index 6aedf13..615d336 100644 --- a/boulder/callbacks/simulation_callbacks.py +++ b/boulder/callbacks/simulation_callbacks.py @@ -173,9 +173,9 @@ def run_simulation( species_fig = apply_theme_to_figure(species_fig, theme) return ( - temp_fig, - press_fig, - species_fig, + temp_fig.to_dict(), + press_fig.to_dict(), + species_fig.to_dict(), code_str, "", {"display": "none"}, @@ -289,9 +289,9 @@ def run_simulation( ) code_str = header + code_str return ( - temp_fig, - press_fig, - species_fig, + temp_fig.to_dict(), + press_fig.to_dict(), + species_fig.to_dict(), code_str, "", {"display": "none"}, @@ -413,7 +413,7 @@ def toggle_download_button(code_str: str) -> Tuple[bool, str]: Output("last-sim-python-code", "data", allow_duplicate=True), [ Input({"type": "prop-edit", "prop": dash.ALL}, "value"), - Input("save-config-json-edit-btn", "n_clicks"), + Input("save-config-yaml-edit-btn", "n_clicks"), Input("upload-config", "contents"), ], prevent_initial_call=True, @@ -441,10 +441,14 @@ def trigger_download_py(n_clicks: int, code_str: str) -> Union[Dict[str, str], A Input("simulation-data", "data"), Input("theme-store", "data"), # Add theme as input ], + State("reactor-graph", "elements"), prevent_initial_call=True, ) def update_sankey_plot( - active_tab: str, simulation_data: Dict[str, Any], theme: str + active_tab: str, + simulation_data: Dict[str, Any], + theme: str, + reactor_elements: List[Dict[str, Any]], ) -> Union[Dict[str, Any], Any]: """Generate Sankey diagram when the Sankey tab is selected.""" import dash @@ -471,7 +475,7 @@ def update_sankey_plot( return dash.no_update try: - # Rebuild the converter from stored session data + # Rebuild the converter from stored session data (same as original simulation) mechanism = simulation_data["mechanism"] config = simulation_data["config"] @@ -479,12 +483,12 @@ def update_sankey_plot( converter: Union[CanteraConverter, DualCanteraConverter] if USE_DUAL_CONVERTER: dual_converter = DualCanteraConverter(mechanism=mechanism) - # Rebuild the network + # Rebuild the network using the exact same config dual_converter.build_network_and_code(config) converter = dual_converter else: single_converter = CanteraConverter(mechanism=mechanism) - # Rebuild the network + # Rebuild the network using the exact same config single_converter.build_network(config) converter = single_converter @@ -492,11 +496,20 @@ def update_sankey_plot( if converter.last_network is None: return dash.no_update - # Generate Sankey data from the rebuilt network with theme-aware colors + # Extract reactor IDs from reactor graph elements + reactor_node_ids = [] + if reactor_elements: + for element in reactor_elements: + if ( + "data" in element and "source" not in element["data"] + ): # It's a node, not an edge + reactor_node_ids.append(element["data"].get("id", "")) + # Generate Sankey data from the rebuilt network + # Now reactor names should match config IDs directly links, nodes = generate_sankey_input_from_sim( converter.last_network, show_species=["H2", "CH4"], - verbose=False, + verbose=False, # Disable verbose output mechanism=converter.mechanism, theme=theme, # Pass theme to sankey generation ) diff --git a/boulder/callbacks/theme_callbacks.py b/boulder/callbacks/theme_callbacks.py index d435a8f..f1882e2 100644 --- a/boulder/callbacks/theme_callbacks.py +++ b/boulder/callbacks/theme_callbacks.py @@ -1,5 +1,6 @@ """Callbacks for theme switching functionality.""" +import dash from dash import Input, Output, clientside_callback @@ -32,14 +33,93 @@ def register_callbacks(app) -> None: # type: ignore prevent_initial_call=False, ) - # Callback to update Cytoscape stylesheet based on theme + # Callback to select reactor graph node when hovering over Sankey nodes @app.callback( - Output("reactor-graph", "stylesheet"), - [Input("theme-store", "data")], + [ + Output("reactor-graph", "selectedNodeData"), + Output("reactor-graph", "stylesheet"), + ], + [ + Input("theme-store", "data"), + Input("sankey-plot", "hoverData"), + ], prevent_initial_call=False, ) - def update_cytoscape_stylesheet(theme: str): - """Update Cytoscape stylesheet based on current theme.""" + def update_cytoscape_selection(theme: str, hover_data): + """Select reactor graph node when hovering over Sankey nodes and update stylesheet.""" + import copy + from ..styles import get_cytoscape_stylesheet - return get_cytoscape_stylesheet(theme) + # Get the base stylesheet for the current theme + base_stylesheet = get_cytoscape_stylesheet(theme) + + # Get the callback context to see what triggered this callback + ctx = dash.callback_context + if not ctx.triggered: + return [], base_stylesheet + + triggered_id = ctx.triggered[0]["prop_id"].split(".")[0] + triggered_prop = ctx.triggered[0]["prop_id"].split(".")[1] + + # Check for Sankey hover interaction + if ( + triggered_id == "sankey-plot" + and triggered_prop == "hoverData" + and hover_data + and hover_data.get("points") + ): + # Get the node label from Sankey diagram (now should match reactor graph ID) + hovered_point = hover_data["points"][0] + + if "label" in hovered_point: + reactor_node_id = hovered_point["label"] + print(f"[DEBUG] Hovering over Sankey node: '{reactor_node_id}'") + + # Create selected node data to programmatically select the node + selected_node_data = [{"id": reactor_node_id}] + print(f"[DEBUG] Setting selectedNodeData: {selected_node_data}") + + # Also update stylesheet with highlight using direct node selector + new_stylesheet = copy.deepcopy(base_stylesheet) + + # Remove any existing node-specific highlight styles + new_stylesheet = [ + style + for style in new_stylesheet + if not ( + style.get("selector", "").startswith("node[id") + and "border-width" in str(style.get("style", {})) + ) + ] + + # Add highlight style for the selected node + if theme == "dark": + highlight_color = "#FFD700" # Gold for dark theme + border_color = "#FFA500" # Orange border + else: + highlight_color = "#FF6B6B" # Red for light theme + border_color = "#DC3545" # Darker red border + + # Use direct node ID selector instead of :selected + highlight_style = { + "selector": f"node[id = '{reactor_node_id}']", + "style": { + "background-color": highlight_color, + "border-width": "8px", + "border-color": border_color, + "border-style": "solid", + "z-index": 999, + "text-outline-color": border_color, + "text-outline-width": 4, + }, + } + + new_stylesheet.append(highlight_style) + print(f"[DEBUG] Added highlight style: {highlight_style}") + print(f"[DEBUG] Total stylesheet entries: {len(new_stylesheet)}") + + return selected_node_data, new_stylesheet + + # For theme changes or other triggers, return empty selection and base stylesheet + return [], base_stylesheet diff --git a/boulder/cantera_converter.py b/boulder/cantera_converter.py index 2c12636..5927b97 100644 --- a/boulder/cantera_converter.py +++ b/boulder/cantera_converter.py @@ -52,6 +52,9 @@ def create_reactor(self, reactor_config: Dict[str, Any]) -> ct.Reactor: else: raise ValueError(f"Unsupported reactor type: {reactor_type}") + # Set the reactor name to match the config ID + reactor.name = reactor_config["id"] + return reactor def create_connection(self, conn_config: Dict[str, Any]) -> ct.FlowDevice: @@ -214,10 +217,14 @@ def build_network_and_code( self.gas.TPX = (temp, pres, self.parse_composition(compo)) if 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) + self.reactors[rid].name = rid elif typ == "Reservoir": self.code_lines.append(f"{rid} = ct.Reservoir(gas)") + self.code_lines.append(f"{rid}.name = '{rid}'") self.reactors[rid] = ct.Reservoir(self.gas) + self.reactors[rid].name = rid else: self.code_lines.append(f"# Unsupported reactor type: {typ}") raise ValueError(f"Unsupported reactor type: {typ}") diff --git a/boulder/config.py b/boulder/config.py index 75468f6..3c9211c 100644 --- a/boulder/config.py +++ b/boulder/config.py @@ -1,9 +1,14 @@ -"""Configuration management for the Boulder application.""" +"""Configuration management for the Boulder application. + +Supports YAML format with 🪨 STONE standard - an elegant configuration format +where component types are keys containing their properties. +""" -import json import os from typing import Any, Dict +import yaml + # Global variable for temperature scale coloring USE_TEMPERATURE_SCALE = True @@ -14,9 +19,99 @@ CANTERA_MECHANISM = "gri30.yaml" +def load_config_file(config_path: str) -> Dict[str, Any]: + """Load configuration from YAML file with 🪨 STONE standard.""" + _, ext = os.path.splitext(config_path.lower()) + + if ext not in [".yaml", ".yml"]: + raise ValueError( + f"Only YAML format with 🪨 STONE standard (.yaml/.yml) files are supported. " + f"Got: {ext}" + ) + + with open(config_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +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 + """ + normalized = config.copy() + + # Normalize components + if "components" in normalized: + for component in normalized["components"]: + if "type" not in component: + # 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] + + if type_keys: + type_name = type_keys[0] # Use the first type key found + properties = component[type_name] + + # Remove the type key and add type + properties + del component[type_name] + component["type"] = type_name + component["properties"] = ( + properties if isinstance(properties, dict) else {} + ) + + # Normalize connections + if "connections" in normalized: + for connection in normalized["connections"]: + if "type" not in connection: + # Find the type key (anything that's not id, source, target, metadata) + standard_fields = {"id", "source", "target", "metadata"} + type_keys = [k for k in connection.keys() if k not in standard_fields] + + if type_keys: + type_name = type_keys[0] # Use the first type key found + properties = connection[type_name] + + # Remove the type key and add type + properties + del connection[type_name] + connection["type"] = type_name + connection["properties"] = ( + properties if isinstance(properties, dict) else {} + ) + + return normalized + + def get_initial_config() -> Dict[str, Any]: - """Load the initial configuration from the sample config file.""" - config_path = os.path.join(os.path.dirname(__file__), "data", "sample_config.json") - with open(config_path, "r") as f: - config_data: Dict[str, Any] = json.load(f) - return config_data + """Load the initial configuration in YAML format with 🪨 STONE standard. + + Loads from examples/example_config.yaml using the elegant 🪨 STONE standard. + """ + # Load from examples directory (YAML with 🪨 STONE standard) + examples_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "examples") + stone_config_path = os.path.join(examples_dir, "example_config.yaml") + + if os.path.exists(stone_config_path): + config = load_config_file(stone_config_path) + return normalize_config(config) + else: + raise FileNotFoundError( + f"YAML configuration file with 🪨 STONE standard not found: {stone_config_path}" + ) + + +def get_config_from_path(config_path: str) -> Dict[str, Any]: + """Load configuration from a specific path.""" + if not os.path.exists(config_path): + raise FileNotFoundError(f"Configuration file not found: {config_path}") + + config = load_config_file(config_path) + return normalize_config(config) diff --git a/boulder/data/sample_config.json b/boulder/data/sample_config.json deleted file mode 100644 index d29c801..0000000 --- a/boulder/data/sample_config.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "components": [ - { - "id": "reactor1", - "type": "IdealGasReactor", - "properties": { - "temperature": 1000, - "pressure": 101325, - "composition": "CH4:1,O2:2,N2:7.52" - } - }, - { - "id": "res1", - "type": "Reservoir", - "properties": { - "temperature": 300, - "composition": "O2:1,N2:3.76" - } - } - ], - "connections": [ - { - "id": "mfc1", - "type": "MassFlowController", - "source": "res1", - "target": "reactor1", - "properties": { - "mass_flow_rate": 0.1 - } - } - ] -} diff --git a/boulder/data/sample_config.yaml b/boulder/data/sample_config.yaml new file mode 100644 index 0000000..bef1978 --- /dev/null +++ b/boulder/data/sample_config.yaml @@ -0,0 +1,45 @@ +# Boulder Application Configuration +# Combined YAML configuration merging process parameters and reactor network definition + +# Global application settings +global: + cantera_mechanism: "gri30.yaml" + use_temperature_scale: true + use_dual_converter: true + +# Reactor network definition (from sample_config2.json) +components: +- id: "reactor1" + type: "IdealGasReactor" + properties: + temperature: 1000 + pressure: 101325 + composition: "CH4:1,O2:2,N2:7.52" + +- id: "res1" + type: "Reservoir" + properties: + temperature: 800 + composition: "O2:1,N2:3.76" + +- id: "downstream" + type: "Reservoir" + properties: + temperature: 300 + pressure: 201325 + composition: "O2:1,N2:3.76" + +connections: +- id: "mfc1" + type: "MassFlowController" + source: "res1" + target: "reactor1" + properties: + mass_flow_rate: 0.1 + +- id: "mfc2" + type: "MassFlowController" + source: "reactor1" + target: "downstream" + properties: + flow_rate: 0.1 diff --git a/boulder/layout.py b/boulder/layout.py index 4c9dffa..6ce2c19 100644 --- a/boulder/layout.py +++ b/boulder/layout.py @@ -30,6 +30,9 @@ def get_layout( dcc.Interval(id="init-interval"), # Dark mode store dcc.Store(id="theme-store", data="light"), + # Intermediate stores for chained callbacks + dcc.Store(id="add-reactor-trigger", data={}), + dcc.Store(id="add-mfc-trigger", data={}), ], id="hidden-dummies", style={"display": "none"}, @@ -52,51 +55,53 @@ def get_layout( ), # Store for config file name dcc.Store(id="config-file-name", data=""), - # Modal for viewing config JSON + # Modal for viewing config in YAML with 🪨 STONE standard dbc.Modal( [ - dbc.ModalHeader("Current Configuration JSON"), + dbc.ModalHeader( + "Current Configuration - YAML with 🪨 STONE Standard" + ), dbc.ModalBody( [ - html.Div(id="config-json-modal-body"), - dcc.Download(id="download-config-json"), + html.Div(id="config-yaml-modal-body"), + dcc.Download(id="download-config-yaml"), ] ), dbc.ModalFooter( [ dbc.Button( "Save as New File", - id="save-config-json-btn", + id="save-config-yaml-btn", color="secondary", className="mr-2", ), dbc.Button( "Edit", - id="edit-config-json-btn", + id="edit-config-yaml-btn", color="primary", className="mr-2", ), dbc.Button( "Save", - id="save-config-json-edit-btn", + id="save-config-yaml-edit-btn", color="success", className="mr-2", ), dbc.Button( "Cancel", - id="cancel-config-json-edit-btn", + id="cancel-config-yaml-edit-btn", color="secondary", className="ml-auto", ), dbc.Button( "Close", - id="close-config-json-modal", + id="close-config-yaml-modal", className="ml-auto", ), ] ), ], - id="config-json-modal", + id="config-yaml-modal", is_open=False, size="lg", ), @@ -217,6 +222,7 @@ def get_layout( ], id="add-reactor-modal", is_open=False, + fade=False, ), # Add MFC Modal dbc.Modal( @@ -301,6 +307,7 @@ def get_layout( ], id="add-mfc-modal", is_open=False, + fade=False, ), # Main content dbc.Row( @@ -458,7 +465,7 @@ def get_layout( # "name": "cose", }, - style={"width": "100%", "height": "600px"}, + style={"width": "100%", "height": "360px"}, elements=config_to_cyto_elements( initial_config ), @@ -555,16 +562,8 @@ def get_layout( dcc.Store(id="current-config", data=initial_config), # Hidden div for toast trigger dcc.Store(id="toast-trigger", data={}), - # Add this hidden div to your layout - html.Div(id="hidden-edge-data", style={"display": "none"}), - # Add a store component to hold edge data - dcc.Store(id="edge-added-store", data=None), - # Add a hidden div to trigger initialization (of new edge creation) - html.Div( - id="initialization-trigger", children="init", style={"display": "none"} - ), # Add a Store to keep track of edit mode - dcc.Store(id="config-json-edit-mode", data=False), + dcc.Store(id="config-yaml-edit-mode", data=False), # Add a Store to keep track of properties panel edit mode dcc.Store(id="properties-edit-mode", data=False), dcc.Store(id="last-selected-element", data={}), diff --git a/boulder/utils.py b/boulder/utils.py index 88a564c..a707e0d 100644 --- a/boulder/utils.py +++ b/boulder/utils.py @@ -9,31 +9,46 @@ def config_to_cyto_elements(config: Dict[str, Any]) -> List[Dict[str, Any]]: # Add nodes (reactors) for component in config.get("components", []): - elements.append( - { - "data": { - "id": component["id"], - "label": component["id"], - "type": component["type"], - "properties": component.get("properties", {}), - } - } - ) + properties = component.get("properties", {}) + node_data = { + "id": component["id"], + "label": component["id"], + "type": component["type"], + "properties": properties, + } + + # Flatten commonly used properties for Cytoscape mapping + # This allows Cytoscape selectors like "mapData(temperature, ...)" to work + if "temperature" in properties: + node_data["temperature"] = properties["temperature"] + if "pressure" in properties: + node_data["pressure"] = properties["pressure"] + if "composition" in properties: + node_data["composition"] = properties["composition"] + if "volume" in properties: + node_data["volume"] = properties["volume"] + + elements.append({"data": node_data}) # Add edges (connections) for connection in config.get("connections", []): - elements.append( - { - "data": { - "id": connection["id"], - "source": connection["source"], - "target": connection["target"], - "label": connection["type"], - "type": connection["type"], # Add type field for consistency - "properties": connection.get("properties", {}), - } - } - ) + properties = connection.get("properties", {}) + edge_data = { + "id": connection["id"], + "source": connection["source"], + "target": connection["target"], + "label": connection["type"], + "type": connection["type"], # Add type field for consistency + "properties": properties, + } + + # Flatten commonly used properties for Cytoscape mapping + if "mass_flow_rate" in properties: + edge_data["mass_flow_rate"] = properties["mass_flow_rate"] + if "valve_coeff" in properties: + edge_data["valve_coeff"] = properties["valve_coeff"] + + elements.append({"data": edge_data}) return elements @@ -84,8 +99,16 @@ def get_available_cantera_mechanisms() -> List[Dict[str, str]]: "thermo", ] + # Use a set to track filenames and avoid duplicates + seen_filenames = set() + for yaml_file in sorted(yaml_files): filename = yaml_file.name + + # Skip duplicates based on filename + if filename in seen_filenames: + continue + # Skip files that match exclude patterns or don't seem like mechanism files if any(pattern in filename.lower() for pattern in exclude_patterns): continue @@ -94,6 +117,13 @@ def get_available_cantera_mechanisms() -> List[Dict[str, str]]: if filename.startswith(".") or len(filename) < 5: continue + # Skip duplicate filenames (same file in multiple directories) + if filename in seen_filenames: + continue + + # Mark this filename as seen + seen_filenames.add(filename) + # Create a readable label label = filename.replace(".yaml", "").replace(".yml", "").replace("_", " ") label = " ".join(word.capitalize() for word in label.split()) @@ -124,6 +154,8 @@ def label_with_unit(key: str) -> str: "composition": "composition (%mol)", "temperature": "temperature (K)", "mass_flow_rate": "mass flow rate (kg/s)", + "volume": "volume (m³)", + "valve_coeff": "valve coefficient (-)", } return unit_map.get(key, key) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..f9d587e --- /dev/null +++ b/examples/README.md @@ -0,0 +1,328 @@ +# YAML with 🪨 STONE Standard - Boulder Configuration Files + +**YAML format with 🪨 STONE standard** is Boulder's elegant configuration format that makes reactor network definitions clean and intuitive. + +## What is the 🪨 STONE Standard? + +**🪨 STONE** stands for **Structured Type-Oriented Network Expressions** - a YAML configuration standard where component types become keys that contain their properties. This creates a visually clear hierarchy that's both human-readable and programmatically robust. + +## Format Overview + +### Traditional vs 🪨 STONE Standard + +**Traditional YAML format:** + +```yaml +components: + - id: reactor1 + type: IdealGasReactor + properties: + temperature: 1000 + pressure: 101325 +``` + +**YAML with 🪨 STONE standard:** + +```yaml +components: + - id: reactor1 + IdealGasReactor: + temperature: 1000 # K + pressure: 101325 # Pa +``` + +### Key Benefits + +- **🎯 Type Prominence**: Component types are visually prominent as keys +- **🧹 Clean Structure**: No nested `properties` sections +- **📖 Better Readability**: Properties are clearly grouped under their component type +- **✅ Valid YAML**: Follows standard YAML syntax without mixed structures +- **🚀 Intuitive**: Type-properties relationship is immediately clear + +## YAML with 🪨 STONE Standard Specification + +### File Structure + +```yaml +metadata: + name: "Configuration Name" + description: "Brief description" + version: "1.0" + +simulation: + mechanism: "gri30.yaml" + time_step: 0.001 # s + max_time: 10.0 # s + solver: "CVODE_BDF" + relative_tolerance: 1.0e-6 + absolute_tolerance: 1.0e-9 + +components: + - id: component_id + ComponentType: + property1: value1 + property2: value2 + # ... more properties + +connections: + - id: connection_id + ConnectionType: + property1: value1 + property2: value2 + source: source_component_id + target: target_component_id +``` + +### Component Types + +#### IdealGasReactor + +```yaml +components: + - id: reactor1 + IdealGasReactor: + temperature: 1000 # K + pressure: 101325 # Pa + composition: "CH4:1,O2:2,N2:7.52" + volume: 0.01 # m³ (optional) +``` + +#### Reservoir + +```yaml +components: + - id: inlet + Reservoir: + temperature: 300 # K + pressure: 101325 # Pa (optional) + composition: "O2:1,N2:3.76" +``` + +### Connection Types + +#### MassFlowController + +```yaml +connections: + - id: mfc1 + MassFlowController: + mass_flow_rate: 0.1 # kg/s + source: inlet + target: reactor1 +``` + +#### Valve + +```yaml +connections: + - id: valve1 + Valve: + valve_coeff: 1.0 # valve coefficient + source: reactor1 + target: outlet +``` + +## Example Configurations + +### 📁 example_config.yaml + +Basic single reactor with reservoir inlet: + +```yaml +metadata: + name: "Basic Reactor Configuration" + description: "Simple configuration with one reactor and one reservoir" + version: "1.0" + +simulation: + mechanism: "gri30.yaml" + time_step: 0.001 + max_time: 10.0 + solver: "CVODE_BDF" + +components: + - id: reactor1 + IdealGasReactor: + temperature: 1000 # K + pressure: 101325 # Pa + composition: "CH4:1,O2:2,N2:7.52" + + - id: res1 + Reservoir: + temperature: 300 # K + composition: "O2:1,N2:3.76" + +connections: + - id: mfc1 + MassFlowController: + mass_flow_rate: 0.1 # kg/s + source: res1 + target: reactor1 +``` + +### 📁 sample_configs2.yaml + +Extended configuration with multiple components: + +```yaml +metadata: + name: "Extended Reactor Configuration" + description: "Multi-component reactor system with different flow controllers" + version: "2.0" + +components: + - id: reactor1 + IdealGasReactor: + temperature: 1200 # K + pressure: 101325 # Pa + composition: "CH4:1,O2:2,N2:7.52" + volume: 0.01 # m³ + + - id: res1 + Reservoir: + temperature: 300 # K + composition: "O2:1,N2:3.76" + + - id: res2 + Reservoir: + temperature: 350 # K + pressure: 202650 # Pa + composition: "CH4:1" + +connections: + - id: mfc1 + MassFlowController: + mass_flow_rate: 0.05 # kg/s + source: res1 + target: reactor1 + + - id: mfc2 + MassFlowController: + mass_flow_rate: 0.02 # kg/s + source: res2 + target: reactor1 +``` + +### 📁 mix_react_streams.yaml + +Complex multi-reactor network: + +```yaml +metadata: + name: "Mixed Reactor Streams" + description: "Complex multi-reactor network with interconnected streams" + version: "3.0" + +components: + - id: reactor1 + IdealGasReactor: + temperature: 1100 # K + pressure: 101325 # Pa + composition: "CH4:0.8,O2:1.6,N2:6.0" + volume: 0.005 # m³ + + - id: reactor2 + IdealGasReactor: + temperature: 900 # K + pressure: 101325 # Pa + composition: "H2:2,O2:1,N2:3.76" + volume: 0.008 # m³ + + - id: mixer1 + IdealGasReactor: + temperature: 400 # K + pressure: 101325 # Pa + composition: "N2:1" + volume: 0.002 # m³ + +connections: + - id: mfc3 + MassFlowController: + mass_flow_rate: 0.025 # kg/s + source: reactor1 + target: mixer1 + + - id: mfc4 + MassFlowController: + mass_flow_rate: 0.035 # kg/s + source: mixer1 + target: reactor2 +``` + +## Property Reference + +### Common Properties + +| Property | Unit | Description | Components | +|----------|------|-------------|------------| +| `temperature` | K | Gas temperature | All | +| `pressure` | Pa | Gas pressure | All | +| `composition` | - | Species mole fractions (e.g., "CH4:1,O2:2") | All | +| `volume` | m³ | Reactor volume | IdealGasReactor | +| `mass_flow_rate` | kg/s | Mass flow rate | MassFlowController | +| `valve_coeff` | - | Valve coefficient | Valve | + +### Composition Format + +Compositions are specified as comma-separated species:mole_fraction pairs: + +```yaml +composition: "CH4:1,O2:2,N2:7.52" +# Equivalent to: 1 mol CH4, 2 mol O2, 7.52 mol N2 +``` + +### Units and Comments + +Always include units in comments for clarity: + +```yaml +IdealGasReactor: + temperature: 1000 # K + pressure: 101325 # Pa + mass_flow_rate: 0.1 # kg/s + volume: 0.01 # m³ +``` + +## Best Practices + +### 🎨 Formatting + +1. **Use consistent indentation** (2 spaces recommended) +1. **Include unit comments** for all physical quantities +1. **Group related components** logically +1. **Use descriptive IDs** (e.g., `fuel_inlet`, `main_reactor`) + +### 🏗️ Structure + +1. **Start with metadata** to describe your configuration +1. **Define simulation parameters** before components +1. **List components** before connections +1. **Order connections** by flow direction when possible + +### 🔄 Composition + +1. **Use standard species names** from your mechanism +1. **Normalize compositions** (they don't need to sum to 1) +1. **Include inert species** (like N2) for realistic mixtures + +## Validation + +YAML with 🪨 STONE standard includes automatic validation: + +- ✅ **Syntax validation**: YAML parser ensures proper syntax +- ✅ **Structure validation**: Required sections and fields are checked +- ✅ **Reference validation**: All connection sources/targets must exist +- ✅ **Type validation**: Component and connection types are verified + +## Getting Started + +1. **Copy an example** configuration file as a starting point +1. **Modify metadata** to describe your system +1. **Update simulation parameters** for your mechanism and time scales +1. **Define your components** with appropriate properties +1. **Connect components** with flow controllers or valves +1. **Test and iterate** using Boulder's simulation interface + +______________________________________________________________________ + +*YAML with 🪨 STONE standard makes reactor network configuration as solid as stone - reliable, clear, and built to last.* diff --git a/examples/README.rst b/examples/README.rst deleted file mode 100644 index b41b02e..0000000 --- a/examples/README.rst +++ /dev/null @@ -1,8 +0,0 @@ -Examples -======== - -You will find below a series of runnable examples using Boulder. - -Most Boulder examples are supposed to be ran with the Web-browser interface. - ---- diff --git a/examples/example_config.yaml b/examples/example_config.yaml new file mode 100644 index 0000000..37670ae --- /dev/null +++ b/examples/example_config.yaml @@ -0,0 +1,31 @@ +metadata: + name: "Basic Reactor Configuration" + description: "Simple configuration with one reactor and one reservoir" + version: "1.0" + +simulation: + mechanism: "gri30.yaml" + time_step: 0.001 # s + max_time: 10.0 # s + solver: "CVODE_BDF" + relative_tolerance: 1.0e-6 + absolute_tolerance: 1.0e-9 + +components: +- id: reactor1 + IdealGasReactor: + temperature: 1000 # K + pressure: 101325 # Pa + composition: "CH4:1,O2:2,N2:7.52" + +- id: res1 + Reservoir: + temperature: 300 # K + composition: "O2:1,N2:3.76" + +connections: +- id: mfc1 + MassFlowController: + mass_flow_rate: 0.1 # kg/s + source: res1 + target: reactor1 diff --git a/examples/mix_react_streams.yaml b/examples/mix_react_streams.yaml new file mode 100644 index 0000000..4fa1588 --- /dev/null +++ b/examples/mix_react_streams.yaml @@ -0,0 +1,72 @@ +metadata: + name: "Mixed Reactor Streams" + description: "Complex multi-reactor network with interconnected streams" + version: "3.0" + author: "Boulder Configuration System" + +simulation: + mechanism: "gri30.yaml" + time_step: 0.0005 # s + max_time: 2.0 # s + solver: "CVODE_BDF" + relative_tolerance: 1.0e-8 + absolute_tolerance: 1.0e-12 + max_steps: 20000 + +components: +- id: reactor1 + IdealGasReactor: + temperature: 1100 # K + pressure: 101325 # Pa + composition: "CH4:0.8,O2:1.6,N2:6.0" + volume: 0.005 # m³ + +- id: reactor2 + IdealGasReactor: + temperature: 900 # K + pressure: 101325 # Pa + composition: "H2:2,O2:1,N2:3.76" + volume: 0.008 # m³ + +- id: res1 + Reservoir: + temperature: 300 # K + composition: "CH4:1,N2:2" + +- id: res2 + Reservoir: + temperature: 320 # K + pressure: 151987 # Pa + composition: "O2:1,N2:3.76" + +- id: mixer1 + IdealGasReactor: + temperature: 400 # K + pressure: 101325 # Pa + composition: "N2:1" + volume: 0.002 # m³ + +connections: +- id: mfc1 + MassFlowController: + mass_flow_rate: 0.03 # kg/s + source: res1 + target: reactor1 + +- id: mfc2 + MassFlowController: + mass_flow_rate: 0.04 # kg/s + source: res2 + target: reactor1 + +- id: mfc3 + MassFlowController: + mass_flow_rate: 0.025 # kg/s + source: reactor1 + target: mixer1 + +- id: mfc4 + MassFlowController: + mass_flow_rate: 0.035 # kg/s + source: mixer1 + target: reactor2 diff --git a/examples/sample_config.json b/examples/sample_config.json deleted file mode 100644 index d29c801..0000000 --- a/examples/sample_config.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "components": [ - { - "id": "reactor1", - "type": "IdealGasReactor", - "properties": { - "temperature": 1000, - "pressure": 101325, - "composition": "CH4:1,O2:2,N2:7.52" - } - }, - { - "id": "res1", - "type": "Reservoir", - "properties": { - "temperature": 300, - "composition": "O2:1,N2:3.76" - } - } - ], - "connections": [ - { - "id": "mfc1", - "type": "MassFlowController", - "source": "res1", - "target": "reactor1", - "properties": { - "mass_flow_rate": 0.1 - } - } - ] -} diff --git a/examples/sample_config2.json b/examples/sample_config2.json deleted file mode 100644 index 19a1f73..0000000 --- a/examples/sample_config2.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "components": [ - { - "id": "reactor1", - "type": "IdealGasReactor", - "properties": { - "temperature": 1000, - "pressure": 101325, - "composition": "CH4:1,O2:2,N2:7.52" - } - }, - { - "id": "res1", - "type": "Reservoir", - "properties": { - "temperature": 800, - "composition": "O2:1,N2:3.76" - } - }, - { - "id": "downstream", - "type": "Reservoir", - "properties": { - "temperature": 300, - "pressure": 201325, - "composition": "O2:1,N2:3.76" - } - } - ], - "connections": [ - { - "id": "mfc1", - "type": "MassFlowController", - "source": "res1", - "target": "reactor1", - "properties": { - "mass_flow_rate": 0.1 - } - }, - { - "id": "mfc2", - "type": "MassFlowController", - "source": "reactor1", - "target": "downstream", - "properties": { - "flow_rate": 0.1 - } - } - ] -} diff --git a/examples/sample_configs2.yaml b/examples/sample_configs2.yaml new file mode 100644 index 0000000..febb029 --- /dev/null +++ b/examples/sample_configs2.yaml @@ -0,0 +1,46 @@ +metadata: + name: "Extended Reactor Configuration" + description: "Multi-component reactor system with different flow controllers" + version: "2.0" + author: "Boulder Configuration System" + +simulation: + mechanism: "gri30.yaml" + time_step: 0.001 # s + max_time: 5.0 # s + solver: "CVODE_BDF" + relative_tolerance: 1.0e-6 + absolute_tolerance: 1.0e-9 + max_steps: 10000 + +components: +- id: reactor1 + IdealGasReactor: + temperature: 1200 # K + pressure: 101325 # Pa + composition: "CH4:1,O2:2,N2:7.52" + volume: 0.01 # m³ + +- id: res1 + Reservoir: + temperature: 300 # K + composition: "O2:1,N2:3.76" + +- id: res2 + Reservoir: + temperature: 350 # K + pressure: 202650 # Pa + composition: "CH4:1" + +connections: +- id: mfc1 + MassFlowController: + mass_flow_rate: 0.05 # kg/s + source: res1 + target: reactor1 + +- id: mfc2 + MassFlowController: + mass_flow_rate: 0.02 # kg/s + source: res2 + target: reactor1 diff --git a/pyproject.toml b/pyproject.toml index 5667b48..4e72c9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,8 @@ dependencies = [ "dash-bootstrap-components>=1.0.0", "dash-cytoscape>=0.3.0", "cantera>=3.0.0", - "python-dotenv>=1.0.0" + "python-dotenv>=1.0.0", + "PyYAML>=6.0" ] description = "A visual interface for Cantera reactor networks" dynamic = ["version"] diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 75028cb..3f5267c 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -197,33 +197,33 @@ def test_config_upload(self, dash_duo): # For now, test the config display dash_duo.wait_for_element("#config-upload-area", timeout=10) - def test_config_json_edit(self, dash_duo): - """Test JSON configuration editing.""" + def test_config_yaml_edit(self, dash_duo): + """Test YAML configuration editing with 🪨 STONE standard.""" # Click on config file name to open modal dash_duo.wait_for_element("#config-file-name-span", timeout=10) config_span = dash_duo.find_element("#config-file-name-span") dash_duo.driver.execute_script("arguments[0].click();", config_span) # Wait for modal - dash_duo.wait_for_element("#config-json-modal", timeout=5) + dash_duo.wait_for_element("#config-yaml-modal", timeout=5) # Click edit button using JavaScript - edit_button = dash_duo.find_element("#edit-config-json-btn") + edit_button = dash_duo.find_element("#edit-config-yaml-btn") dash_duo.driver.execute_script("arguments[0].click();", edit_button) # Wait for textarea to appear - dash_duo.wait_for_element("#config-json-edit-textarea", timeout=5) + dash_duo.wait_for_element("#config-yaml-edit-textarea", timeout=5) - # Edit the JSON - textarea = dash_duo.find_element("#config-json-edit-textarea") + # Edit the YAML + textarea = dash_duo.find_element("#config-yaml-edit-textarea") current_text = textarea.get_attribute("value") - # Modify the JSON (add a comment or change a value) - modified_text = current_text.replace('"temperature": 300', '"temperature": 350') + # Modify the YAML (change temperature value) + modified_text = current_text.replace("temperature: 300", "temperature: 350") textarea.clear() textarea.send_keys(modified_text) # Save changes using JavaScript click - save_button = dash_duo.find_element("#save-config-json-edit-btn") + save_button = dash_duo.find_element("#save-config-yaml-edit-btn") dash_duo.driver.execute_script("arguments[0].click();", save_button) # Wait for the textarea to disappear (indicates save was processed) @@ -231,7 +231,7 @@ def test_config_json_edit(self, dash_duo): time.sleep(1) try: - textarea = dash_duo.find_element("#config-json-edit-textarea") + textarea = dash_duo.find_element("#config-yaml-edit-textarea") assert not textarea.is_displayed(), "Textarea should be hidden after save" except ( NoSuchElementException,