"""CLI input adapter; XML stays the numerical backend's execution contract.""" from __future__ import annotations import ast from copy import deepcopy import json from math import isfinite import operator from pathlib import Path from app.system_xml import validate_system_xml_document # Matches the editor's displayed-unit conversions. Stored numeric values are # already SI; only arithmetic expressions are evaluated in the selected unit. _SCALES = { "area": {"m2": 1, "cm2": 1e-4, "mm2": 1e-6}, "length": {"m": 1, "cm": .01, "mm": .001}, "pressure": {"Pa": 1, "kPa": 1e3, "MPa": 1e6, "bar": 1e5}, "volume": {"m3": 1, "L": .001, "mL": 1e-6}, } _OPERATIONS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow} def arithmetic_value(source: str) -> float: """Bounded arithmetic only: never evaluate code, names or function calls.""" source = source.strip().removeprefix("=").strip() if len(source) > 512: raise ValueError("Parameter expression exceeds 512 characters.") tree = ast.parse(source, mode="eval") if sum(1 for _ in ast.walk(tree)) > 256: raise ValueError("Parameter expression is too complex.") def visit(node, depth=0): if depth > 32: raise ValueError("Parameter expression is nested too deeply.") if isinstance(node, ast.Constant) and type(node.value) in (int, float): result = float(node.value) elif isinstance(node, ast.UnaryOp) and type(node.op) in (ast.UAdd, ast.USub): result = visit(node.operand, depth+1) * (-1 if isinstance(node.op, ast.USub) else 1) elif isinstance(node, ast.BinOp) and type(node.op) in _OPERATIONS: a, b = visit(node.left, depth+1), visit(node.right, depth+1) if isinstance(node.op, ast.Pow) and abs(b) > 16: raise ValueError("CLI parameter exponents must be between -16 and 16.") result = _OPERATIONS[type(node.op)](a, b) else: raise ValueError("CLI supports arithmetic expressions only; export complex expressions as XML from the editor.") if not isinstance(result, (int, float)) or not isfinite(result): raise ValueError("Parameter expression must produce a finite real number.") return result return float(visit(tree.body)) def project_xml(data: dict) -> bytes: from app.main import ReactFlowProjectPayload, build_reactflow_system_xml from app.simulation.registry import get_component_model_spec normalized = deepcopy(data) for node in normalized.get("nodes", []): model = node["data"] spec = get_component_model_spec(model["modelType"]) for name, value in list(model.get("parameters", {}).items()): if not isinstance(value, str): continue try: number = float(value) except ValueError: definition = spec.parameter_by_name[name] if definition.editor: raise ValueError(f"{node['id']}.{name}: a discrete parameter cannot be an expression.") number = arithmetic_value(value) unit = model.get("parameterUnits", {}).get(name, definition.unit) if definition.quantity in _SCALES: if unit not in _SCALES[definition.quantity]: raise ValueError(f"Unsupported parameter unit: {unit}.") number *= _SCALES[definition.quantity][unit] elif definition.quantity == "temperature" and unit == "degC": number += 273.15 elif unit != definition.unit: raise ValueError(f"Unsupported parameter unit: {unit}.") if not isfinite(number): raise ValueError(f"{node['id']}.{name}: parameter must be finite.") model["parameters"][name] = number return build_reactflow_system_xml(ReactFlowProjectPayload.model_validate(normalized)) def load_input(path: Path): xml = project_xml(json.loads(path.read_text(encoding="utf-8"))) if path.suffix.lower() == ".json" else path.read_bytes() report = validate_system_xml_document(xml) if not report.valid or report.document is None: raise ValueError(f"Invalid simulation XML: {report.as_dict()}") return xml, report.document