支持后端解析参数表达式并保留工程JSON
This commit is contained in:
1 parent
48da6be21c
commit
a87d462e94
7 files changed
+640
-3
No files matched your search
@@ -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)},
|
||||
}
|
||||
Reference in new issue
Block a user