旧版前端工程文件导入时版本对比查验、审阅与仿真时部分阻挡功能实现;前端参数输入格式统一规范

This commit is contained in:
lujingze committed 2026-09-12 14:15:40 +00:00
1 parent 22579e51c9
commit 44b6ea74ab
32 files changed
+2087 -322

No files matched your search

+42 -16
View File
@@ -20,7 +20,9 @@ from xml.etree import ElementTree as ET
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field, StrictFloat, StrictStr, ValidationError
from app.project_parameters import prepare_project, version_warning
from app.simulation.performance import performance_span, profile_phase, profile_run
from app.simulation.native_codegen.transport import NativeSeriesJson, serialize_result_parts
@@ -147,8 +149,8 @@ class ReactFlowNodeData(BaseModel):
componentType: str = "component"
modelType: str = "component"
# Optional at the storage boundary so an incompatible project can still be
# opened and inspected. Every execution path requires an exact registry
# match before defaults, equations, or ports are consumed.
# opened and inspected. Input adapters warn and select the current model;
# the normalized XML/numerical boundary still enforces an exact match.
modelVersion: str | None = None
ports: list[ReactFlowPortDefinition] = Field(default_factory=list)
parameters: dict[str, Any] = Field(default_factory=dict)
@@ -185,17 +187,17 @@ class ReactFlowEdgePayload(BaseModel):
class ReactFlowSimulationConfig(BaseModel):
t_start: float = 0.0
t_stop: float = 2.0
step: float = 0.1
max_step: float = 0.005
t_start: StrictFloat | StrictStr = 0.0
t_stop: StrictFloat | StrictStr = 2.0
step: StrictFloat | StrictStr = 0.1
max_step: StrictFloat | StrictStr = 0.005
method: str = "BDF"
class ReactFlowProjectPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
projectSchemaVersion: Literal[1]
projectSchemaVersion: Literal[1, 2]
name: str = "untitled"
nodes: list[ReactFlowNodePayload] = Field(default_factory=list)
edges: list[ReactFlowEdgePayload] = Field(default_factory=list)
@@ -339,10 +341,19 @@ def get_component_catalog() -> dict[str, object]:
@app.post("/api/reactflow/system-xml")
def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response:
try:
xml = build_reactflow_system_xml(payload)
normalized, notices = prepare_project(payload)
xml = build_reactflow_system_xml(normalized)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return Response(content=xml, media_type="application/xml")
headers = {}
if notices:
warning = {**version_warning(notices), "totalCount": len(notices), "components": notices[:10]}
encoded = quote(json.dumps(warning, ensure_ascii=False))
while len(encoded) > 3800 and warning["components"]:
warning["components"] = warning["components"][:-1]
encoded = quote(json.dumps(warning, ensure_ascii=False))
headers["X-Component-Version-Warnings"] = encoded
return Response(content=xml, media_type="application/xml", headers=headers)
@app.post("/api/simulation-results/csv")
@@ -510,10 +521,11 @@ def simulate_reactflow_test_mql(payload: ReactFlowProjectPayload) -> dict[str, o
@app.post("/api/reactflow/compile-model")
def compile_reactflow_model(payload: ReactFlowProjectPayload) -> dict[str, object]:
try:
network = compile_reactflow_network(payload)
normalized, notices = prepare_project(payload)
network = compile_reactflow_network(normalized)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"success": True, **network.as_interface_dict()}
return {"success": True, **network.as_interface_dict(), "warnings": [version_warning(notices)] if notices else []}
@app.post("/api/system-xml/validate")
@@ -1025,7 +1037,7 @@ def validate_reactflow_component_contract(
node: ReactFlowNodePayload,
component_spec: "ComponentModelSpec",
) -> dict[str, float]:
"""Validate the persisted model contract before consuming current defaults."""
"""Validate the normalized SI model contract before consuming current defaults."""
if (
node.data.componentType != node.data.modelType
@@ -1094,6 +1106,7 @@ def validate_reactflow_execution_contract(
def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
from app.simulation.registry import get_component_model_spec
validate_si_project(project)
system_attributes = {
"schemaVersion": SYSTEM_XML_SCHEMA_VERSION,
"unitSystem": SYSTEM_XML_UNIT_SYSTEM,
@@ -1300,6 +1313,7 @@ def _solver_model_from_reactflow(
) -> SolverModelInput:
from app.simulation.registry import get_component_model_spec
validate_si_project(project)
components: list[SolverComponentInput] = []
for node in project.nodes:
spec = get_component_model_spec(node.data.modelType)
@@ -1517,6 +1531,18 @@ def parameter_float(
return default
value = node.data.parameters.get(name, default)
try:
return float(value)
except (TypeError, ValueError):
raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.")
if type(value) in (int, float) and isfinite(value):
return float(value)
except OverflowError:
pass
raise ValueError(f"Parameter '{name}' on component '{node.id}' must be a finite SI number; normalize expressions before execution.")
def validate_si_project(project: ReactFlowProjectPayload) -> None:
"""Guard the editor-independent numerical boundary, including time settings."""
if project.projectSchemaVersion != 1:
raise ValueError("Execution requires normalized SI data; preprocess project input first.")
for name in ("t_start", "t_stop", "step", "max_step"):
value = getattr(project.simulation, name)
if type(value) not in (int, float) or not isfinite(value):
raise ValueError(f"Simulation {name} must be a finite SI number.")
+250
View File
@@ -0,0 +1,250 @@
"""User-input adapter shared by HTTP and CLI; the numerical layer stays SI-only.
JSON v1 numbers (including decimal strings) were SI, but expressions used the
selected unit. JSON v2 consistently uses the selected unit for both. Missing
parameters use catalog defaults, which are always SI. Never infer a format from
magnitudes or relabel a legacy project without converting its values.
"""
from __future__ import annotations
import json
import math
from pathlib import Path
import re
UNIT_TABLE = json.loads((Path(__file__).resolve().parent.parent / "schemas" / "parameter-units.json").read_text(encoding="utf-8"))
DECIMAL = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\Z", re.ASCII)
TOKEN = re.compile(r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|[A-Za-z_][A-Za-z_0-9]*|\*\*|[+*/^(),-]", re.ASCII)
def finite(value: float) -> float:
if not math.isfinite(value):
raise ValueError("Parameter expression must produce a finite real number.")
return value
def numeric_literal(value: object) -> float | None:
if type(value) in (int, float):
try:
return finite(float(value))
except OverflowError as exc:
raise ValueError("Parameter magnitude exceeds finite float range.") from exc
if isinstance(value, str) and DECIMAL.fullmatch(value.strip()):
return finite(float(value))
return None
def expression_value(source: str) -> float:
"""Same bounded recursive-descent grammar as parameterExpression.ts; no eval."""
source = source.strip().removeprefix("=").strip()
if not source or len(source) > 512:
raise ValueError("Parameter expression must contain 1..512 characters.")
tokens: list[str] = []
position = 0
while position < len(source):
if source[position].isspace():
position += 1
continue
match = TOKEN.match(source, position)
if match is None:
raise ValueError(f"Unsupported expression character at {position + 1}.")
tokens.append(match[0])
position = match.end()
if len(tokens) > 256:
raise ValueError("Parameter expression exceeds 256 tokens.")
tokens.append("")
index = 0
operations = 0
def current():
return tokens[index]
def take():
nonlocal index
token = current()
if token:
index += 1
return token
def operation():
nonlocal operations
operations += 1
if operations > 256:
raise ValueError("Parameter expression exceeds 256 operations.")
def depth_check(depth):
if depth > 32:
raise ValueError("Parameter expression exceeds 32 nesting levels.")
def additive(depth):
value = multiplicative(depth)
while current() in ("+", "-"):
op = take()
right = multiplicative(depth)
operation()
value = finite(value + right if op == "+" else value - right)
return value
def multiplicative(depth):
value = unary(depth)
while current() in ("*", "/"):
op = take()
right = unary(depth)
operation()
value = finite(value * right if op == "*" else value / right)
return value
def unary(depth):
depth_check(depth)
if current() in ("+", "-"):
op = take()
operation()
value = unary(depth + 1)
return value if op == "+" else -value
return power(depth)
def power(depth):
depth_check(depth)
value = primary(depth)
if current() in ("^", "**"):
take()
exponent = unary(depth + 1)
operation()
value = finite(math.pow(value, exponent))
return value
def primary(depth):
depth_check(depth)
token = take()
if token == "(":
value = additive(depth + 1)
if take() != ")":
raise ValueError("Missing closing parenthesis.")
return value
if token and (token[0].isdigit() or token[0] == "."):
return finite(float(token))
name = token.lower()
if token and (token[0].isalpha() or token[0] == "_"):
if current() != "(":
if name in ("pi", "e"):
return math.pi if name == "pi" else math.e
raise ValueError(f"Unknown identifier: {token}.")
depth_check(depth + 1)
take()
args = []
if current() != ")":
while True:
if len(args) >= 16:
raise ValueError("Functions accept at most 16 arguments.")
args.append(additive(depth + 1))
if current() != ",":
break
take()
if take() != ")":
raise ValueError("Missing function closing parenthesis.")
operation()
functions = {"sqrt": math.sqrt, "abs": abs, "sin": math.sin,
"cos": math.cos, "tan": math.tan, "asin": math.asin,
"acos": math.acos, "atan": math.atan, "exp": math.exp,
"ln": math.log, "log": math.log, "log10": math.log10,
"pow": math.pow}
if name in ("min", "max") and args:
return finite((min if name == "min" else max)(args))
if name not in functions or len(args) != (2 if name == "pow" else 1):
raise ValueError(f"Unsupported function or argument count: {token}.")
return finite(functions[name](*args))
raise ValueError("Expected a number, constant or function.")
try:
result = additive(0)
if current():
raise ValueError("Unexpected trailing expression content.")
return finite(result)
except (ArithmeticError, RecursionError) as exc:
raise ValueError("Invalid arithmetic or expression domain.") from exc
def unit_conversion(definition, unit: str) -> tuple[float, float]:
options = UNIT_TABLE.get(definition.quantity, {}) if definition.unit else {}
if unit in options:
scale, offset, _ = options[unit]
return scale, offset
if unit == definition.unit:
return 1.0, 0.0
raise ValueError(f"Unsupported unit '{unit}' for {definition.name} ({definition.quantity}).")
def prepare_project(project):
"""Copy external input to a current-version, numeric SI execution project.
Returns consolidated version notices to the caller. Does not mutate saved
data and does not weaken the strict XML/native model-version checks.
"""
from app.simulation.registry import get_component_model_spec
normalized = project.model_copy(deep=True)
notices = []
specs = {}
for node in normalized.nodes:
model = node.data
spec = specs.get(model.modelType)
if spec is None:
spec = get_component_model_spec(model.modelType)
specs[model.modelType] = spec
if model.componentType != spec.model_type:
raise ValueError(f"COMPONENT_MODEL_TYPE_MISMATCH: {node.id}.")
if model.modelVersion != spec.model_version:
notices.append({"componentId": node.id, "label": model.label or node.id,
"storedVersion": model.modelVersion,
"currentVersion": spec.model_version})
for name, value in model.parameters.items():
definition = spec.parameter_by_name.get(name)
if definition is None:
raise ValueError(f"Component '{node.id}' contains unsupported parameters: {name}.")
try:
scale, offset = unit_conversion(definition, model.parameterUnits.get(name, definition.unit))
number = numeric_literal(value)
is_expression = number is None
if is_expression:
if not isinstance(value, str) or definition.editor:
raise ValueError("Expected a numeric value; discrete parameters cannot use expressions.")
number = expression_value(value)
if project.projectSchemaVersion == 2 or is_expression:
number = finite(number * scale + offset)
model.parameters[name] = number
except ValueError as exc:
raise ValueError(f"{node.id}.{name}: {exc}") from exc
# Explicit legacy migrations also used by the browser.
if model.modelVersion == "0.1.0" and model.modelType == "amesim_forc":
model.parameters.setdefault("direction", 1.0)
if model.modelVersion == "0.1.0" and model.modelType == "amesim_lmechn1":
count = model.parameters.get("v1")
if count in range(1, 9):
for edge in normalized.edges:
if edge.source == node.id and edge.sourceHandle == "port_9":
edge.sourceHandle = f"port_{int(count) + 1}"
if edge.target == node.id and edge.targetHandle == "port_9":
edge.targetHandle = f"port_{int(count) + 1}"
model.parameters["sum"] = 1.0
# Historical LMECHN1 exposed only nine ports; use its migrated contract.
from app.main import ReactFlowPortDefinition
model.ports = [ReactFlowPortDefinition(name=p.name, kind=p.kind, domain=p.domain,
nominalRole=p.nominal_role, positiveFlowDirection=p.positive_flow_direction)
for p in spec.ports]
model.modelVersion = spec.model_version
model.parameterUnits = {p.name: p.unit for p in spec.parameters}
model.parameterScientificNotation = {}
for name in ("t_start", "t_stop", "step", "max_step"):
value = getattr(normalized.simulation, name)
number = numeric_literal(value)
if number is None:
number = expression_value(value)
setattr(normalized.simulation, name, number)
normalized.projectSchemaVersion = 1 # Internal numeric SI contract, never a v2 wire payload.
return normalized, notices
def version_warning(notices):
return {"code": "COMPONENT_MODEL_VERSION_WARNING",
"message": "旧版或版本未知的组件将使用当前模型执行,可能仿真失败或结果与实际不符。",
"components": notices}
-15
View File
@@ -1,15 +0,0 @@
# 元件开发示例
权威规则见 [组件模型建模规范](../../../docs/standard/component-model-authoring-spec-v1.md)。当前模型采用 Python 声明、C 数值实现。
以气瓶为例:
1. 在 [cylinder.py](experimental/storage/cylinder.py) 声明 `MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、`RESULT_VARIABLES`、`DISPLAY` 和 `create()`。
2. 构造函数调用 `set_parameter_values()`、`register_declared_port()`,保存介质选择和容积。不要在 Python 中计算密度、内能或状态导数。
3. 通过 `EQUATIONS` 声明端口压力与气瓶状态之间的约束;只保存变量名和关系。
气动端口同时声明 `computation`,说明温度、压力和质量/能量流率由谁提供;固定参考口还要声明其支路的参考来源,见 [端口供需合同](../../../docs/standard/port-computation-contract.md)。
4. 在 [extended.py](../native_codegen/extended.py) 分配状态及输出位置,生成 `native_medium_init()` 初始化调用和气瓶质量/能量导数计算。
5. 公共物性和数值公式由 [kernels.c](../../../native/components/kernels.c) 实现,积分和事件由 `native/runtime/` 处理。
6. 加入组件库 `library.py` 及 C 版本白名单,验证目录/XML 合同、边界输入、逆流、守恒、RK45/BDF 和输出键。
新增模型的参考值应来自独立解析结果、外部可信结果或已有冻结基准;不恢复第二套 Python 数值实现。
+7 -72
View File
@@ -1,85 +1,20 @@
"""CLI input adapter; XML stays the numerical backend's execution contract."""
"""CLI input adapter shared with HTTP; numerical execution accepts SI XML only."""
from __future__ import annotations
import ast
from copy import deepcopy
import json
from math import isfinite
import operator
from pathlib import Path
import warnings
from app.project_parameters import expression_value as arithmetic_value, prepare_project, version_warning
from app.system_xml import validate_system_xml_document
# Matches the editor's displayed-unit conversions. Stored numeric values are
# already SI; only arithmetic expressions are evaluated in the selected unit.
_SCALES = {
"area": {"m2": 1, "cm2": 1e-4, "mm2": 1e-6},
"length": {"m": 1, "cm": .01, "mm": .001},
"pressure": {"Pa": 1, "kPa": 1e3, "MPa": 1e6, "bar": 1e5},
"volume": {"m3": 1, "L": .001, "mL": 1e-6},
}
_OPERATIONS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Pow: operator.pow}
def arithmetic_value(source: str) -> float:
"""Bounded arithmetic only: never evaluate code, names or function calls."""
source = source.strip().removeprefix("=").strip()
if len(source) > 512:
raise ValueError("Parameter expression exceeds 512 characters.")
tree = ast.parse(source, mode="eval")
if sum(1 for _ in ast.walk(tree)) > 256:
raise ValueError("Parameter expression is too complex.")
def visit(node, depth=0):
if depth > 32:
raise ValueError("Parameter expression is nested too deeply.")
if isinstance(node, ast.Constant) and type(node.value) in (int, float):
result = float(node.value)
elif isinstance(node, ast.UnaryOp) and type(node.op) in (ast.UAdd, ast.USub):
result = visit(node.operand, depth+1) * (-1 if isinstance(node.op, ast.USub) else 1)
elif isinstance(node, ast.BinOp) and type(node.op) in _OPERATIONS:
a, b = visit(node.left, depth+1), visit(node.right, depth+1)
if isinstance(node.op, ast.Pow) and abs(b) > 16:
raise ValueError("CLI parameter exponents must be between -16 and 16.")
result = _OPERATIONS[type(node.op)](a, b)
else:
raise ValueError("CLI supports arithmetic expressions only; export complex expressions as XML from the editor.")
if not isinstance(result, (int, float)) or not isfinite(result):
raise ValueError("Parameter expression must produce a finite real number.")
return result
return float(visit(tree.body))
def project_xml(data: dict) -> bytes:
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
from app.simulation.registry import get_component_model_spec
normalized = deepcopy(data)
for node in normalized.get("nodes", []):
model = node["data"]
spec = get_component_model_spec(model["modelType"])
for name, value in list(model.get("parameters", {}).items()):
if not isinstance(value, str):
continue
try:
number = float(value)
except ValueError:
definition = spec.parameter_by_name[name]
if definition.editor:
raise ValueError(f"{node['id']}.{name}: a discrete parameter cannot be an expression.")
number = arithmetic_value(value)
unit = model.get("parameterUnits", {}).get(name, definition.unit)
if definition.quantity in _SCALES:
if unit not in _SCALES[definition.quantity]:
raise ValueError(f"Unsupported parameter unit: {unit}.")
number *= _SCALES[definition.quantity][unit]
elif definition.quantity == "temperature" and unit == "degC":
number += 273.15
elif unit != definition.unit:
raise ValueError(f"Unsupported parameter unit: {unit}.")
if not isfinite(number):
raise ValueError(f"{node['id']}.{name}: parameter must be finite.")
model["parameters"][name] = number
return build_reactflow_system_xml(ReactFlowProjectPayload.model_validate(normalized))
normalized, notices = prepare_project(ReactFlowProjectPayload.model_validate(data))
if notices:
warnings.warn(json.dumps(version_warning(notices), ensure_ascii=False), UserWarning, stacklevel=2)
return build_reactflow_system_xml(normalized)
def load_input(path: Path):