From a87d462e94969049f2a88c4b21d740d66739084c Mon Sep 17 00:00:00 2001 From: ljz <425868052@qq.com> Date: Thu, 3 Sep 2026 18:18:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=90=8E=E7=AB=AF=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=8F=82=E6=95=B0=E8=A1=A8=E8=BE=BE=E5=BC=8F=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=B7=A5=E7=A8=8BJSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 49 ++- app/parameter_expression.py | 408 ++++++++++++++++++ .../backend-interface-version-spec-v1.md | 8 + skills/system-simulation/SKILL.md | 1 + .../references/file-contracts.md | 4 + .../system-simulation/references/workflows.md | 1 + tests/test_parameter_expression.py | 172 ++++++++ 7 files changed, 640 insertions(+), 3 deletions(-) create mode 100644 app/parameter_expression.py create mode 100644 tests/test_parameter_expression.py diff --git a/app/main.py b/app/main.py index 1c86aed..68d47ff 100644 --- a/app/main.py +++ b/app/main.py @@ -22,6 +22,11 @@ from fastapi import FastAPI, HTTPException, Request, Response from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse from pydantic import BaseModel, ConfigDict, Field, ValidationError +from app.parameter_expression import ( + ParameterExpressionError, + evaluate_parameter_expression, + expression_value_to_base_unit, +) from app.simulation.performance import performance_span, profile_phase, profile_run from app.simulation.property_cache import property_cache_run from app.simulation.solvers.solver import SolverActivityTracker @@ -1100,7 +1105,14 @@ def validate_reactflow_component_contract( parameter_values: dict[str, float] = {} for parameter in component_spec.parameters: - value = parameter_float(node, parameter.name, parameter.default) + value = parameter_float( + node, + parameter.name, + parameter.default, + quantity=parameter.quantity, + base_unit=parameter.unit, + expressions_allowed=parameter.editor is None, + ) validation_message = parameter.validation_message(value) if validation_message is not None: raise ValueError( @@ -1667,14 +1679,45 @@ def parameter_float( node: ReactFlowNodePayload | None, name: str, default: float, + *, + quantity: str = "dimensionless", + base_unit: str = "", + expressions_allowed: bool = True, ) -> float: if node is None: return default value = node.data.parameters.get(name, default) try: - return float(value) + numeric_value = float(value) except (TypeError, ValueError): - raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.") + if not isinstance(value, str): + raise ValueError( + f"Parameter '{name}' on component '{node.id}' must be numeric." + ) + if not expressions_allowed: + raise ValueError( + f"PARAMETER_EXPRESSION_FORBIDDEN: Parameter '{name}' on component " + f"'{node.id}' is a discrete selection and cannot use an expression." + ) + try: + evaluated = evaluate_parameter_expression(value) + selected_unit = node.data.parameterUnits.get(name, base_unit) + return expression_value_to_base_unit( + evaluated, + quantity=quantity, + selected_unit=selected_unit, + ) + except ParameterExpressionError as exc: + raise ValueError( + f"PARAMETER_EXPRESSION_INVALID: Parameter '{name}' on component " + f"'{node.id}' contains an invalid expression: {exc}." + ) from exc + if not isfinite(numeric_value): + raise ValueError( + f"Parameter '{name}' on component '{node.id}' must be a finite " + "numeric value." + ) + return numeric_value def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type): diff --git a/app/parameter_expression.py b/app/parameter_expression.py new file mode 100644 index 0000000..4e88686 --- /dev/null +++ b/app/parameter_expression.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +from dataclasses import dataclass +import math +import re +from typing import Callable + + +MAX_INPUT_LENGTH = 512 +MAX_TOKEN_COUNT = 256 +MAX_OPERATION_COUNT = 256 +MAX_NESTING_DEPTH = 32 +MAX_FUNCTION_ARGUMENTS = 16 + +_UNSIGNED_NUMBER_PREFIX = re.compile( + r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?" +) + + +class ParameterExpressionError(ValueError): + """Raised when an editor parameter expression cannot be resolved safely.""" + + +@dataclass(frozen=True) +class _Token: + kind: str + text: str + position: int + value: float | None = None + + +def evaluate_parameter_expression(expression: str) -> float: + """Evaluate the same bounded arithmetic subset accepted by the frontend. + + The parser never executes Python code and cannot access names other than + the constants ``pi`` and ``e`` or the explicitly supported functions. + """ + + source = expression.strip() + if source.startswith("="): + source = source[1:].strip() + if not source: + raise ParameterExpressionError("expression must not be empty") + if len(source) > MAX_INPUT_LENGTH: + raise ParameterExpressionError( + f"expression must not exceed {MAX_INPUT_LENGTH} characters" + ) + return _ParameterExpressionParser(_tokenize(source)).parse() + + +def expression_value_to_base_unit( + value: float, + *, + quantity: str, + selected_unit: str, +) -> float: + """Convert an expression result from its editor unit to the SI contract. + + Plain numeric JSON values are already stored in SI and must not pass + through this function. Only expression results use the selected display + unit, matching the existing frontend behavior. + """ + + conversions = _UNIT_CONVERSIONS.get(quantity) + if not conversions: + return _ensure_finite(value, "expression result") + conversion = conversions.get(selected_unit) + if conversion is None: + # The frontend falls back to the first (base) unit for an unknown or + # absent selection. Keep the execution boundary behavior identical. + conversion = next(iter(conversions.values())) + scale, offset = conversion + return _ensure_finite(value * scale + offset, "converted expression result") + + +def _tokenize(source: str) -> tuple[_Token, ...]: + tokens: list[_Token] = [] + position = 0 + + def append(token: _Token) -> None: + tokens.append(token) + if len(tokens) > MAX_TOKEN_COUNT: + raise ParameterExpressionError( + f"expression must not exceed {MAX_TOKEN_COUNT} tokens" + ) + + while position < len(source): + character = source[position] + if character.isspace(): + position += 1 + continue + + if character.isdigit() or ( + character == "." + and position + 1 < len(source) + and source[position + 1].isdigit() + ): + match = _UNSIGNED_NUMBER_PREFIX.match(source, position) + if match is None: + raise ParameterExpressionError( + f"invalid number near character {position + 1}" + ) + text = match.group(0) + value = _ensure_finite(float(text), f"number {text!r}") + append(_Token("number", text, position, value)) + position = match.end() + continue + + if character.isascii() and (character.isalpha() or character == "_"): + end = position + 1 + while end < len(source): + candidate = source[end] + if not candidate.isascii() or not ( + candidate.isalnum() or candidate == "_" + ): + break + end += 1 + append(_Token("identifier", source[position:end], position)) + position = end + continue + + if character == "*" and source[position : position + 2] == "**": + append(_Token("operator", "**", position)) + position += 2 + continue + if character in "+-*/^": + append(_Token("operator", character, position)) + position += 1 + continue + if character == "(": + append(_Token("left_parenthesis", character, position)) + position += 1 + continue + if character == ")": + append(_Token("right_parenthesis", character, position)) + position += 1 + continue + if character == ",": + append(_Token("comma", character, position)) + position += 1 + continue + + raise ParameterExpressionError( + f"unsupported symbol {character!r} at character {position + 1}" + ) + + tokens.append(_Token("end", "", len(source))) + return tuple(tokens) + + +class _ParameterExpressionParser: + def __init__(self, tokens: tuple[_Token, ...]) -> None: + self._tokens = tokens + self._index = 0 + self._operation_count = 0 + + def parse(self) -> float: + value = self._parse_additive(0) + trailing = self._current() + if trailing.kind != "end": + raise ParameterExpressionError( + f"unexpected content {trailing.text!r} near character " + f"{trailing.position + 1}" + ) + return _ensure_finite(value, "expression result") + + def _parse_additive(self, depth: int) -> float: + value = self._parse_multiplicative(depth) + while self._is_operator("+") or self._is_operator("-"): + operator = self._advance().text + right = self._parse_multiplicative(depth) + self._count_operation() + value = _safe_operation( + lambda: value + right if operator == "+" else value - right, + f"operation {operator!r}", + ) + return value + + def _parse_multiplicative(self, depth: int) -> float: + value = self._parse_unary(depth) + while self._is_operator("*") or self._is_operator("/"): + operator = self._advance().text + right = self._parse_unary(depth) + self._count_operation() + if operator == "/" and right == 0: + raise ParameterExpressionError("division by zero is not allowed") + value = _safe_operation( + lambda: value * right if operator == "*" else value / right, + f"operation {operator!r}", + ) + return value + + def _parse_unary(self, depth: int) -> float: + self._assert_depth(depth) + if self._is_operator("+") or self._is_operator("-"): + operator = self._advance().text + self._count_operation() + operand = self._parse_unary(depth + 1) + return _ensure_finite( + operand if operator == "+" else -operand, + f"unary operation {operator!r}", + ) + return self._parse_power(depth) + + def _parse_power(self, depth: int) -> float: + self._assert_depth(depth) + base = self._parse_primary(depth) + if not self._is_operator("^") and not self._is_operator("**"): + return base + operator = self._advance().text + exponent = self._parse_unary(depth + 1) + self._count_operation() + return _safe_operation( + lambda: math.pow(base, exponent), + f"operation {operator!r}", + ) + + def _parse_primary(self, depth: int) -> float: + self._assert_depth(depth) + token = self._current() + if token.kind == "number": + self._advance() + return _ensure_finite( + token.value if token.value is not None else math.nan, + f"number {token.text!r}", + ) + + if token.kind == "identifier": + self._advance() + normalized_name = token.text.casefold() + if self._current().kind == "left_parenthesis": + return self._parse_function_call( + normalized_name, + token.text, + depth + 1, + ) + if normalized_name == "pi": + return math.pi + if normalized_name == "e": + return math.e + raise ParameterExpressionError(f"unknown identifier {token.text!r}") + + if token.kind == "left_parenthesis": + self._advance() + value = self._parse_additive(depth + 1) + self._expect("right_parenthesis", "missing closing parenthesis") + return value + + if token.kind == "end": + raise ParameterExpressionError( + "expression ends before a number, constant, or function" + ) + raise ParameterExpressionError( + f"expected a number, constant, or function near character " + f"{token.position + 1}" + ) + + def _parse_function_call( + self, + normalized_name: str, + source_name: str, + depth: int, + ) -> float: + self._assert_depth(depth) + self._expect( + "left_parenthesis", + f"function {source_name} is missing an opening parenthesis", + ) + arguments: list[float] = [] + if self._current().kind != "right_parenthesis": + while True: + if len(arguments) >= MAX_FUNCTION_ARGUMENTS: + raise ParameterExpressionError( + f"function {source_name} accepts at most " + f"{MAX_FUNCTION_ARGUMENTS} arguments" + ) + arguments.append(self._parse_additive(depth)) + if self._current().kind != "comma": + break + self._advance() + if self._current().kind == "right_parenthesis": + raise ParameterExpressionError( + f"function {source_name} has no argument after its comma" + ) + self._expect( + "right_parenthesis", + f"function {source_name} is missing a closing parenthesis", + ) + self._count_operation() + return _evaluate_function(normalized_name, source_name, arguments) + + def _current(self) -> _Token: + return self._tokens[min(self._index, len(self._tokens) - 1)] + + def _advance(self) -> _Token: + token = self._current() + if token.kind != "end": + self._index += 1 + return token + + def _expect(self, kind: str, message: str) -> _Token: + if self._current().kind != kind: + raise ParameterExpressionError(message) + return self._advance() + + def _is_operator(self, operator: str) -> bool: + token = self._current() + return token.kind == "operator" and token.text == operator + + def _assert_depth(self, depth: int) -> None: + if depth > MAX_NESTING_DEPTH: + raise ParameterExpressionError( + f"expression nesting must not exceed {MAX_NESTING_DEPTH} levels" + ) + + def _count_operation(self) -> None: + self._operation_count += 1 + if self._operation_count > MAX_OPERATION_COUNT: + raise ParameterExpressionError( + f"expression must not exceed {MAX_OPERATION_COUNT} operations" + ) + + +def _evaluate_function( + normalized_name: str, + source_name: str, + arguments: list[float], +) -> float: + def require_count(expected: int) -> None: + if len(arguments) != expected: + raise ParameterExpressionError( + f"function {source_name} requires {expected} arguments, " + f"received {len(arguments)}" + ) + + if normalized_name == "sqrt": + require_count(1) + if arguments[0] < 0: + raise ParameterExpressionError("sqrt argument must not be negative") + operation = lambda: math.sqrt(arguments[0]) + elif normalized_name == "abs": + require_count(1) + operation = lambda: abs(arguments[0]) + elif normalized_name in {"sin", "cos", "tan", "asin", "acos", "atan"}: + require_count(1) + if normalized_name in {"asin", "acos"} and not -1 <= arguments[0] <= 1: + raise ParameterExpressionError( + f"{source_name} argument must be between -1 and 1" + ) + function = getattr(math, normalized_name) + operation = lambda: function(arguments[0]) + elif normalized_name == "exp": + require_count(1) + operation = lambda: math.exp(arguments[0]) + elif normalized_name in {"ln", "log"}: + require_count(1) + if arguments[0] <= 0: + raise ParameterExpressionError(f"{source_name} argument must be positive") + operation = lambda: math.log(arguments[0]) + elif normalized_name == "log10": + require_count(1) + if arguments[0] <= 0: + raise ParameterExpressionError("log10 argument must be positive") + operation = lambda: math.log10(arguments[0]) + elif normalized_name in {"min", "max"}: + if not arguments: + raise ParameterExpressionError( + f"function {source_name} requires at least one argument" + ) + function = min if normalized_name == "min" else max + operation = lambda: float(function(arguments)) + elif normalized_name == "pow": + require_count(2) + operation = lambda: math.pow(arguments[0], arguments[1]) + else: + raise ParameterExpressionError(f"unsupported function {source_name!r}") + return _safe_operation(operation, f"function {source_name}") + + +def _safe_operation(operation: Callable[[], float], context: str) -> float: + try: + value = operation() + except (ArithmeticError, ValueError) as exc: + raise ParameterExpressionError(f"{context} has no finite real result") from exc + return _ensure_finite(float(value), context) + + +def _ensure_finite(value: float, context: str) -> float: + if not math.isfinite(value): + raise ParameterExpressionError(f"{context} is not finite") + return value + + +# Ordered exactly like the editor's unit selector. The first entry is the +# fallback SI unit when a persisted selection is absent or unknown. +_UNIT_CONVERSIONS: dict[str, dict[str, tuple[float, float]]] = { + "area": {"m2": (1.0, 0.0), "cm2": (1.0e-4, 0.0), "mm2": (1.0e-6, 0.0)}, + "heat_transfer_coefficient": {"W/(m2*K)": (1.0, 0.0)}, + "pressure": { + "Pa": (1.0, 0.0), + "kPa": (1.0e3, 0.0), + "MPa": (1.0e6, 0.0), + "bar": (1.0e5, 0.0), + }, + "volume": {"m3": (1.0, 0.0), "L": (1.0e-3, 0.0), "mL": (1.0e-6, 0.0)}, + "temperature": {"K": (1.0, 0.0), "degC": (1.0, 273.15)}, + "length": {"m": (1.0, 0.0), "cm": (1.0e-2, 0.0), "mm": (1.0e-3, 0.0)}, +} diff --git a/docs/standard/backend-interface-version-spec-v1.md b/docs/standard/backend-interface-version-spec-v1.md index a0b2569..54eabbe 100644 --- a/docs/standard/backend-interface-version-spec-v1.md +++ b/docs/standard/backend-interface-version-spec-v1.md @@ -45,6 +45,7 @@ FastAPI 自动生成的 OpenAPI 当前可能显示默认 `info.version=0.1.0`; | 组件库及分类 | 各库 `library.py` | | 组件目录 JSON | `build_component_catalog()` 与目录 JSON Schema | | System XML | v3 XSD、`app/system_xml.py` | +| ReactFlow 参数表达式 | `app/parameter_expression.py`、`frontend/src/parameterExpression.ts` 及相应合同测试 | | 网络最终连接检查 | `SimulationNetwork.connect()` | | HTTP 路由和请求模型 | `app/main.py` | @@ -151,6 +152,13 @@ OpenAPI,但当前多数 JSON 响应仍以 `dict[str, object]` 构造,XML、C - XML 和求解参数统一使用 SI 基准值; - 实例 ID 和机器标识必须稳定,显示名称不能代替机器标识。 +ReactFlow 工程 JSON 的连续数值参数可保存前端既有的受限算术表达式。 +编译或 JSON→XML 时,后端在内存中安全求值,再按 `parameterUnits` 从 +显示单位换算为 SI。普通数值及数值字符串仍按已存储的 SI 值解释,避免 +二次换算;原表达式不回写工程 JSON。离散选项参数和任意代码不属于该合同。 +这是补齐已有工程 JSON v1 前端语义的兼容性修复,不改变 System XML v3: +XML 仍只保存最终 SI 数值。 + System XML 校验问题统一包含: ```json diff --git a/skills/system-simulation/SKILL.md b/skills/system-simulation/SKILL.md index cf96c3d..a063a55 100644 --- a/skills/system-simulation/SKILL.md +++ b/skills/system-simulation/SKILL.md @@ -11,6 +11,7 @@ description: 读取、校验并简要解释 SystemSimulationApp 工程 JSON v1 - 仅处理 ReactFlow 工程 JSON v1 和 System XML v3。版本缺失、不受支持或模型版本不匹配时,说明问题并停止,不进行迁移猜测。 - 组件参数是仿真前设定的固定输入;结果变量才是可随时间绘制的量。不要把“参数”当成结果曲线。 +- 工程 JSON 可在连续数值参数中保存受限算术表达式。检查、编译或生成 XML 时由后端安全求值并换算为 SI;不得把计算结果回写到源 JSON。 - 文件通过格式校验不等于物理系统一定可求解。不要隐瞒编译或运行阶段的诊断。 - 不直接覆盖源文件,不自行修改参数、连接、组件类型、模型版本或求解设置。 - 本版不支持把模型自动注入网页、生成可直接打开的预装页面、任意损坏文件修复、模型迁移或自动调参迭代。明确告知用户这些能力尚未实现,不要用手工网页操作冒充支持。 diff --git a/skills/system-simulation/references/file-contracts.md b/skills/system-simulation/references/file-contracts.md index 2ced179..e6b6753 100644 --- a/skills/system-simulation/references/file-contracts.md +++ b/skills/system-simulation/references/file-contracts.md @@ -30,10 +30,14 @@ simulation { t_start, t_stop, step, max_step, method } - 节点的 `id` 是实例稳定标识;显示标签不能替代它。 - `data.modelType` 标识注册模型,`data.modelVersion` 必须与当前组件目录精确匹配,执行前不得自动补成当前版本。 - `data.parameters` 保存输入值;`parameterUnits`、科学计数法偏好、坐标、旋转和镜像属于编辑显示信息。 +- 连续数值参数可保存受限算术表达式字符串。支持可选前导 `=`、`+ - * / ^ **`、括号、科学计数法、`pi/e` 和白名单函数 `sqrt/abs/sin/cos/tan/asin/acos/atan/exp/ln/log/log10/min/max/pow`。不支持变量引用、组件间引用、属性访问或任意代码。 +- 普通数值及可直接解析的数值字符串按已存储的 SI 值处理;只有表达式的计算结果才按 `parameterUnits` 中的显示单位换算为 SI。例如 `area0 = "3.14*10**2/4"` 且单位为 `mm2` 时,XML 值为 `7.85e-05` m²,JSON 仍保留原表达式。 +- 下拉选项、介质引用等离散参数不允许使用表达式。表达式语法、值域或复杂度不合法时,必须在编译/仿真前明确报错,不得猜测或改写。 - 连接必须保留两端组件及 Handle。不能根据节点位置猜测缺失端口。 - `simulation.step` 是结果采样间隔;`max_step` 是求解器内部步长上限,两者不能混用。 工程 JSON 可以导出为 System XML v3,但转换后不会保留全部画布显示信息的对等逆转换合同。 +导出时表达式仅在内存中求值,System XML 只写入换算后的 SI 数值,不改动输入工程对象或源 JSON 文件。 ## System XML v3 diff --git a/skills/system-simulation/references/workflows.md b/skills/system-simulation/references/workflows.md index 66feb6b..238c228 100644 --- a/skills/system-simulation/references/workflows.md +++ b/skills/system-simulation/references/workflows.md @@ -29,6 +29,7 @@ py -3.12 skills/system-simulation/scripts/simulation_skill.py inspect INPUT --fo ``` `--format` 可为 `auto`、`json` 或 `xml`。完成后按 [file-contracts.md](file-contracts.md) 解释模型。错误和警告应保留层级、稳定错误码、路径或行号;不要只复述最后一句消息。 +对工程 JSON,`inspect` 的编译检查会安全计算受支持的连续参数表达式;原始组件数据仍显示用户输入的表达式。仿真时生成的临时 XML 只包含换算后的 SI 数值,不会回写 JSON。 默认只返回首批 50 个紧凑组件、25 条连接和 20 个结果变量,避免大型工程输出撑满上下文。翻阅模型摘要、按组件查看完整合同或搜索结果变量时使用: diff --git a/tests/test_parameter_expression.py b/tests/test_parameter_expression.py new file mode 100644 index 0000000..2b7d50d --- /dev/null +++ b/tests/test_parameter_expression.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import unittest +from xml.etree import ElementTree as ET + +from app.main import ( + ReactFlowNodePayload, + ReactFlowProjectPayload, + build_reactflow_system_xml, + reactflow_project_storage_data, + validate_reactflow_component_contract, +) +from app.parameter_expression import ( + ParameterExpressionError, + evaluate_parameter_expression, +) +from app.simulation.registry import get_component_model_spec +from tests.test_amesim_pnvo001_signal_xml import amesim_pnvo001_signal_project + + +AREA_EXPRESSION = "3.14*10**2/4" +AREA_IN_SQUARE_METRES = 7.85e-5 + + +def pnvo001_project_with_area( + area0: object, + *, + selected_unit: str = "mm2", +) -> ReactFlowProjectPayload: + project = amesim_pnvo001_signal_project().model_copy(deep=True) + valve = next(node for node in project.nodes if node.id == "valve_1") + valve.data.parameters["area0"] = area0 + valve.data.parameterUnits["area0"] = selected_unit + return project + + +def pnvo001_node(project: ReactFlowProjectPayload) -> ReactFlowNodePayload: + return next(node for node in project.nodes if node.id == "valve_1") + + +class ParameterExpressionParserTests(unittest.TestCase): + def test_supported_arithmetic_constants_and_functions(self) -> None: + cases = { + "=3.14*10^2/4": 78.5, + "3.14*10**2/4": 78.5, + "(2 + 3) * 4": 20.0, + "2^3^2": 512.0, + "-2^2": -4.0, + "2.5E-3": 0.0025, + "sqrt(16) + abs(-2)": 6.0, + "sin(pi/2) + ln(e)": 2.0, + "max(1, 5, 3) + pow(2, 3)": 13.0, + } + + for expression, expected in cases.items(): + with self.subTest(expression=expression): + self.assertAlmostEqual( + evaluate_parameter_expression(expression), + expected, + places=12, + ) + + def test_invalid_or_unsafe_expressions_are_rejected(self) -> None: + cases = ( + "", + "=", + "1 / 0", + "sqrt(-1)", + "pow(-1, 0.5)", + "unknown + 1", + "window.alert(1)", + "__import__('os')", + "1 + * 2", + "1e309", + "min()", + "max(" + ",".join("1" for _ in range(17)) + ")", + "(" * 34 + "1" + ")" * 34, + "1" * 513, + ) + + for expression in cases: + with self.subTest(expression=expression[:40]): + with self.assertRaises(ParameterExpressionError): + evaluate_parameter_expression(expression) + + +class ParameterExpressionExecutionTests(unittest.TestCase): + def test_pnvo001_area_expression_uses_selected_mm2_unit(self) -> None: + project = pnvo001_project_with_area(AREA_EXPRESSION) + valve = pnvo001_node(project) + spec = get_component_model_spec(valve.data.modelType) + + parameters = validate_reactflow_component_contract(valve, spec) + + self.assertAlmostEqual( + parameters["area0"], + AREA_IN_SQUARE_METRES, + places=15, + ) + + def test_plain_numeric_si_value_is_not_converted_again(self) -> None: + for stored_value in (AREA_IN_SQUARE_METRES, "7.85e-5"): + with self.subTest(stored_value=stored_value): + project = pnvo001_project_with_area(stored_value) + valve = pnvo001_node(project) + spec = get_component_model_spec(valve.data.modelType) + + parameters = validate_reactflow_component_contract(valve, spec) + + self.assertEqual(parameters["area0"], AREA_IN_SQUARE_METRES) + + def test_storage_preserves_the_original_expression(self) -> None: + project = pnvo001_project_with_area(AREA_EXPRESSION) + + stored = reactflow_project_storage_data(project) + + stored_valve = next( + node for node in stored["nodes"] if node["id"] == "valve_1" + ) + self.assertEqual( + stored_valve["data"]["parameters"]["area0"], + AREA_EXPRESSION, + ) + self.assertEqual( + pnvo001_node(project).data.parameters["area0"], + AREA_EXPRESSION, + ) + + def test_xml_contains_resolved_si_value_without_mutating_project(self) -> None: + project = pnvo001_project_with_area(AREA_EXPRESSION) + + xml_bytes = build_reactflow_system_xml(project) + root = ET.fromstring(xml_bytes) + + area_parameter = root.find( + "./Components/Component[@id='valve_1']/Parameter[@name='area0']" + ) + self.assertIsNotNone(area_parameter) + assert area_parameter is not None + self.assertAlmostEqual( + float(area_parameter.attrib["value"]), + AREA_IN_SQUARE_METRES, + places=15, + ) + self.assertNotIn(AREA_EXPRESSION, xml_bytes.decode("utf-8")) + self.assertEqual( + pnvo001_node(project).data.parameters["area0"], + AREA_EXPRESSION, + ) + + def test_invalid_expression_has_stable_execution_error_code(self) -> None: + project = pnvo001_project_with_area("sqrt(-1)") + + with self.assertRaisesRegex( + ValueError, + "PARAMETER_EXPRESSION_INVALID.*area0.*valve_1", + ): + build_reactflow_system_xml(project) + + def test_discrete_parameter_expression_is_rejected(self) -> None: + project = pnvo001_project_with_area(AREA_IN_SQUARE_METRES) + pnvo001_node(project).data.parameters["flowset"] = "1 + 0" + + with self.assertRaisesRegex( + ValueError, + "PARAMETER_EXPRESSION_FORBIDDEN.*flowset.*valve_1", + ): + build_reactflow_system_xml(project) + + +if __name__ == "__main__": + unittest.main()