支持后端解析参数表达式并保留工程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 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):
+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)},
}