支持后端解析参数表达式并保留工程JSON

This commit is contained in:
ljz committed 2026-09-03 18:18:47 +08:00
1 parent 48da6be21c
commit a87d462e94
7 files changed
+640 -3

No files matched your search

+46 -3
View File
@@ -22,6 +22,11 @@ from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from pydantic import BaseModel, ConfigDict, Field, ValidationError 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.performance import performance_span, profile_phase, profile_run
from app.simulation.property_cache import property_cache_run from app.simulation.property_cache import property_cache_run
from app.simulation.solvers.solver import SolverActivityTracker from app.simulation.solvers.solver import SolverActivityTracker
@@ -1100,7 +1105,14 @@ def validate_reactflow_component_contract(
parameter_values: dict[str, float] = {} parameter_values: dict[str, float] = {}
for parameter in component_spec.parameters: 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) validation_message = parameter.validation_message(value)
if validation_message is not None: if validation_message is not None:
raise ValueError( raise ValueError(
@@ -1667,14 +1679,45 @@ def parameter_float(
node: ReactFlowNodePayload | None, node: ReactFlowNodePayload | None,
name: str, name: str,
default: float, default: float,
*,
quantity: str = "dimensionless",
base_unit: str = "",
expressions_allowed: bool = True,
) -> float: ) -> float:
if node is None: if node is None:
return default return default
value = node.data.parameters.get(name, default) value = node.data.parameters.get(name, default)
try: try:
return float(value) numeric_value = float(value)
except (TypeError, ValueError): 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): def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type):
+408
View File
@@ -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)},
}
@@ -45,6 +45,7 @@ FastAPI 自动生成的 OpenAPI 当前可能显示默认 `info.version=0.1.0`;
| 组件库及分类 | 各库 `library.py` | | 组件库及分类 | 各库 `library.py` |
| 组件目录 JSON | `build_component_catalog()` 与目录 JSON Schema | | 组件目录 JSON | `build_component_catalog()` 与目录 JSON Schema |
| System XML | v3 XSD、`app/system_xml.py` | | System XML | v3 XSD、`app/system_xml.py` |
| ReactFlow 参数表达式 | `app/parameter_expression.py`、`frontend/src/parameterExpression.ts` 及相应合同测试 |
| 网络最终连接检查 | `SimulationNetwork.connect()` | | 网络最终连接检查 | `SimulationNetwork.connect()` |
| HTTP 路由和请求模型 | `app/main.py` | | HTTP 路由和请求模型 | `app/main.py` |
@@ -151,6 +152,13 @@ OpenAPI,但当前多数 JSON 响应仍以 `dict[str, object]` 构造,XML、C
- XML 和求解参数统一使用 SI 基准值; - XML 和求解参数统一使用 SI 基准值;
- 实例 ID 和机器标识必须稳定,显示名称不能代替机器标识。 - 实例 ID 和机器标识必须稳定,显示名称不能代替机器标识。
ReactFlow 工程 JSON 的连续数值参数可保存前端既有的受限算术表达式。
编译或 JSON→XML 时,后端在内存中安全求值,再按 `parameterUnits` 从
显示单位换算为 SI。普通数值及数值字符串仍按已存储的 SI 值解释,避免
二次换算;原表达式不回写工程 JSON。离散选项参数和任意代码不属于该合同。
这是补齐已有工程 JSON v1 前端语义的兼容性修复,不改变 System XML v3:
XML 仍只保存最终 SI 数值。
System XML 校验问题统一包含: System XML 校验问题统一包含:
```json ```json
+1
View File
@@ -11,6 +11,7 @@ description: 读取、校验并简要解释 SystemSimulationApp 工程 JSON v1
- 仅处理 ReactFlow 工程 JSON v1 和 System XML v3。版本缺失、不受支持或模型版本不匹配时,说明问题并停止,不进行迁移猜测。 - 仅处理 ReactFlow 工程 JSON v1 和 System XML v3。版本缺失、不受支持或模型版本不匹配时,说明问题并停止,不进行迁移猜测。
- 组件参数是仿真前设定的固定输入;结果变量才是可随时间绘制的量。不要把“参数”当成结果曲线。 - 组件参数是仿真前设定的固定输入;结果变量才是可随时间绘制的量。不要把“参数”当成结果曲线。
- 工程 JSON 可在连续数值参数中保存受限算术表达式。检查、编译或生成 XML 时由后端安全求值并换算为 SI;不得把计算结果回写到源 JSON。
- 文件通过格式校验不等于物理系统一定可求解。不要隐瞒编译或运行阶段的诊断。 - 文件通过格式校验不等于物理系统一定可求解。不要隐瞒编译或运行阶段的诊断。
- 不直接覆盖源文件,不自行修改参数、连接、组件类型、模型版本或求解设置。 - 不直接覆盖源文件,不自行修改参数、连接、组件类型、模型版本或求解设置。
- 本版不支持把模型自动注入网页、生成可直接打开的预装页面、任意损坏文件修复、模型迁移或自动调参迭代。明确告知用户这些能力尚未实现,不要用手工网页操作冒充支持。 - 本版不支持把模型自动注入网页、生成可直接打开的预装页面、任意损坏文件修复、模型迁移或自动调参迭代。明确告知用户这些能力尚未实现,不要用手工网页操作冒充支持。
@@ -30,10 +30,14 @@ simulation { t_start, t_stop, step, max_step, method }
- 节点的 `id` 是实例稳定标识;显示标签不能替代它。 - 节点的 `id` 是实例稳定标识;显示标签不能替代它。
- `data.modelType` 标识注册模型,`data.modelVersion` 必须与当前组件目录精确匹配,执行前不得自动补成当前版本。 - `data.modelType` 标识注册模型,`data.modelVersion` 必须与当前组件目录精确匹配,执行前不得自动补成当前版本。
- `data.parameters` 保存输入值;`parameterUnits`、科学计数法偏好、坐标、旋转和镜像属于编辑显示信息。 - `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。不能根据节点位置猜测缺失端口。 - 连接必须保留两端组件及 Handle。不能根据节点位置猜测缺失端口。
- `simulation.step` 是结果采样间隔;`max_step` 是求解器内部步长上限,两者不能混用。 - `simulation.step` 是结果采样间隔;`max_step` 是求解器内部步长上限,两者不能混用。
工程 JSON 可以导出为 System XML v3,但转换后不会保留全部画布显示信息的对等逆转换合同。 工程 JSON 可以导出为 System XML v3,但转换后不会保留全部画布显示信息的对等逆转换合同。
导出时表达式仅在内存中求值,System XML 只写入换算后的 SI 数值,不改动输入工程对象或源 JSON 文件。
## System XML v3 ## System XML v3
@@ -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) 解释模型。错误和警告应保留层级、稳定错误码、路径或行号;不要只复述最后一句消息。 `--format` 可为 `auto`、`json` 或 `xml`。完成后按 [file-contracts.md](file-contracts.md) 解释模型。错误和警告应保留层级、稳定错误码、路径或行号;不要只复述最后一句消息。
对工程 JSON,`inspect` 的编译检查会安全计算受支持的连续参数表达式;原始组件数据仍显示用户输入的表达式。仿真时生成的临时 XML 只包含换算后的 SI 数值,不会回写 JSON。
默认只返回首批 50 个紧凑组件、25 条连接和 20 个结果变量,避免大型工程输出撑满上下文。翻阅模型摘要、按组件查看完整合同或搜索结果变量时使用: 默认只返回首批 50 个紧凑组件、25 条连接和 20 个结果变量,避免大型工程输出撑满上下文。翻阅模型摘要、按组件查看完整合同或搜索结果变量时使用:
+172
View File
@@ -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()