支持后端解析参数表达式并保留工程JSON
This commit is contained in:
1 parent
48da6be21c
commit
a87d462e94
7 files changed
+640
-3
No files matched your search
+46
-3
@@ -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):
|
||||
|
||||
@@ -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` |
|
||||
| 组件目录 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
|
||||
|
||||
@@ -11,6 +11,7 @@ description: 读取、校验并简要解释 SystemSimulationApp 工程 JSON v1
|
||||
|
||||
- 仅处理 ReactFlow 工程 JSON v1 和 System XML v3。版本缺失、不受支持或模型版本不匹配时,说明问题并停止,不进行迁移猜测。
|
||||
- 组件参数是仿真前设定的固定输入;结果变量才是可随时间绘制的量。不要把“参数”当成结果曲线。
|
||||
- 工程 JSON 可在连续数值参数中保存受限算术表达式。检查、编译或生成 XML 时由后端安全求值并换算为 SI;不得把计算结果回写到源 JSON。
|
||||
- 文件通过格式校验不等于物理系统一定可求解。不要隐瞒编译或运行阶段的诊断。
|
||||
- 不直接覆盖源文件,不自行修改参数、连接、组件类型、模型版本或求解设置。
|
||||
- 本版不支持把模型自动注入网页、生成可直接打开的预装页面、任意损坏文件修复、模型迁移或自动调参迭代。明确告知用户这些能力尚未实现,不要用手工网页操作冒充支持。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 个结果变量,避免大型工程输出撑满上下文。翻阅模型摘要、按组件查看完整合同或搜索结果变量时使用:
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user