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

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

No files matched your search

+1
View File
@@ -110,6 +110,7 @@ XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `require
## 文档
- [开发文档索引](docs/README.md)
- [现行规范索引与新组件注册流程](docs/standard/README.md)
- [后端接口版本与定义规范 v1](docs/standard/backend-interface-version-spec-v1.md)
- [组件模型建模规范 v1](docs/standard/component-model-authoring-spec-v1.md)
- [组件库分类、发现与读取规范 v1](docs/standard/component-library-spec-v1.md)
+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):
@@ -0,0 +1,72 @@
# 工程版本警告与统一参数输入验收
报告版本:1.0.0;日期:2026-09-12;基线:`22579e5` 加本次工作区改动。
本次落实旧组件版本警告和参数输入语义统一。验收案例为 `tests/data/test-mql-8-corrected.json`,157 个组件、178 条连接、1092 个已保存参数。测试没有改动四路/八路基准文件、C 方程、雅可比算法或求解精度。
## 实现与使用规则
- 导入时集中列出不同/缺失版本;运行或生成 XML 时再次警告,兼容的数据使用当前模型。类型、端口、未知参数、范围和非法表达式仍校验,版本警告不会掩盖实际错误。
- 导出工程 JSON 时,兼容节点写当前模型版本;无法转换的草稿保留旧格式/原版本并提示。当前编辑草稿保留来源版本,重新导入新文件后使用导出版本。
- 外部工程格式升级到 `projectSchemaVersion: 2`:数值、数字字符串、表达式均使用 `parameterUnits`,缺省为目录 SI 单位。网页、HTTP、CLI 在进入数值计算前完成求值与单位转换。XML/内部数值构造器严格接收有限 SI 数字和当前版本。
- v1 兼容保持原数值的 SI 含义;不能手工只把版本号改为 2。用网页导出完成转换。内部编辑数据、浏览器草稿及结果快照继续保留 v1/SI 数字表示。
- 单位换算表由网页与 Python 共用。表达式支持受限算术、幂、常量和白名单函数;两端共用测试数据,禁止任意代码、非有限结果和离散参数表达式。
导出时检查数值能否经显示单位精确往返。不能时,该参数改用 SI 单位导出,并在控制台说明,避免物理输入漂移。本八路工程的 30 个压力参数因此从 bar 改为 Pa 显示;数值大小与单位共同保持同一个精确 SI 输入。未额外保存一份隐藏的数值副本。
## 功能验收
真实构建后的网页由临时 `127.0.0.1:8036` 提供,Chromium 151.0.7922.34;未模拟 API 或 C 结果。顺序运行原工程、将全部组件版本改成 `0.0.1` 的工程、浏览器重新导出的 v2 工程。三次均以原始配置运行到 10 s,1002 个采样点。1785 列、共 1,788,570 个结果值逐值相同,编译缓存键也相同。
| 工程 | 导入完成 ms | 点击至 HTTP 响应收齐 ms | 点击至 IndexedDB 保存完成 ms | C 求解 s |
| --- | ---: | ---: | ---: | ---: |
| current | 364.45 | 3759.60 | 3882.24 | 2.6147 |
| old | 255.86 | 3725.61 | 3869.42 | 2.6327 |
| exported-v2 | 237.20 | 3710.86 | 3807.77 | 2.5902 |
这是本机回环、缓存命中的验收记录,各工程各一次;不用于推断远程端口转发速度或长期性能提升。导入时间包含浏览器事件和界面工作,不等于版本检查耗时。
真实网页与 HTTP 分别输入 `2.5`、`"2.5"`、`"=2.5"`、`"2+0.5"`、`"sqrt(6.25)"`,单位为 bar,均生成 250000 Pa;`"=10+10"` degC 均生成 293.15 K。浏览器导出后保留数字或表达式的正确单位语义。浏览器 `pageerror` 为 0。
| 验收层次 | 结果 |
| --- | --- |
| 后台相关回归 | 49 项通过;包含 HTTP XML/网络入口、CLI、SI 数值边界、版本/端口错误、单位、表达式、原生执行与结果传输 |
| 前端相关回归 | 分批覆盖 54 个不同用例,最终通过;包含原参数表、科学计数法、压力、存档、旧版 LMECHN1/FORC、非法参数与精确导出;失败项修正后定向 9 项复测通过 |
| TypeScript/Vite | 生产构建通过;仍有既有的大包体提示 |
| 原八路 CLI 输入 | 新旧适配代码生成 XML 逐字节一致,106719 字节 |
| 全量后台扩展检查 | 运行 362 项;3 处旧夹具缺失错误,1 项跳过,未宣称全量通过 |
全量检查的缺失项为两组测试仍指向不存在的 `AmesimModels/test_mql.ame`,以及缺少 `tests/fixtures/high_stiffness_explicit_rk45.xml`。这些测试/夹具不在本次实现改动内,没有伪造数据或改成通过。原始日志保留。没有重新执行 Amesim 对照。Linux 实测完成;Windows 使用通用 Python/浏览器实现,没有新增平台专用调用,但本次没有 Windows 实机验收。
## 版本检查的独立耗时
在 Chromium 内执行正式 `projectCompatibility.ts` 中的目录索引、版本比较及提示文字生成函数。目录仅建立一次内存 Map,通过 WeakMap 按目录数组复用;每次扫描节点,无逐组件网络请求或文件扫描。预热后每组 200 个样本、每样本 10 次调用;时间分辨率约 0.1 ms,通过批量计时减小量化误差。首次索引建立约 0.10 ms。
| 组件数 | 当前版本:中位/P95 ms | 全部旧版:中位/P95 ms | 旧版检查+提示文字:中位/P95 ms |
| ---: | ---: | ---: | ---: |
| 157 | 0.010 / 0.020 | 0.010 / 0.020 | 0.030 / 0.040 |
| 1000 | 0.030 / 0.040 | 0.060 / 0.070 | 0.160 / 0.230 |
| 10000 | 0.280 / 0.310 | 0.550 / 0.620 | 1.720 / 1.820 |
该表不包含 React 绘制控制台文字、JSON 解析或整模型合法性检查。对实际八路案例,版本扫描和生成提示的中位耗时约 0.03 ms;不构成可感知的等待。
Python 输入适配的完整成本另外测量,包含防御性复制、版本对照、1092 个已保存参数的求值/换算及仿真时间转换,并非版本扫描本身:
| 阶段 | 中位 ms | P95 ms |
| --- | ---: | ---: |
| Pydantic 结构读取 | 2.084 | 2.299 |
| 当前版本输入归一化 | 11.684 | 13.276 |
| 全部旧版输入归一化 | 11.754 | 30.189 |
| 归一化后严格构造 XML | 22.358 | 43.884 |
以上为预热后各 200 次测量,尾部受调度与垃圾回收波动影响。不能把当前/旧版的微小中位数差当成稳定性能变化。
## 文件与复现
- 现行规范:[接口规范 1.2.0](../standard/backend-interface-version-spec-v1.md#61-工程存储与执行入口)、[规范索引](../standard/README.md)。
- 回归代码:`tests/test_project_input_contract.py`、`frontend/tests/e2e/project-input-contract.spec.ts`;共享表达式用例为 `tests/fixtures/parameter-expressions.json`。
- 网页验收:`tests/manual/browser_project_contract.mjs`;后台阶段计时:`tests/manual/benchmark_project_input.py`。
- 原始记录在被 Git 忽略的 `test/project-contract-20260912/`。最终网页记录为 `browser-accepted/summary.json`、`version-check.json`、三份 XML/结果文件、`upgraded-eight.json`、五份单位用例 JSON 和 `acceptance.png`。其他 browser 子目录是诊断过程,不能当作最终验收。
- `backend-input-timing.json`、`backend-targeted.log`、`backend-all.log`、`frontend-regression.log`、`frontend-final-targets.log` 保留对应计时/测试;前端第一次 26 项参数相关通过记录见本报告与执行记录。
本次没有安装新环境或上传 Git;环境和运行产物仍保持忽略。测试结束后停止临时 8036 服务,5173/8000 始终未启动。
+59
View File
@@ -0,0 +1,59 @@
# 现行规范索引
索引版本:1.1.0;整理/复核日期:2026-09-12。
新增组件从[注册流程](component-registration-workflow-v1.md)开始,再按涉及的能力读取专项规范。[注册示例](component-registration-example-v1.md)包含实际失败阶段、修订对照和复现方法。本目录保存现行版本,修订时更新正文版本、日期及变更说明;历史实现报告仍在 `docs/other/`,不覆盖其历史结论。
| 文件 | 文档版本 / 本次修订日期 | 用途 |
| --- | --- | --- |
| [component-registration-workflow-v1.md](component-registration-workflow-v1.md) | 1.1.0 / 2026-09-12 | 总流程、条件修改范围、交付资料与完成状态 |
| [component-registration-example-v1.md](component-registration-example-v1.md) | 1.0.0 / 2026-09-12 | 斜坡信号源完整注册演练、规范差异、复现与验证记录 |
| [component-library-spec-v1.md](component-library-spec-v1.md) | 1.2.0 / 2026-09-12 | 库清单、身份版本、注册与目录/工程读取 |
| [component-model-authoring-spec-v1.md](component-model-authoring-spec-v1.md) | 1.2.0 / 2026-09-12 | 参数、端口、状态、结果、C 模块和代码生成要求 |
| [port-computation-contract.md](port-computation-contract.md) | 1.1.1 / 2026-09-12 | 端口逐变量供需、固定参考关系与连接合法性 |
| [native-evaluation-schedule.md](native-evaluation-schedule.md) | 1.1.1 / 2026-09-12 | 计算依赖、局部求解,以及新模型的依赖检查 |
| [跨平台交付约定.md](跨平台交付约定.md) | 1.1.1 / 2026-09-12 | Windows/Linux 构建、缓存、文件系统和实际验收 |
| [backend-interface-version-spec-v1.md](backend-interface-version-spec-v1.md) | 1.2.0 / 2026-09-12 | 接口、模型与工程格式各自的版本边界 |
文档文件名中的 `v1` 是文档主版本,正文另标修订版本。当前协议为目录 schema `1`、工程 JSON `2`(兼容读 `1`)、System XML `3`;本次工程格式升级没有改变各组件 `MODEL_VERSION`。
其他配套规范本轮也完成核对并补上文档版本:
| 文件 | 文档版本 / 核对日期 | 本轮调整 |
| --- | --- | --- |
| [System XML v3](system-xml-v3.md) | 1.1.0 / 2026-09-12 | 求解方法、示例可执行边界、参数输入及连接检查层次 |
| [优化基准模型](optimization-benchmark-model.md) | 1.0.0 / 2026-09-12 | 真实计时字段、预热、网页保存/CSV 与原始数据口径 |
以上版本仅表示文档,XML Schema 和用户指定的八路基准未改变。
## 文件迁移与后续删改
| 原位置 | 现位置 | 处理 |
| --- | --- | --- |
| `docs/standard/新组件注册流程与规范草案.md` | `docs/standard/component-registration-workflow-v1.md` | 校正后转为现行流程,移除草案副本 |
| `app/simulation/components/example.md` | `docs/standard/component-registration-example-v1.md` | 迁入本目录并替换为可复现的新组件示例,删除旧入口 |
| 既有组件、端口、求值与跨平台规范 | 原有 `docs/standard/` 路径 | 原位更新,保持稳定链接 |
后续调整流程先改总流程;字段或行为规则改对应专项规范;演练方法和证据改示例。移动或删除文件时同步本索引、仓库 README 与其他规范中的链接。可执行演练工具保留在 `tests/manual/`;本地生成项目、源码副本和缓存位于被 Git 忽略的 `test/component-registration-20260912*`,停掉其临时服务后可删除,并可按示例重新生成。
## 2026-09-12 全目录代码一致性复核
本轮核对本目录全部 11 份文档(含本索引)。修改现行规范以描述当前代码;Windows 实际验收、八路优先、固定精度和独立参考等用户约束继续保留,不能因代码尚未自动完成就删去要求。注册示例的历史实测记录保持原版本,没有把本轮静态核对写成重新执行整套网页演练。
| 核对项 | 确认的代码行为及本轮修订 | 主要代码依据 |
| --- | --- | --- |
| XML 积分方法 | XSD 列六种,语义层只接受 RK45/BDF;其余返回 SIMULATION_METHOD_UNSUPPORTED | [XML 校验](../../app/system_xml.py)、[XSD](../../schemas/system-simulation-v3.xsd)、[原生 runner](../../app/simulation/native_codegen/runner.py) |
| XML 示例和编译 API | 文档示例是结构有效但机械无惯性锚点的系统;compile-model 只构造网络,C 生成仍可能失败 | [HTTP/网络入口](../../app/main.py)、[扩展生成](../../app/simulation/native_codegen/extended.py) |
| 存储与执行 | 版本不同在输入层警告后使用当前模型;类型/端口/参数仍严查,XML 精确版本不变 | [请求与存储](../../app/main.py)、[前端解析](../../frontend/src/App.tsx) |
| 参数表达式与单位 | 已统一:外部 v2 数值与表达式按所选单位,v1 保持旧 SI 数字;预处理后才进入数值内核 | [网页表达式](../../frontend/src/parameterExpression.ts)、[CLI 输入](../../app/simulation/native_codegen/input.py)、[HTTP 转换](../../app/main.py) |
| 组件元数据 | DISPLAY 的介质 role 参与识别;数值基类需配套;输出过滤 visible/活动端口,editor 为受控枚举 | [注册器](../../app/simulation/registry.py)、[组件基类](../../app/simulation/core/base.py)、[元数据](../../app/simulation/core/metadata.py) |
| 连接入口 | XML/网络允许信号扇出,网页检查限制显示端口一条边;存储不做供需校验 | [网络](../../app/simulation/systems/network.py)、[XML](../../app/system_xml.py)、[前端](../../frontend/src/App.tsx) |
| 雅可比与误差尺度 | 有收益时着色差分,矩阵/LU 仍稠密;扩展路径使用统一的物性差分求值;核对选项传给 EXE | [雅可比结构](../../app/simulation/native_codegen/jacobian.py)、[CVODE](../../native/runtime/cvode_solver.c)、[容差](../../app/simulation/native_codegen/tolerances.py) |
| 缓存和平台 | 缓存命中仍预处理;按模块复用,有清理预算及超限例外;Windows 使用 GCC/MinGW 接口 | [构建](../../app/simulation/native_codegen/build.py)、[缓存](../../app/simulation/native_codegen/cache_storage.py)、[CI](../../.github/workflows/solver-regression.yml) |
| 结果与耗时 | C JSON 字节直传、IndexedDB 保存、Worker CSV 是不同阶段;任务内存保留与终态清理有明确边界 | [结果传输](../../app/simulation/native_codegen/transport.py)、[结果持久化](../../frontend/src/resultPersistence.ts)、[CSV](../../frontend/src/resultCsvExport.ts)、[C 计时](../../native/runtime/common.c) |
验证:64 项注册、目录、元数据、工程格式、XML、端口供需、雅可比结构和原生结果传输测试通过。另外直接核对六种积分方法、示例原生拒绝原因、存储/执行版本差异、数字/表达式单位和网页函数表达式行为。原始结果在被 Git 忽略的 `test/standard-code-audit-20260912/`:`regression.log`、`probe.json`、`frontend-expression.json`。该次审计未改业务实现。后续已按用户决定实现两项输入合同改动,另见下方新验收记录。
## 输入合同实现 / 2026-09-12
旧组件版本警告、工程 JSON v2 参数单位与表达式统一的现行规则见[接口规范 1.2.0](backend-interface-version-spec-v1.md#61-工程存储与执行入口)。测试与独立版本检查计时见[验收报告](../other/2026-09-12-project-contract-acceptance.md)。端口快照精简及其他求解优化未纳入此改动。
@@ -1,7 +1,9 @@
# 后端接口版本与定义规范 v1
修订日期:2026-09-12;核对代码基线:`22579e5`。本版按代码补齐存储/执行、表达式、流式结果、浏览器保存和任务生命周期边界。
本文统一说明 SystemSimulationApp 后端的接口边界、版本编号和事实来源。规范版本为
`1.0.0`。这个编号只表示本文档自身的修订版本,不等同于 HTTP API、System XML、
`1.2.0`。这个编号只表示本文档自身的修订版本,不等同于 HTTP API、System XML、
组件目录或单个模型的版本。
## 1. 当前版本基线
@@ -17,7 +19,7 @@
| System XML Schema | `3` | XML 校验、编译和仿真输入 | `schemas/system-simulation-v3.xsd` |
| 组件库版本 | 各库独立 | 一个组件库的发布边界 | 各库 `library.py` 的 `version` |
| 模型合同版本 | 各模型独立 | 单个 `MODEL_TYPE` 的物理和数据合同 | 模型类的 `MODEL_VERSION` |
| ReactFlow 工程 JSON | `1` | 编辑器存档 | `projectSchemaVersion`、`ReactFlowProjectPayload` 和前端读取代码 |
| ReactFlow 工程 JSON | `2`(兼容读 `1`) | 编辑器存档 | `projectSchemaVersion`、`ReactFlowProjectPayload` 和前端读取代码 |
| 结果文件格式 | `1` | 前端导入、导出的结果快照 | `SimulationResultsView.tsx` |
因此,`schemaVersion=3` 只能说明文件是 System XML v3,不能说明“后端 API 是
@@ -70,7 +72,7 @@ DISPLAY = ...
- `PORTS` 定义端口名称、种类、物理域、名义角色、变量和连接规则;
- `PARAMETERS` 定义 SI 单位、默认值、范围和离散选项;
- `RESULT_VARIABLES` 定义结构化结果元数据;
- `DISPLAY` 只定义前端展示,不得成为物理方程的隐式输入。
- `DISPLAY` 中图形、布局、分组不定义物理方程;但 `role="amesimGasMediumDefinition"` 已用于前端及 XML 介质引用检查。网络构造还依据 `AmesimGasMediumDefinitionComponent` 的继承关系识别介质定义,不能只加 role 就得到介质能力。
物理流变量统一以进入组件为正。物理连接的两个端点无方向;信号方向由注册端口的
`output/input` 合同决定。
@@ -122,13 +124,13 @@ FastAPI 当前直接注册未版本化的 `/api/...` 路由,没有 `/api/v1`
| --- | --- | --- |
| `GET /api/components/catalog` | 无 | 组件目录 JSON v1 |
| `GET /api/reactflow/projects` | 无 | 已保存工程摘要列表 |
| `GET /api/reactflow/projects/{id}` | 工程 ID | 严格校验后的工程 JSON v1 |
| `GET /api/reactflow/projects/{id}` | 工程 ID | 经 Pydantic 存储结构检查的原始 JSON;不保证可执行或可被浏览器导入 |
| `POST /api/reactflow/projects/{id}` | `ReactFlowProjectPayload` | 保存摘要 |
| `POST /api/reactflow/system-xml` | `ReactFlowProjectPayload` | `application/xml`,System XML v3 |
| `POST /api/reactflow/compile-model` | `ReactFlowProjectPayload` | 编译网络 JSON |
| `POST /api/reactflow/compile-model` | `ReactFlowProjectPayload` | 网络结构 JSON;不生成或编译 C |
| `POST /api/system-xml/validate` | 原始 XML v3 | 三层校验报告 |
| `POST /api/system-xml/parse` | 原始 XML v3 | 规范化执行模型 |
| `POST /api/system-xml/compile-model` | 原始 XML v3 | 校验报告、设置和网络 |
| `POST /api/system-xml/compile-model` | 原始 XML v3 | 校验报告、设置和网络;不生成或编译 C |
| `POST /api/system-xml/simulate` | 原始 XML v3 | 同步仿真结果 |
| `POST /api/system-xml/simulate-stream` | 原始 XML v3 | `application/x-ndjson` 事件流 |
| `GET /api/system-xml/simulations/{id}` | 路径 ID | 任务快照 |
@@ -143,6 +145,45 @@ OpenAPI,但当前多数 JSON 响应仍以 `dict[str, object]` 构造,XML、C
也使用原始响应类型。因此 OpenAPI 目前不是完整的响应合同;代码、Schema 和合同测试
仍是必要依据。
### 6.1 工程存储与执行入口
`ReactFlowProjectPayload` 顶层禁止未知字段,节点 data 和边 data 保留额外编辑元数据。存储不等同于执行校验:可保留缺失/不同的组件版本供查看。浏览器要求有效布局、端口对象与 `data.isContactEdge`;信号端口 `positiveFlowDirection: null` 仍应省略,这部分精简规则未改。
**工程 JSON v2**:参数数值、十进制数字字符串、数学表达式,全部按 `parameterUnits[name]` 指定的单位解释;缺省使用目录 SI 单位。缺失参数采用目录 SI 默认值,不随显示单位二次换算。例如单位选 `bar` 时,`2.5`、`"2.5"`、`"=2.5"`、`"2+0.5"` 均得到 `250000 Pa`。温度使用仿射换算,`20 degC = 293.15 K`;压力是绝压,不添加大气压偏移。
**旧工程 JSON v1**:为保持已有模型物理输入不变,普通数值/十进制数字字符串仍按 SI,表达式按显示单位解释。不可只把版本号 1 改成 2。浏览器兼容读取后提示旧规则,手工导出时将 SI 数字转换回所选单位,写出 v2。若某参数经过显示单位往返会改变浮点末位,则该参数改用 SI 单位导出并提示,保持求解输入逐值精确不变。编辑器内存、浏览器草稿/本地保存和结果快照继续使用内部 v1/SI 数字表示,不是新的外部 JSON v2。无法转换的未知组件/参数草稿保留 v1 和原版本,发出警告供修复,不丢字段。
| 入口 | 输入处理 | 数值执行边界 |
| --- | --- | --- |
| 网页参数面板与 JSON v2 | 数值与表达式均使用所选单位;导出 XML 前求值 | 完整、有限 SI 数值和当前模型版本 |
| HTTP JSON→XML / compile-model | `prepare_project()` 共享表达式解析和单位换算,返回版本警告 | 严格数值化后调用原 XML/网络构造器 |
| 原生 CLI JSON 输入 | 与 HTTP 复用 `prepare_project()`,警告写入 stderr | 同上;XML 输入仍不允许表达式 |
| XML / 内部数值构造器 / C | 不接收显示单位或表达式;数字字符串也应在输入适配层转为数字 | 仅有限 SI 数值;版本与端口/参数合同严格校验 |
语法统一为有界算术 `+ - * / ^ **`、括号、科学计数法、常量 `pi/e`(不区分大小写)及函数 `sqrt abs sin cos tan asin acos atan exp ln log log10 min max pow`。幂右结合,`-2^2=-4`;`ln/log` 均是自然对数。最多 512 字符、256 词元/运算、32 层嵌套、16 个函数参数;禁止代码执行、未知变量、除零和非有限结果。离散编辑器参数只允许合法数值选项,不接受表达式。仿真时间设置同样可输入表达式,单位固定 s。
组件版本检查使用当前目录内存索引,导入时集中提示;运行/生成 XML 时再次警告,单纯版本不同或缺失不阻止执行。组件缺失、类型冲突、端口不兼容、未知参数、非法数值仍阻止执行。浏览器导出 JSON 时,只有通过组件兼容与参数校验的节点更新为当前版本;未通过者保留原版本。该过程不是旧方程的复现,也不能证明物理语义兼容,警告须明确可能失败或结果与实际不符。
HTTP XML 导出在 `X-Component-Version-Warnings` 返回 URL 编码 JSON(代码、说明、总数、最多前 10 个组件,头部限制 3800 字节);compile-model 在响应 `warnings` 中返回完整清单。没有版本差异时无需该响应头。
参数单位换算表唯一来源为 [`schemas/parameter-units.json`](../../schemas/parameter-units.json),网页和 Python 共用;表达式语法用跨语言同一组用例验收。
### 6.2 流式结果、保存与 CSV
C 程序把结果写为 JSON。数值数组使用 Ryu 的 binary64 往返编码,结合缓冲写出;流式路径附带字节索引,Python 读取较小元数据并直接拼接 series 字节,避免将全部曲线转成 Python 浮点列表再编码。HTTP 数据仍是普通 JSON 数组,没有改成二进制数组协议。
`simulate-stream` 的心跳是带 `heartbeat: true` 的 `event="progress"`,队列无消息时约每 5 秒发送;最终事件为 `result` 或 `error`。一个 NDJSON 事件可能分成多段字节 yield,消费者须按换行组装,不能按 HTTP chunk 一段一对象处理。同步 `/simulate` 默认仍物化完整曲线;任务 GET 遇到保留的原生 series 会以分段 JSON 返回,外部对象结构相同。
正式网页在接收和解析后将结果交给界面,`resultPersistence.ts` 再异步把曲线打包为 Float64Array 写入 IndexedDB,事务完成后才发布 sessionStorage 指针。因此“结果可查看”“浏览器持久化完成”和“下载文件保存”是不同事件。`.simresult` 仍通过 JSON 编码保存;不能把数值往返精度等同于所有导出文本逐字节一致。
网页 CSV 由 `resultCsvExport.ts` 向 Web Worker 分块传输数据并生成 Blob,默认不调用仍保留的 `/api/simulation-results/csv`。列顺序来自结果元数据,数值使用原始 SI,与图表显示单位分开;后端 CSV 接口为另一个可用入口。普通网页只能知道已生成 Blob/触发下载,无法通用地确认操作系统已将文件落盘,自动化测量需要额外下载完成信号。
### 6.3 任务生命周期
流式接口接受可选 `X-Simulation-Id`,省略时生成 ID,并在响应头返回。状态、取消标志与最终结果保存在当前 Python 进程内存中;服务重启即丢失,不是持久任务队列。终态任务超过 600 秒后,在注册新任务时清理,不是精确到时删除。取消 reason 为 `user/stalled`,断开事件流会请求 `stalled` 取消。
原生执行默认超时 300 秒;C 可返回部分结果,进程超过时限再加 5 秒,或收到取消后 5 秒仍未退出,Python 才强制结束并报错。不能把所有取消都承诺为立即终止且一定有完整结果文件。
## 7. 数据命名、单位和错误
- XML 属性和目录 JSON 主要使用 `camelCase`;
@@ -205,14 +246,12 @@ System XML 校验问题统一包含:
- 不对不匹配的 `modelVersion` 做自动升级;
- 旧版本值只用于验证“不受支持输入应被拒绝”的边界测试。
ReactFlow 工程 JSON 只接受 `projectSchemaVersion: 1` 的当前结构,每个节点必须保存
目录给出的 `modelVersion`。执行、编译和 XML 导出前会再次核对节点版本;缺失或不匹配
时明确拒绝,不能先补当前默认参数再冒充当前模型。字符串端口、缺失连接 Handle 或
已经删除的兼容标记也不会被猜测、补齐或迁移;以后确有升级需求时再为新的工程版本
单独设计迁移器。
ReactFlow 工程 JSON 接受 v1/v2,导出使用 v2 的统一单位规则。输入适配层允许版本警告后选择当前模型,内部执行 XML 仍精确匹配。LMECHN1/FORC 的既有显式迁移由网页和 Python 输入层对齐;PNVO 的布局兼容留在网页。不得把一般版本提示解释为自动保留旧模型的物理语义。
工程结构解析先于目录恢复。当前信号端口的 `positiveFlowDirection` 应缺省,不能从目录复制 `null`;连线须有 `data.isContactEdge` 布尔字段。后端请求模型接受的数据不一定通过前端 `parseProjectPayload()`,应使用实际浏览器导出的结构并验证往返,见[目录与工程协议](component-library-spec-v1.md)。旧 XML v1/v2 不属于上述型号专用处理的范围。
MECMAS21 的 `useFriction`、`strib` 等 AMESim 选项统一使用目录声明的原生编码:
`1` 表示“否/禁用”,`2` 表示“是/启用”。工程 JSON v1、组件目录、System XML v3
`1` 表示“否/禁用”,`2` 表示“是/启用”。工程 JSON v1/v2、组件目录、System XML v3
和模型构造器不再接受或自动换算旧的 `0/1` 编码,也不再使用
`amesimParameterEncodingVersion` 触发猜测式转换。
@@ -226,3 +265,7 @@ MECMAS21 的 `useFriction`、`strib` 等 AMESim 选项统一使用目录声明
4. 更新当前规范和示例;
5. 增加请求、响应、拒绝边界和前后端联调测试;
6. 明确说明未实现的兼容或迁移能力。
## 2026-09-12 / 1.2.0 修订
实现工程组件版本警告、外部 JSON v2 一致单位语义、网页/HTTP/CLI 表达式归一化;XML v3、数值内核模型版本与求解精度未改变。验收记录见 `docs/other/2026-09-12-project-contract-acceptance.md`。
+70 -58
View File
@@ -1,8 +1,12 @@
# 组件库分类、发现与读取规范 v1
状态:已在 `experimental` 临时组件库实施
文档版本:1.2.0
修订日期:2026-09-12
核对代码基线:`22579e5` 加本次输入合同实现;配套工程 JSON v2,XML v3 和模型版本不变。
状态:已在 `experimental` 与 `amesim` 组件库实施;本版按注册演练校正
适用范围:`app/simulation/components`、组件注册中心、System XML 和 React Flow 组件库
当前试验库:`experimental`(仅用于注册契约验证,不在前端组件库中显示)
当前启用库:`experimental`(前端按库 ID 隐藏)、`amesim`(前端可见)。完整接入顺序见[新组件注册流程](component-registration-workflow-v1.md),实际演练见[注册示例与验证](component-registration-example-v1.md)。
## 0. 文档定位
@@ -56,8 +60,7 @@ flowchart LR
E --> H["System XML 模型实例化"]
```
新增一个符合本规范的模型后,前端不应再修改 `App.tsx` 中的组件列表、参数列表
或分类列表。只有新增一种前端尚不支持的图形渲染方式时,才需要补充前端图标组件。
普通固定端口、已有参数编辑器和单位的模型由目录生成组件列表及参数面板,无需再复制型号定义。新图形、动态端口、新编辑器、新单位或物理域仍须补齐对应前端支持,见注册流程的条件修改表。上图只表示元数据发现;模型参与仿真还需原生发现、支持版本、方程生成及 C 模块链接。
## 2. 术语和层级
@@ -129,8 +132,7 @@ app/simulation/components/
- 参数名
- 结果变量名
标识符应使用 `snake_case`,只允许小写英文字母、数字和下划线,并以字母开头。
已有工程约定中的 `T`、`U` 等热力学变量可以保留。
库 ID、分类 ID、模型类型及端口名使用小写字母开头的小写字母、数字和下划线。参数和结果变量按成员标识符规则允许大小写字母,以字母开头,例如 `T0`、`T`、`U`;具体校验以注册器的标识符规则为准。
界面中文名称单独保存在 `label` 中。修改 `label` 不影响工程兼容性;修改机器标识
会影响工程文件、System XML、结果文件和后端注册,因此发布后不得直接改名。
@@ -144,7 +146,7 @@ LIBRARY_VERSION = "0.1.0"
MODEL_VERSION = "1.0.0"
```
版本遵循 `主版本.次版本.修订版本`:
版本遵循 `主版本.次版本.修订版本`(当前校验接受三段数字,不接受预发布或构建后缀):
- 修订版本:只修复实现,不改变输入输出契约。
- 次版本:向后兼容地新增参数、结果或能力。
@@ -152,7 +154,7 @@ MODEL_VERSION = "1.0.0"
当前 System XML v3 要求每个 `Component` 显式保存 `modelVersion`,并与注册模型
版本完全一致;不一致时拒绝加载,不做静默升级。v3 不另存 `library`,而由全局唯一的
`Component/@type` 定位注册模型。旧模型的自动迁移仍未实现,需要另行提供显式规则。
`Component/@type` 定位注册模型。当前没有通用迁移框架;前端有部分型号专用迁移逻辑,不能推断新型号会自动迁移。
因此当前“修订/次版本向后兼容”只表示合同设计意图,不表示旧 XML 会被解析器自动
接受;任意模型版本变化都会使旧 XML 的精确版本检查失败。
@@ -241,17 +243,17 @@ class ExampleComponent(Component):
1. 构造函数调用 `super().__init__(name)`。
2. 使用 `set_parameter_values()` 保存所有规范化后的参数。
3. 使用 `register_declared_port()` 创建 `PORTS` 中声明的端口。
4. 组件级结果键必须与 `RESULT_VARIABLES` 完全一致。
4. 可见组件结果对应 `RESULT_VARIABLES` 中 `visible=True` 的项;端口结果对应活动端口的可见变量。
5. 所有内部计算均使用 SI 基准值。
6. 模型不能直接依赖 FastAPI、React Flow 或 XML DOM。
7. 模型的方程不能依赖图标方向、界面分类或画布位置。
完整方程示例参见
[`app/simulation/components/example.md`](../../app/simulation/components/example.md)。
[注册示例与验证](component-registration-example-v1.md)。
## 7. 界面显示声明
`DISPLAY` 只描述模型在前端的呈现,不参与物理求解:
`DISPLAY` 的图形、布局和分组描述前端呈现,不定义物理公式。例外是已实现的介质 `role`:前端及 XML 使用它做引用识别;后端网络另按介质定义基类判断,新增介质必须同步二者。普通显示声明示例:
```python
from app.simulation.core.catalog import (
@@ -319,6 +321,8 @@ PORTS = (
| `p` | effort | `equal` | `Pa` |
| `m_flow` | flow | `sumToZero` | `kg/s` |
| `h_outflow` | stream | `streamMix` | `J/kg` |
| `volume` | signal | `directed` | `m3` |
| `volume_flow` | signal | `directed` | `m3/s` |
气动端口统一约定 `m_flow > 0` 表示质量流入当前组件。`inlet`、`outlet` 是标称角色,
不应阻止反向流动;实际方向由求解结果中的流量符号决定。
@@ -362,15 +366,15 @@ PARAMETERS = (
- 用户输入可以使用其他公制单位,但提交后端前必须换算为 SI。
- 文本框编辑中的临时字符串不立即判错,失焦、回车或运行仿真时再执行数值校验。
后端不得静默忽略未知参数。缺少参数时可使用声明的默认值;出现未知参数时必须
后端不得静默忽略未知参数。Python 工厂可补齐声明默认值,XML 本身必须写全参数;出现未知参数时必须
返回包含组件 ID 和参数名的明确错误。
## 10. 结果变量规范
组件结果和端口结果分开管理:
- 组件结果来自 `RESULT_VARIABLES`。
- 端口结果根据 `PORTS` 中 `result_visible=True` 的端口变量自动生成。
- 组件结果来自 `RESULT_VARIABLES` 中 `visible=True` 的项。
- 端口结果根据活动端口中 `result_visible=True` 的变量自动生成。
- 求解器缓存、残差和调试量默认不进入用户结果。
每个结果变量必须提供:
@@ -451,6 +455,7 @@ def create(
```python
ENABLED_COMPONENT_LIBRARIES = (
"app.simulation.components.experimental.library:LIBRARY",
"app.simulation.components.amesim.library:LIBRARY",
)
```
@@ -464,6 +469,8 @@ ENABLED_COMPONENT_LIBRARIES = (
发现或校验失败时,FastAPI 应拒绝启动并给出库 ID、模型类型、字段和原因。
此启用列表仅控制注册中心。当前 `native_codegen/extended.py::catalog_contracts()` 另外显式读取两个内置库;新增第三个库必须同步该入口。`contracts.py::SUPPORTED_VERSIONS` 是独立的原生支持承诺,加入清单和支持版本后仍需实现状态、方程及输出映射。缓存不会替代这些接入步骤。
## 13. 启动校验规则
注册表完成前必须执行以下校验:
@@ -487,7 +494,7 @@ ENABLED_COMPONENT_LIBRARIES = (
### 13.3 端口
- 端口名在模型内唯一。
- 显示端口集合与物理端口集合完全一致。
- 显示端口集合与 `PORTS` 声明集合完全一致,包含物理端口与信号端口。
- 端口物理域、变量角色和连接规则有效。
- 实例实际注册的端口与静态声明一致。
@@ -503,8 +510,10 @@ ENABLED_COMPONENT_LIBRARIES = (
- 结果变量名在对应作用域内唯一。
- `quantity` 和单位有效。
- `component_result_values()` 的键与声明一致。
- 端口结果只来自声明为可见的端口变量。
- 检查组件结果声明的名称、物理量、单位和分类;启动时不执行数值求解。
- 端口结果只来自活动端口中声明为可见的变量。
Python `component_result_values()` 已移除。生成器须为 `result_variable_metadata()` 中全部可见键提供 C 输出映射;映射完整性和实际数值分别在原生生成、EXE 运行测试中验证,不应写成启动校验已覆盖。
## 14. 前端组件目录协议
@@ -578,10 +587,7 @@ GET /api/components/catalog
此类模型允许 `ports: []`,在 System XML v3 中仍按普通零端口
`Component` 保存;XML 不写任何 `Port` 快照,只保存模型版本和完整参数。
这些字段在目录对象中均为可选。宽松读取目录的消费者可以把未知编辑器参数
退化为普通数值输入;按本仓库 JSON Schema 严格校验的消费者必须与后端成套
升级,才能识别新增的 `editor` 值和 `options` 字段。正式前端必须依据目录字段
生成控件,不能硬编码具体 AMESim 模型名。
这些字段在目录对象中为可选,但 `editor` 值是受控枚举。注册器和 Schema 当前只支持上述三类;新增编辑器必须成套扩展元数据、校验和前端控件,不能将未知编辑器退化为普通输入作为正式支持。已有型号仍有专用动态端口/迁移逻辑,新增普通参数控件继续以目录为来源。
`property_model` 是每种介质组件内部的稳定选项编号,不等同于 AMESim 原始
`eosType`。例如空气组件的 `property_model=0` 表示理想气体,并映射到
@@ -620,9 +626,23 @@ cd F:\Master\SystemSimulationApp
只刷新浏览器无法让已运行的 Python 进程重新导入模型。前端源代码由 Vite 开发服务
热更新;普通目录内容变化不需要重启 Vite。
### 14.3 目录对象与工程快照不是同一协议
目录经 `normalizeComponentCatalog()` 转为前端模型定义;工程 JSON 经 `parseProjectPayload()` 校验后才与当前目录合并。后端能够从 JSON 生成合法 XML,不代表该 JSON 能被浏览器导入。 当前工程连线还必须有 `data.isContactEdge` 布尔字段,仅有两端点不足以通过浏览器解析。
例如目录中的信号端口可能含 `"positiveFlowDirection": null`,当前工程解析器仅接受该字段缺省或值为 `"intoComponent"`,因此信号端口快照应省略它:
```json
{"name":"out","kind":"signal","domain":"signal","nominalRole":"output","side":"right"}
```
人工或脚本生成工程应采用实际浏览器导出的结构,保留节点版本、按对应格式约定存储的参数、布局与真实连线端点;不要原样复制目录端口对象。物理供需规则仍来自注册表,快照不能覆盖它们。导入后检查模型、导出 XML、运行以及再次导出 JSON 都是必要验证。
当前前端要求所有显示端口恰好连接一次。后端独立信号算例可以只有未接端口警告,浏览器会将其视为运行前错误;网页验收须使用完整接线工程。
## 15. System XML 映射
System XML 中:
System XML 中的组件片段如下(仅演示字段结构;实际气瓶还须写全其余声明参数,不能直接将此片段作为有效最小算例):
```xml
<Component
@@ -642,14 +662,14 @@ System XML 中:
- XML v3 不保存 `name/componentType/Port` 或画布布局;连接中的
`Endpoint/@port` 必须存在于模型的 `PORTS`。
介质定义组件不通过物理端口连接。编译器先收集目录角色为
`amesimGasMediumDefinition` 的零端口组件,再解析带
介质定义组件不通过物理端口连接。XML 和前端通过目录角色
`amesimGasMediumDefinition` 识别,网络构造则通过 `AmesimGasMediumDefinitionComponent` 基类识别并收集介质定义,再解析带
`editor="amesimGasReference"` 参数的组件引用;介质定义组件本身不进入数值
仿真网络。System XML 语义校验会在编译前检查介质索引的整数范围、定义唯一
性、正索引引用完整性,以及同一气动连通分量的引用一致性。
XML 解析器只负责结构、引用和契约校验;模型注册中心负责选择 Python 类并创建实例;
模型自身负责方程和状态。三层职责不得混合。
Python 模型负责声明与约束;原生生成器分配状态、生成方程和输出,公共 C 模块执行数值计算。目录注册不能替代原生实现。
## 16. 测试要求
@@ -673,24 +693,18 @@ XML 解析器只负责结构、引用和契约校验;模型注册中心负责
## 17. 新增模型操作清单
开发者新增模型时只执行以下步骤:
按[注册流程](component-registration-workflow-v1.md)执行以下步骤:
1. 在目标库的正确分类目录中新建模型文件。
2. 实现 `MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、
`RESULT_VARIABLES` 和 `DISPLAY`。
3. 实现统一 `create()` 和模型方程。
4. 将模型类路径加入该库 `library.py` 的 `models`。
5. 添加模型单元测试和最小 XML/仿真测试。
6. 运行注册校验和完整测试。
7. 重启 FastAPI,刷新前端确认目录来源为“后端目录”。
1. 明确模型依据,列出参数、端口供需、状态、结果和支持边界。
2. 在目标库声明六个公共字段与类自身的 `create()`,校验默认值和参数边界。
3. 加入库清单;新库还需启用列表及原生 `catalog_contracts()` 入口。
4. 实现或复用 C 模块,新增导出函数时同步 `kernels.h`、`modules.py` 的导出及依赖。
5. 接入具体生成路径的状态、初始化、方程、输出、事件,核对求值依赖、雅可比与误差尺度;登记原生支持版本。
6. 完成独立数值对照、错误输入、最小完整网络、构建与缓存检查。
7. 重启后端,验证浏览器发现、编辑、导入导出、接线、运行、结果保存和 CSV;需要的新图形或交互能力同步实现。
8. 记录 Windows/Linux 的实际验证状态,交付接入说明与可复现实例。
正常情况下不需要修改:
- React Flow 左侧组件列表。
- 参数面板字段。
- System XML 模型类型分派代码。
- 结果变量关键词映射。
- 集中式模型工厂表。
普通型号通常不改通用 XML 分派、目录列表或参数面板字段;新协议、编辑器、单位及动态端口等按需修改。只通过目录和 XML 校验应标记“已注册”,不能标记“可求解”。
### 17.1 AI 修改约束
@@ -724,34 +738,26 @@ AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开
| 模型发现 | 已实现 | 按库清单受控发现 |
| 启动校验 | 已实现首版 | 覆盖版本、分类、端口、参数、单位和默认实例 |
| System XML 中的模型版本 | 已实现 | v3 显式保存并严格匹配 `modelVersion` |
| 工程 JSON 的整体版本 | 已实现 | 固定为 `projectSchemaVersion: 1`,节点显式锁定 `modelVersion` |
| 自动版本迁移 | 未实现 | 当前明确拒绝不匹配版本,本阶段不实现迁移 |
| 工程 JSON 的整体版本 | 已实现 | v2 统一所选单位语义,兼容读取 v1;节点保留来源版本并在兼容导出时更新 |
| 自动版本迁移 | 无通用框架,部分型号有前端专用迁移 | JSON 版本差异警告后生成当前 XML,XML 精确匹配;新型号明确语义兼容边界 |
| 目录 JSON Schema | 已实现 | `schemas/component-catalog-v1.schema.json` |
## 19. 推荐实施顺序
## 19. 维护与版本同步
1. 已完成:声明类型已放入独立的 `core/catalog.py`。
2. 已完成:`experimental/library.py` 已成为临时库唯一清单入口。
3. 已完成:五个公开模型自行声明 `DISPLAY` 和 `MODEL_VERSION`。
4. 已完成:公开模型统一实现 `create()`,集中式工厂函数已删除。
5. 已完成:注册表由库清单构建,并在导入时执行契约和默认实例校验。
6. 已完成:已增加组件目录 JSON Schema。
7. 已完成:System XML v3 保存并严格校验模型版本;工程 JSON 使用
`projectSchemaVersion: 1`,每个节点保存创建时的 `modelVersion`,当前不实现旧工程迁移。
8. 待完成:规范稳定后新建正式组件库,不再向 `experimental` 增加生产模型。
新增模型按第 17 节逐层验证,不再把“建立正式库”列为未来任务:`amesim` 已是启用的公开库。当前目录加载有缓存,修改 Python 声明、清单或原生发现代码后须重启服务。前端发布包有变更时须重新构建;仅刷新页面不会重新导入后端模块。
该顺序可以保证每一步都保持现有前端和 System XML 可用,不需要一次性重写模型、
解析器和界面。
模型版本修改时同时核对原生支持表、工程/XML 示例和兼容处理。原生内容缓存根据内容和构建环境失效,不以删除用户全部缓存作为新增模型的常规步骤。文档修订版本独立于库版本、模型版本、目录 schema 版本和工程格式版本。
## 20. 改动影响表
| 想做的改动 | 必须修改 | 通常不需要修改 |
| --- | --- | --- |
| 新增同库同分类模型 | 模型文件、`library.py/models`、测试 | 注册表、前端参数列表 |
| 新增同库同分类模型 | 模型声明、库清单、原生支持版本、代码生成及数值实现/复用、测试 | 注册中心启用表、普通前端参数列表 |
| 新增分类 | 库 `categories`、模型 `DISPLAY.category_id`、测试 | 物理端口和求解器 |
| 新增组件库 | 新库包和 `library.py`、启用列表、测试 | 已有库清单 |
| 新增组件库 | 新库包和清单、启用列表、原生 `catalog_contracts()`、逐型号数值接入和测试 | 已有库清单 |
| 修改参数默认值或范围 | 模型 `PARAMETERS`、测试、必要的版本 | 前端参数硬编码 |
| 修改端口 | 模型 `PORTS`、`DISPLAY.ports`、主版本、XML/网络拒绝边界测试 | 库分类 |
| 新增 C 导出函数/模块 | 数值模块、`kernels.h`、`modules.py` 导出/依赖、生成调用、适用的雅可比依赖与测试 | 前端分类 |
| 新增专用图标 | 模型 `DISPLAY.symbol`、前端图标渲染器 | 参数和物理方程 |
| 新增物理域 | 端口协议、网络、求解器、XML、前端兼容规则和测试 | 仅修改分类名称 |
| 修改目录响应结构 | 后端序列化、JSON Schema、前端解析、协议版本和测试 | 单个模型方程 |
@@ -762,12 +768,14 @@ AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开
| --- | --- |
| 模型完全没有出现在目录响应 | 模型类路径是否加入已启用库的 `models` |
| FastAPI 无法启动 | 启动错误中的库、模型和字段;通常是契约校验失败 |
| 接口有模型但前端没有 | `schemaVersion`、前端控制台、目录规范化错误 |
| 接口有模型但前端没有 | `schemaVersion`、目录规范化、库是否为隐藏的 `experimental` |
| 前端显示红色“加载失败” | 悬停状态查看详情,再检查 8000 端口、`/api/components/catalog`、后端是否重启 |
| 分类错误 | `DISPLAY.category_id` 与库 `categories` |
| 端口数量或位置错误 | `PORTS` 与 `DISPLAY.ports` 是否完全一致 |
| 参数面板缺字段 | 模型 `PARAMETERS` 和目录响应,不先改前端 |
| XML 报不支持类型 | XML `type` 是否精确匹配 `MODEL_TYPE` |
| 导入 JSON 失败但后端 XML 正常 | 工程解析器要求与目录对象的差异,尤其信号端口的 `null` 字段 |
| 目录可见但原生失败 | 原生发现入口、支持版本、C 输出映射和模块导出是否逐层齐全 |
| 图标是通用图形 | `symbol` 尚无专用前端渲染器,但模型仍应可用 |
最小诊断命令:
@@ -776,3 +784,7 @@ AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开
.\.venv-win\Scripts\python.exe -c "from app.simulation.registry import build_component_catalog; print(build_component_catalog())"
.\.venv-win\Scripts\python.exe -m unittest tests.test_component_registry tests.test_component_catalog
```
## 22. 本轮代码核对(2026-09-12)
1.1.1 修正了 DISPLAY 介质角色的实际用途、结果可见性和未知编辑器支持边界。工程存储/执行、HTTP/CLI 表达式和网页结果保存差异统一见[接口规范](backend-interface-version-spec-v1.md),本规范不重复声明一套实现。
@@ -1,6 +1,11 @@
# 组件模型建模规范 v1
状态:2026-09-10 更新为 Python 声明、C 数值实现
文档版本:1.2.0
修订日期:2026-09-12
核对代码基线:`22579e5` 加本次输入合同实现;配套工程 JSON v2,XML v3 和模型版本不变。
状态:Python 声明、C 数值实现;本版按实际注册与浏览器导入演练修订。
总流程:[新组件注册流程](component-registration-workflow-v1.md);示例:[注册示例与验证](component-registration-example-v1.md)。
适用对象:人工开发者、代码生成工具和 AI 编程助手
配套读取规范:[组件库分类、发现与读取规范 v1](component-library-spec-v1.md)
端口供需规范:[气动端口变量供需合同](port-computation-contract.md)
@@ -22,7 +27,7 @@
### 2.1 新增公开模型
公开模型会出现在前端组件库中,也能被 System XML 创建。必须:
公开模型加入启用库后能被目录和 System XML 识别;前端当前隐藏 `experimental` 库,其余有效库可见。参与原生仿真还需第 12 节的完整数值接入。必须:
- 放入某个组件库的分类目录。
- 实现完整模型契约。
@@ -36,15 +41,15 @@
| 改动 | 版本建议 | 兼容性要求 |
| --- | --- | --- |
| 修复数值实现但不改变契约 | 修订版本 | 若提高 `modelVersion`,既有 XML 会因精确版本不匹配而被拒绝;需明确是否真的变更合同 |
| 新增有默认值的参数或结果 | 次版本 | 新 XML 必须写全当前参数;本阶段不提供旧文件自动迁移 |
| 新增有默认值的参数或结果 | 次版本 | 新 XML 必须写全当前参数;无通用迁移,前端已有部分型号专用迁移 |
| 修改界面名称或图标 | 库修订版本 | 不修改机器标识 |
| 修改方程的物理语义 | 根据影响提高次版本或主版本 | 补充基准和变更说明 |
| 删除、改名端口或参数 | 主版本 | 当前格式直接拒绝旧端口或参数;如以后需要兼容,再单独设计迁移器 |
| 修改 `MODEL_TYPE` | 视为新模型 | 旧类型必须保留迁移映射 |
| 修改 `MODEL_TYPE` | 视为新模型 | 明确保留旧实现、显式迁移或拒绝旧工程;没有自动生成的迁移映射 |
### 2.3 新增内部模型
不进入前端目录的研究模型不加入 `library.py`。需要执行时仍应编写 C 内核并显式纳入编译器支持合同;不要恢复旧 Python 求解路径。
研究类可以不加入生产清单。但当前原生入口要求精确的类及版本合同,不能只新增 C 函数和白名单就直接执行未发现的类。需要端到端演练时,在隔离源码副本中建立明确的测试库、原生发现入口与方程适配,见注册示例;或设计并验证专门的内部适配入口。不要恢复旧 Python 求解路径,也不要把演练类留在正式目录。
### 2.4 新增物理域
@@ -94,7 +99,7 @@ app/simulation/components/experimental/junctions/tee.py
规则:
- 一个公开模型原则上对应一个文件和一个主要模型类。
- 推荐每个公开模型单独成文件;注册器不按文件名发现,现有同一文件包含多个相关型号的组织方式仍有效。
- 模块名、`MODEL_TYPE`、端口名和参数名使用稳定机器标识。
- `MODEL_TYPE` 使用小写 `snake_case`。
- 参数和结果变量允许保留已有热力学惯例,如 `T0`、`T`、`U`。
@@ -136,6 +141,8 @@ def create(
`AlgebraicComponent` 表示没有积分状态的元件;`DynamicComponent` 表示有积分状态的元件;`ThermodynamicVolumeComponent` 提供标准 `m,U,p,T,rho,u,h` 结果声明。这些基类只描述模型,不再实现数值求值方法。
`state_size` 只描述 Python 对象,不能自动生成原生状态;状态索引、初值、导数和输出须在生成路径逐项实现。
构造函数负责参数校验、几何预处理和端口注册。介质对象保存物性常量与模型选择。状态初值、流量、焓、受力和导数必须在 C 中计算。
用 `EQUATIONS` 或 `equation_definitions()` 声明结构化连接约束,返回 `EquationDefinition`,包括关系、变量和归属,不包含运行时残差值。`__MODEL__` 占位符由基类替换为实例名。该声明供网络结构展示与检查使用,不替代 C 方程或构建支持白名单。
@@ -278,8 +285,7 @@ ParameterDefinition(
| `volume_flow` | `m3/s` |
| `windage` | `N/(m/s)^2` |
新增物理量时必须先扩展后端受控单位表,再评估前端是否需要单位换算选项。禁止在
单个模型中私自拼写新的同义 `quantity`。
新增物理量时先扩展后端受控单位表,并核对共享参数单位表 `schemas/parameter-units.json`、结果页 `RESULT_UNIT_OPTIONS` 及三入口换算测试。目录的 `unit` 只给出基准单位,不会自动产生全部换算选项;例如当前 `time` 参数只显示 `s`,不能假设已支持 `ms`。新增编辑器需同时扩展元数据枚举、注册校验、目录解析和实际控件。禁止在单个模型中私自拼写新的同义 `quantity`。
构造函数必须调用:
@@ -293,7 +299,7 @@ self.set_parameter_values(
)
```
保存值、方程计算和结果输出都使用 SI。前端显示单位变化不能改变后端参数语义。
方程和结果输出使用 SI。外部工程 JSON v2 数值、数字字符串、表达式统一使用所选参数单位;网页、HTTP、CLI 在适配层归一化,内核仅接受有限 SI 数字。旧 v1 数值为 SI,导入保留含义后再转换导出 v2。单位源表为 `schemas/parameter-units.json`,详见[参数入口合同](backend-interface-version-spec-v1.md#61-工程存储与执行入口)。
## 9. 结果变量规范
@@ -312,7 +318,7 @@ ResultVariableDefinition(
)
```
声明的每个输出必须在 C 生成器的输出布局中有对应值。测试应核对实际 EXE 输出键与 `result_variable_metadata()` 一致,不再实现 Python `component_result_values()`。
声明为可见的组件输出和活动端口可见输出必须在 C 生成器的输出布局中有对应值。测试应核对实际 EXE 输出键与 `result_variable_metadata()` 一致,不再实现 Python `component_result_values()`。
### 9.2 端口结果
@@ -351,10 +357,12 @@ DISPLAY = ComponentDisplaySpec(
- `library_id` 必须等于所属库 ID。
- `category_id` 必须存在于所属库的 `categories`。
- `symbol` 是前端图形键,不是模型类型。
- 未实现专用图标时使用新的稳定键,前端会回退到通用图形。
- 可复用已存在的图形键;也可使用新的稳定键并暂时回退通用图形,不能据此声称已实现专用图标。
- 只有确实需要专用工程图标时才修改前端图标渲染器。
- `side` 只允许 `left` 或 `right`。
- 旋转和镜像不能改变端口名或物理语义。
- `role="amesimGasMediumDefinition"` 是介质识别元数据,XML/前端和网络的介质定义基类必须配套;不是只设图形角色就能实现新介质。
- 动态端口不是通用目录能力;当前 LMECHN1 存在专用逻辑。新动态型号必须同步有效端口查询、前端布局、参数变更后旧连线处理及往返测试。
## 11. 标准创建入口
@@ -389,26 +397,30 @@ def create(
3. 实际端口与 `PORTS` 完全一致。
4. 实例保存的参数与规范化参数完全一致。
`create()` 不应重复实现参数默认值和边界校验,也不能静默修改传入参数。
`create()` 不另造一套默认值或偷偷修改传入参数。构造函数仍须调用 `set_parameter_values()`,并处理必要的跨字段约束;不能以注册器已检查为由删除直接构造所需的校验。XML 要求完整参数,不能把 Python 工厂的补默认行为当成 XML 的规则。
## 12. C 方程实现要求
1. 在 `native/components/kernels.c` 及 `native/include/kernels.h` 实现物性或元件数值公式。
2. 在 `native_codegen/extended.py` 注册状态、端口、参数、初始化与输出映射;符合简单拓扑的模型还应核对 `compiler.py` 快速路径。
3. 在 `native_codegen/contracts.py` 声明支持版本,不允许仅注册 Python 模型就声称具备 C 求解能力。
4. 当前状态与试探状态分离,求值不能覆盖已接受状态。无效物性、欠定连接和不收敛必须明确失败。
5. 信号跳变和限位事件接入 C 运行库;不能改动刚度、阻尼或容差来隐藏数值错误。
6. 保持 SI 单位和端口流入为正,核对逆流、质量/能量守恒及边界状态。
1. 优先复用现有内核;新公式放入 `native/components/modules/` 合适模块,公共声明放入 `native/include/kernels.h`。增加函数或模块依赖时同步 `native_codegen/modules.py::EXPORTS/DEPENDENCIES`,防止生成代码已有调用但链接遗漏实现。
2. `native/components/kernels.c` 是模块的诊断聚合入口,生产按需构建不直接编译它。新增模块核对聚合包含清单;不要同时链接聚合文件和各模块造成重复定义。
3. 在 `native_codegen/extended.py` 接入端口、介质、状态、初始化、方程、导数、输出与事件。新型号默认走支持它的路径;只有将其纳入 `compiler.py` 紧凑路径时才同步实现该路径,不能只扩充类型集合。
4. 新库还要加入 `extended.py::catalog_contracts()`;核对精确类及 `contracts.py::SUPPORTED_VERSIONS`。版本表代表已实现的能力,不能代替方程或 C 输出映射。
5. 用 `Computation/EvaluationSchedule` 声明实际计算依赖,多输出函数区分读取参数与写出指针;别名和依赖流量的汇总拆开。新增循环需要对应的求解能力,不是图排序成功就一定能求解。
6. 核对 `jacobian.py` 的表达式和状态依赖分析。新函数经审查后才纳入识别集合;分支、投影、多输出依赖必须完整。未知依赖允许保守逐列差分,禁止当常量处理来保留着色。动态模型适用时给生成的 `model`/`model.exe` 传 `--verify-jacobian` 对照;Python 包装 CLI 当前没有该参数。
7. 核对 `tolerances.py` 的量纲尺度:当前 `m/m1/m2` 为 `1e-14`,`x/v` 为 `1e-12`,其他字段默认 `1e-8`。新状态按物理量评估,不能只依名称套用默认值。
8. 当前状态与试探状态分离,无效物性、欠定连接和不收敛明确失败。气动物性复用传递 `NativePropertyCache` 上下文,不跨试算无条件复用旧值。信号跳变和限位接入运行库,不能改物理参数或放宽容差隐藏错误。
9. 保持 SI、端口符号和适用的质量/能量守恒;按需编译及缓存由内容失效机制管理,测试完整模型命中、参数变化、模块变化及遗漏导出错误。
10. C 代码、构建依赖、进程与文件操作遵守[跨平台交付约定](跨平台交付约定.md)。分别记录 Linux 与 Windows 实际结果,不把 Linux 模拟测试写成 Windows 验收。
## 13. 模型实现示例
可参照 [气瓶声明](../../app/simulation/components/experimental/storage/cylinder.py)、[气腔声明](../../app/simulation/components/amesim/storage/chambers.py)、[C 内核](../../native/components/kernels.c) 与 [系统 C 生成器](../../app/simulation/native_codegen/extended.py)。完整开发顺序见 [组件目录说明](../../app/simulation/components/example.md)。
可参照 [气瓶声明](../../app/simulation/components/experimental/storage/cylinder.py)、[气腔声明](../../app/simulation/components/amesim/storage/chambers.py)、[C 数值模块](../../native/components/modules/) 与 [系统 C 生成器](../../app/simulation/native_codegen/extended.py)。完整开发顺序见 [注册示例与验证](component-registration-example-v1.md)。
Python `create()` 只创建经校验的描述对象;C `model_init()` 生成质量、能量及机械状态,`model_eval()` 计算导数和输出。两者通过生成的状态/参数布局关联。
## 14. 注册模型
模型文件完成后,只修改所属库的 `library.py`:
元数据发现这一步,在所属库的 `library.py` 加入类路径;它不替代第 12 节的原生接入:
```python
models=(
@@ -434,10 +446,12 @@ models=(
3. 参数边界测试。
4. 端口与显示布局一致性测试。
5. C 方程与独立解析解或冻结参考值对照。
6. 零流量或反向流动测试。
6. 按物理适用性覆盖零流量、反向流动或信号边界;无流量的信号源不套用气动守恒验收。
7. 目录输出测试。
8. 最小 XML 编译测试。
9. 使用 C RK45/BDF 的短时仿真与事件测试。
9. 使用当前支持的 C RK45/BDF 做短时仿真,事件按模型适用性验证;XSD 中出现其他方法不代表运行库已支持。
10. 浏览器完整接线工程的导入、编辑、XML、运行、结果保存、CSV 和刷新恢复。后端单元件允许的未接端口警告,在前端可能是阻止运行的错误。
11. 编译/缓存与实际 Windows/Linux 验证;依赖着色的动态模型另有雅可比对照。
推荐先运行:
@@ -448,7 +462,7 @@ models=(
tests.test_component_metadata
```
然后运行完整回归:
随后按改动范围运行型号数值、网络、原生构建与浏览器测试;涉及共享合同、内核或求解行为时运行完整回归:
```powershell
.\.venv-win\Scripts\python.exe -m unittest discover -s tests
@@ -458,7 +472,7 @@ models=(
```powershell
cd frontend
$env:Path = 'F:\Master\SystemSimulationApp\.tools\node-v24.18.0-win-x64;' + $env:Path
$env:Path = (Join-Path (Split-Path $PWD) '.tools\node-v24.18.0-win-x64') + ';' + $env:Path
npm.cmd run build
```
@@ -471,7 +485,7 @@ npm.cmd run build
5. 修改模型类,不在注册器和前端复制规则。
6. 检查默认实例和旧参数是否仍能创建。
7. 检查最小系统是否仍然闭合。
8. 运行针对性测试和完整回归。
8. 运行针对性测试,涉及共享机制时完成相应完整回归。
9. 同步本文档或模型专属说明中的物理假设。
## 17. 人工或 AI 的任务输入卡
@@ -526,7 +540,7 @@ AI 创建或修改模型时必须遵守:
1. 展示涉及的模型、清单和测试文件。
2. 报告版本变化和兼容性影响。
3. 运行针对性测试、完整后端测试和必要的前端构建。
3. 运行针对性测试和必要的前端构建;共享机制修改运行相应完整后端回归。
4. 检查 `GET /api/components/catalog` 中的模型、分类、端口和参数。
5. 告知用户需要重启 FastAPI 才能加载新的 Python 模块。
6. 未执行的校验必须明确说明原因。
@@ -551,11 +565,12 @@ AI 创建或修改模型时必须遵守:
- 模型契约完整且启动校验通过。
- 默认参数和边界有效。
- 端口、参数和结果具有稳定物理含义。
- 方程覆盖零流量、正常流动和必要的反向流动。
- 方程覆盖正常、边界与适用的反向流动/事件,并有独立参考。
- 模型已加入正确库清单。
- 目录接口能自动输出模型。
- 前端无需复制参数和端口定义即可使用。
- XML 能映射到正确模型。
- 最小系统能够编译;声称可仿真的模型必须产生有限结果。
- 针对性测试、完整回归和必要的前端构建通过。
- 相应分层测试及必要的构建/回归通过,浏览器能实际运行完整接线案例。
- Windows/Linux 的验证状态明确,尚未实测的平台不能标记为通过。
- 文档记录了模型假设、适用范围和已知限制。
@@ -0,0 +1,165 @@
# 新组件注册示例与验证记录
文档版本:1.0.0;修订日期:2026-09-12;代码基线:`22579e5`。
本文由原 `app/simulation/components/example.md` 迁入并重写,配合[注册流程](component-registration-workflow-v1.md)、[建模规范](component-model-authoring-spec-v1.md)和[库读取规范](component-library-spec-v1.md)使用。示例在隔离源码副本中实际执行,正式组件库不增加演练型号。它验证当前接入流程,不是待发布的新物理模型。
## 1. 示例合同
新建第三个库 `registration_demo`、分类 `signals`、型号 `registration_demo_ramp`,类名 `DemoRamp`,模型版本 `1.0.0`。复用图形键 `amesim_step0`,增加 C 模块 `demo_signal`;图形键不决定数值行为。
公式为:
```text
y(t) = offset + amplitude × t / duration
```
| 参数 | SI / 默认值 | 约束 |
| --- | --- | --- |
| `offset` | 无量纲 / 2 | 有限值 |
| `amplitude` | 无量纲 / 3 | 有限值 |
| `duration` | s / 1 | 有限且严格大于零 |
唯一端口 `out` 是信号输出,显示在右侧。组件输出 `y` 与端口输出 `out.signal` 相同。模型无积分状态、无事件、无气动参考口。Python 类显式声明 `MODEL_TYPE/MODEL_VERSION/PORTS/PARAMETERS/RESULT_VARIABLES/DISPLAY/create`;构造函数保存参数并注册端口。完整可执行类及各处接入代码保存在[后端演练脚本](../../tests/manual/rehearse_component_registration.py),不再维护另一份易过期的代码副本。
C 函数是:
```c
#include "kernels.h"
double native_demo_ramp(double t, double offset, double amplitude, double duration) {
return offset + amplitude * t / duration;
}
```
`duration > 0` 由组件参数合同检查。本演练用已校验的参数生成 C 常量,不能据此认为任意外部输入都已校验,或任意有限幅值不会溢出;它是受限输入的接入示例。
## 2. 分阶段注册:有意遗漏与实际失败
每阶段使用新 Python 进程,避免已导入模块掩盖发现入口变化。脚本在对应失败处断言错误原因,最终阶段要求成功。
| 阶段 | 已完成的步骤 | 实际结果 / 规范含义 |
| --- | --- | --- |
| `unlisted` | 新类和库文件存在,未启用库 | 注册表没有新型号;目录扫描不会自动注册 |
| `catalog_only` | 启用新库与清单 | 目录、参数和 XML 校验通过;原生生成报 `no native contract` |
| `native_discovery_only` | 将新库加入 `extended.catalog_contracts()` | 原生版本与模型合同不匹配;还需 `SUPPORTED_VERSIONS` |
| `contract_only` | 补支持版本 | 报 `Native output mapping incomplete`,缺少 `ramp_1.y` 和 `ramp_1.out.signal`;版本白名单不会实现方程 |
| `lowered_without_module_export` | 扩展生成器输出分支、C 函数和公共头文件 | 链接报 `undefined reference to native_demo_ramp`;模块未纳入按需导出 |
| `complete` | 补 `modules.py::EXPORTS`、诊断聚合包含清单、经核对的函数识别 | 构建与 RK45/BDF 运行通过 |
实际修改仅发生在生成的 `sandbox/`:新库的四个 Python 文件、`registry.py`、`extended.py`、`contracts.py`、`modules.py`、`jacobian.py`、`kernels.h`、新 C 模块及诊断聚合入口。生产源码没有新增该类型。
该函数只读取时间和参数,没有状态依赖。将其纳入受控函数识别集不等于验证了任意多输出函数、投影或新动态模型的雅可比结构;这些仍按专项规范审查。
## 3. 浏览器导入过程中发现的额外边界
| 初次尝试 | 实际行为 | 已采用的处理与规范修订 |
| --- | --- | --- |
| 把目录 `ports` 原样写入工程 | 信号端口带 `positiveFlowDirection: null`,浏览器拒绝;后端仍可转出有效 XML | 工程快照按前端结构规范化,信号端口省略该字段;保留错误导入检查 |
| 连线只写端点 | 缺 `data.isContactEdge`,浏览器拒绝 | 普通连线显式保存 `data: {"isContactEdge": false}` |
| 尝试把时长从 s 切到 ms | 当前只有 s 选项 | 验证已有 SI 参数编辑;不把新增单位描述成目录自动支持 |
| 使用未接线的独立信号源点击运行 | 后端是未接端口警告,前端检查为错误并阻止仿真 | 网页改用完整接线网络;区分单元件后端测试与网页系统测试 |
合法信号端口快照示例:
```json
{"name":"out","kind":"signal","domain":"signal","nominalRole":"output","side":"right"}
```
网页案例使用四个元件、三条连接:
```text
ramp_1.out -> force_1.res
force_1.port_2 -- mass_1.port_2
mass_1.port_1 -- zero_1.port_1
```
`force_1` 使用 FORC 的正向信号转力,`mass_1` 使用 MECMAS21(质量 10 kg、零初始位移/速度、禁用摩擦和限位),`zero_1` 是 F000。网页把 `duration` 从 1 s 改为 2 s,因此 `F(t)=2+1.5t` N,质量块的独立解析参考为:
```text
v(t) = 0.2t + 0.075t²
x(t) = 0.1t² + 0.025t³
```
## 4. 本版规范的逐项校正
| 旧规范不匹配之处 | 本版处理 |
| --- | --- |
| 仅描述 experimental,仍计划未来建立正式库 | 核对当前 experimental/amesim 两库;临时隐藏由库 ID 决定 |
| “加入 library 就完成注册/无需其他修改” | 明确元数据发现与原生发现、版本、方程和模块链接四个独立检查点 |
| C 公式仍要求放 `kernels.c` | 改为 `modules/*.c`、`kernels.h` 和模块导出/依赖;聚合文件仅用于诊断 |
| 启动校验要求 Python `component_result_values()` | 删除过期要求,改为元数据校验、C 输出映射及 EXE 输出的分层检查 |
| Python 状态声明容易被理解为自动得到 C 状态 | 明确索引、初值、导数和结果由具体生成路径实现 |
| 前端新增能力仅需图标 | 补齐动态端口、新编辑器、新物理量/单位、介质和连线规则的条件修改范围 |
| 目录、工程和 XML 合同被混用 | 补信号端口 null、连线 data 字段、SI 参数、完整参数、网页必接端口要求 |
| 无原生模块缓存、雅可比、新状态尺度要求 | 新增导出/依赖、缓存失效、状态依赖和容差量纲检查项 |
| “不支持迁移”的表述过于绝对 | 区分 XML 精确版本、前端型号专用处理和未实现的通用迁移框架 |
| 未区分跨平台适配与实际验收 | 分别记录 Linux/Windows 状态,新 C/构建/缓存能力同样适用 |
修改发生在现行规范正文,不只在本表列出待办。文件分工和迁移位置见[规范索引](README.md)。
## 5. 验证结果与范围
最终可复现记录放在本地 `test/component-registration-20260912-verified/`,该目录被 Git 忽略。
| 验证 | 结果与边界 |
| --- | --- |
| 六阶段发现、版本、输出、链接检查 | 全部符合阶段预期,最终构建成功 |
| 后端独立元件,RK45 与 BDF | 0~1 s,0.1 s 采样,各 11 点;`y` 解析误差为 0,端口输出与组件输出相同 |
| 重复构建 | 完整模型命中,对象编译 0 次 |
| 参数 duration 1→2 | 模型对象重编译 1 次,其余 8 个对象复用,最终输出 3.5 |
| 失败输入 | 错误 modelVersion、零 duration、XML 缺 duration 均被拒绝 |
| 浏览器 | 真实 Chromium、真实目录/API/C 程序;发现、拖入、错误 JSON 拒绝、完整工程导入、参数编辑、JSON/XML 导出、两次 BDF、曲线显示、结果文件/CSV、刷新恢复 |
| 网页解析对照 | 信号与输出力逐点误差 < 1e-12;质量块最大位移差 `1.26236e-11 m`、速度差 `2.36616e-13 m/s`(门槛分别为 1e-7) |
| 网页缓存与保存 | 第二次运行完整模型缓存命中;CSV 中新元件两列符合解析值;刷新恢复与保存结果完全一致 |
| 生产目录回归 | 注册、目录、元数据共 23 项测试通过;生产仍为 27 类,原生版本一致,演练类型未进入生产库 |
| Windows | 未实际执行;本机无 Windows 运行环境,不宣称双平台验收完成 |
第一轮缺导出的链接失败已生成部分共享对象,因此 `firstCompleteBuild` 不是全空缓存耗时;本演练不作性能优化结论。目录结构、失败阶段、函数接入在 Linux 得到实证,Windows 仍需用现有工具链执行复现与对应回归。
本案例不覆盖新物理域、动态端口、新编辑器、非平凡的雅可比着色、新非线性环或气动固定参考口,也不代表完成了 Amesim 等价性验证。此次修改仅为规范和演练工具,未修改共享求解行为,因此没有再次运行八路性能基准。
主要证据文件:`summary.json`、各阶段 `*.json/*.log`、`input.xml`、`project.json`(后端独立案例)、`browser-project.json`(网页完整网络)、`browser/summary.json`、网页导出与截图。探索中的失败记录在旁边的 `test/component-registration-20260912/` 和 `test/component-registration-20260912-final/`;最终结论以 `-verified` 目录为准。
## 6. 复现方法
前提:仓库 Python 依赖、现有 C/SUNDIALS 工具链和前端依赖可用。前端源代码变化后先在 `frontend/` 执行 `npm run build`,演练会复制现有 `frontend/dist/`。环境、工具链和浏览器不随 Git 提交。
Linux,从仓库根目录生成全新目录(已存在会拒绝覆盖):
```bash
.venv/bin/python tests/manual/rehearse_component_registration.py --output-dir test/registration-replay
```
另开终端启动隔离后端,使用同一 Python 环境:
```bash
registration_repo="$PWD"
cd test/registration-replay/sandbox
"$registration_repo/.venv/bin/python" -m uvicorn app.main:app --host 127.0.0.1 --port 8036
```
回到仓库根目录运行浏览器脚本:
```bash
node tests/manual/browser_component_registration.mjs test/registration-replay/browser-project.json test/registration-replay/browser http://127.0.0.1:8036
```
本机采用 `.tools/node-v24.18.0-linux-x64/bin/node`。若使用此前补充的浏览器动态库,在命令前设置:
```bash
export LD_LIBRARY_PATH="$PWD/.venv/native/browser-libs/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
```
Windows PowerShell 对应入口(须先配置现有 Windows C/SUNDIALS 工具链及 Playwright 浏览器,以下不是已通过记录):
```powershell
$registrationRepo = (Get-Location).Path
$registrationPython = Join-Path $registrationRepo '.venv-win\Scripts\python.exe'
& $registrationPython tests/manual/rehearse_component_registration.py --output-dir test/registration-replay-win
Set-Location test/registration-replay-win/sandbox
& $registrationPython -m uvicorn app.main:app --host 127.0.0.1 --port 8036
```
在另一个 PowerShell 终端回到仓库,运行同一 `.mjs` 脚本,输入换成 `test/registration-replay-win/browser-project.json`,Node 使用本机 Windows 路径。浏览器与 Python 均使用原平台环境,不复制 Linux 动态库。
完成后在启动 uvicorn 的终端按 `Ctrl+C`。演练使用临时端口 8036;本次未启动用户已停止的 5173/8000。删除生成目录即可清理演练源码、结果与缓存;生产组件及现行规范不依赖这些生成物。
@@ -0,0 +1,237 @@
# 新组件注册流程与交付规范
文档版本:1.1.0
修订日期:2026-09-12
核对代码基线:`22579e5`(缓存功能 Windows 平台适配)。本文是新增网页建模与原生仿真组件的总流程;专项字段规则见[规范索引](README.md),执行证据见[注册示例与验证](component-registration-example-v1.md)。文档版本独立于模型和协议版本。
本版通过隔离源码中的斜坡信号源注册演练校正现有规范。演练组件和编译缓存不加入正式组件库;可复现脚本保留在 `tests/manual/`。
## 1. 当前架构与完成边界
当前有两条接入链路。元数据注册控制组件发现和编辑;原生支持控制能否生成并执行数值模型。注册类不会自动获得 C 求解能力。
```mermaid
flowchart TD
A[组件物理定义与 Python 声明] --> B[library.py 与注册校验]
B --> C[组件目录 API]
C --> D[前端组件库、参数面板和端口]
D --> E[工程 JSON 与执行 XML]
E --> F[校验并构造网络]
B --> F
F --> G[C 支持合同与系统代码生成]
H[公共 C 数值模块] --> I[按需编译、缓存和链接]
G --> I
I --> J[独立 C 程序积分与输出]
J --> K[网页结果、保存和 CSV]
```
源码依据:
- [注册中心](../../app/simulation/registry.py):`ENABLED_COMPONENT_LIBRARIES` → `library.py` → `validate_component_model_class()` / `create()` → 注册表。
- [目录 API 与网络构造](../../app/main.py):`/api/components/catalog`、`compile_system_xml_network()`、`_compile_solver_network()`。
- [原生入口](../../app/simulation/native_codegen/compiler.py):`compile_native_program()` 检查类型和版本,再选择紧凑路径或扩展路径。
- [扩展生成器](../../app/simulation/native_codegen/extended.py):按具体模型实现状态、方程与输出映射。
- [前端工作台](../../frontend/src/App.tsx):`normalizeComponentCatalog()`、工程读写及通用参数处理。
本次直接加载注册目录核对:`experimental` 5 类、`amesim` 22 类,共 27 类;原生版本表与注册版本一致。这是当前快照,不应成为未来新增组件后的固定数量要求。
以下标识不可混为一谈:
| 标识 | 作用 | 示例 |
| --- | --- | --- |
| `library.id` | 发布和发现边界 | `amesim` |
| `category.id` | 前端组件面板分组 | `flow` |
| `MODEL_TYPE` | 工程/XML/原生支持的模型类型 | `amesim_pnor001` |
| `DISPLAY.symbol` | 前端图形渲染键 | 可以与 MODEL_TYPE 相同,也可以复用已有图形 |
| C 功能模块 | 数值代码组织及对象缓存粒度 | `orifice`、`pipe` |
界面分类与 C 功能模块没有自动的一一映射。不同型号可以调用同一个公共 C 函数;同一型号也可以依赖多个模块。
## 2. 新增组件的实际步骤
### 第一步:确定物理合同和接入范围(必做)
先整理模型依据、适用介质/物理域、计算公式、符号约定、有效范围和未支持能力。明确它是无状态代数组件、动态组件,还是编译期介质定义。若是新增物理域或新种类的介质,须单独评估网络、XML、前端连线、物性和原生生成支持,不能只新增一个 `domain` 或类型字符串。
建议首先形成四张表:
- 参数:机器名、物理意义、SI 单位、默认值、范围、枚举、显示条件和是否影响端口/状态数量。
- 端口:稳定名称、物理域、连接类型、各变量含义、正方向、供需关系、参考来源及是否必须连接。
- 状态:名称、含义、单位、初值公式、导数公式、允许范围、绝对误差尺度及相关事件。
- 输出:稳定名称、含义、单位、显示名称、计算公式和所属组件/端口。
### 第二步:编写 Python 组件声明(必做)
文件放在 `app/simulation/components/<library>/<category>/`,参照同类模型。现有代码存在一个文件容纳多个相关型号的情况;“每模型单文件”是整理建议,不是注册器的硬性要求。
公开模型类必须在自己的类体显式声明:
```python
MODEL_TYPE
MODEL_VERSION
PORTS
PARAMETERS
RESULT_VARIABLES
DISPLAY
@classmethod
def create(cls, *, name, medium, parameters):
...
```
这七项由 [registry.py](../../app/simulation/registry.py) 检查,不能只从父类隐式继承。可以显式引用已有声明,例如 `RESULT_VARIABLES = Parent.RESULT_VARIABLES`。
构造过程调用 `set_parameter_values()` 保存完整规范化参数,使用 `register_declared_port()` 注册声明的端口,并保存介质和常量。参数合法性包括单字段范围和跨参数约束;默认参数必须能够创建描述对象。公共创建入口填充缺省值后还会核对实例类型、端口和参数是否与声明一致。
Python 可以做参数验证和常量几何换算;运行时物性、流量、力和状态导数由 C 计算。`EQUATIONS` / `equation_definitions()` 是结构化约束声明,不会被自动翻译成完整 C 数值方程。仅设置 `DynamicComponent.state_size` 也不会自动分配原生积分状态。
### 第三步:落实端口语义(必做,静态与数值两侧一致)
`PORTS` 是物理接口来源,`DISPLAY.ports` 是显示布局,两者的名称集合必须一致。当前显示端口基础声明支持 `left/right`,旋转和镜像由前端处理,不能直接写未经实现的显示边。
气动端口需要区分:
- 名义入口/出口及实际正反流。
- 哪些量由本端提供,哪些量由对端提供。
- 是否是固定供需的参考口或支路口;`reference_port` 指向哪个真实参考来源。
使用 [port_computation.py](../../app/simulation/core/port_computation.py) 的既有合同或声明准确的新合同。参考关系不会随流向反转而自动改变;不能从图标左右位置推断物理参考口。端口流量遵守现有“流入组件为正”的合同。
参数决定端口启停时,同时实现类级和实例级有效端口查询,并核对必须连接的端口。前端目前对 LMECHN1 的动态端口还有专门逻辑,尚无统一的参数化端口目录协议;新动态端口模型要明确补齐前端规则及变更参数后旧连线的处理。
### 第四步:加入受控组件清单(必做)
在所属库 `library.py` 的 `models` 中加入 `完整模块路径:类名`。如新增界面分类,同步该库 `categories` 和 `DISPLAY.category_id`。不要直接修改运行时 `COMPONENT_MODEL_REGISTRY`,也不要扫描目录执行任意 Python 文件。
新库需增加 `ComponentLibrarySpec`,并加入 `registry.py` 的 `ENABLED_COMPONENT_LIBRARIES`。另外,当前 `extended.py::catalog_contracts()` 仍显式读取 `amesim` 和 `experimental` 两个库,**新增第三个库还必须扩展这一原生发现入口**。
前端当前按库 ID 隐藏 `experimental`。加入实验库的模型可以被后端发现,但不会出现在左侧公开组件列表;`temporary` 标记不是现有隐藏规则。
### 第五步:实现或复用 C 数值内核(数值接入必做,新增 C 文件按需)
先判断现有公共函数是否已经满足方程。如果只需已有内核加不同参数或组合,可以复用,避免按元件实例复制内核。
新增函数应放在 `native/components/modules/` 的合适模块,公共接口在 [kernels.h](../../native/include/kernels.h) 声明;新增独立模块时再建立新的 `.c`。当前五模块为物性、孔口、管路、机械和信号。
按需构建由 [modules.py](../../app/simulation/native_codegen/modules.py) 的 `EXPORTS` 和 `DEPENDENCIES` 决定。增加新导出函数或模块依赖时应同步这里;只在头文件写声明不会保证模块进入链接。新模块如需参与聚合诊断,还要加入 `native/components/kernels.c` 的包含清单。
`kernels.c` 现在是诊断聚合入口,生产构建不直接编译它;不能同时链接聚合入口与各模块,否则产生重复定义。
内核应说明非法状态、非有限值及迭代失败的处理。新气动计算优先传递现有 `NativePropertyCache` 上下文;试算不能污染已接受状态,也不能把跨状态的旧物性无条件复用。沿用 C11、严格浮点和已有 Windows/Linux 构建约定。
### 第六步:接入系统代码生成、依赖和数值精度(必做)
在 `native_codegen/extended.py` 接入该具体型号的:
1. 有效端口、连接组及介质选择。
2. 状态编号、初值、必要的状态约束/投影。
3. 代数关系、流量、焓和力计算。
4. 状态导数及全部可见输出赋值。
5. 分段信号、限位或其他事件(适用时)。
目前这些逻辑包含具体类型集合与分支,例如 `GAS_TYPES`、`NODES`、`RESISTORS`;简单地把新型号塞进某个集合,不能保证后续分支正确处理它。
计算输入/输出通过 `Computation` / `EvaluationSchedule` 接入 [求值排序](../../app/simulation/native_codegen/schedule.py)。多输出函数要准确声明读取量和写出量;参考值复制与依赖流量的混合计算应分开;正流、逆流、零流量和各离散模式的依赖均需覆盖。新增不被当前局部求解器支持的非线性环,要实现对应求解或明确拒绝。
还需核对 [雅可比结构](../../app/simulation/native_codegen/jacobian.py) 的状态依赖。新的多输出调用、投影和分支不能遗漏依赖;无法证明时允许使用保守逐列差分,不能为保持着色而把未知依赖当作常量。适用时给生成的 `model`/`model.exe` 传入 `--verify-jacobian` 核对;Python 包装 CLI 没有同名选项。
新增状态量需要检查 [绝对误差尺度](../../app/simulation/native_codegen/tolerances.py)。当前按状态字段名区分质量、位移/速度,其余默认 `1e-8`;新物理量不能未经量纲评估直接套默认值。
`compiler.py` 保留紧凑生成路径,但新型号不一定需要同步实现第二套路径。正确做法是确认它被路由到已支持的生成路径;只有纳入紧凑路径或改变两路径共享行为时,才同步实现并做路径对照。不要只扩充 `_STORAGE_ANCHORED_TYPES` 却漏掉对应计算。
实现并验证后,将 `MODEL_TYPE: MODEL_VERSION` 加入 [contracts.py](../../app/simulation/native_codegen/contracts.py)。这是支持承诺,不能用加入白名单代替真正的数值实现。
### 第七步:核对前端图形与交互(必查,代码修改按需)
对普通固定端口、现有编辑器和现有单位的模型,组件库列表、参数名称/默认值/边界/枚举/显隐条件通过目录自动生成。通常不用在 `App.tsx` 再添加一份型号参数表或新建仿真 API。
需要新图形时,在 `frontend/src/componentSymbols/` 实现渲染,并加入 [ComponentSymbol.tsx](../../frontend/src/ComponentSymbol.tsx) 的 `symbolRegistry`。完整图形定义包含 `viewBox`、图标尺寸、节点占地、端口锚点;按参数变化的图形还需相应布局函数。没有专用图形时当前有通用边框回退,但它不代表专用图形已经验收。
以下情况需额外前端工作:
| 新能力 | 需要核对的入口 |
| --- | --- |
| 新图形/锚点 | `componentSymbols/*`、`ComponentSymbol.tsx` |
| 新参数编辑器 | `core/metadata.py` / 注册校验、目录解析、`ParameterTable.tsx` / `App.tsx` |
| 新单位或物理量的单位切换 | 后端 `SI_UNIT_BY_QUANTITY`、共享参数 `schemas/parameter-units.json`、结果 `RESULT_UNIT_OPTIONS`;三入口回归 |
| 动态端口 | 后端有效端口接口、前端端口显示/连线处理和参数变更逻辑 |
| 新物理域/连接规则 | `core/ports.py`、网络/XML 校验、前端连线检查及 C 生成 |
| 特殊介质定义/引用 | XML/前端的目录角色、网络识别的介质定义基类、介质注册与引用选择逻辑 |
浏览器检查应覆盖端口号、实际连线端点、旋转/镜像、参数改变后的布局及导入导出。图形变了不能偷偷改变物理端口名称或参考关系。
### 第八步:核对工程文件、版本和输出(必做)
普通已有协议下的模型通过注册表即可被通用 XML 流程识别,通常不需要修改 XSD 或添加型号专用 API。
- XML 要求精确匹配 `modelVersion`,参数集合完整且没有未知字段;Python 工厂可填默认值不等于 XML 可以任意缺参数。
- 外部工程 JSON v2 的数值和表达式统一按所选单位解释,网页/HTTP/CLI 预处理后才进入 SI 数值边界。旧 v1 数值不能重复换算。组件版本差异提示后允许执行;型号、端口、参数错误继续拒绝。导出时只对通过校验的节点升级版本,按[输入合同](backend-interface-version-spec-v1.md#61-工程存储与执行入口)验收旧文件和重新导出的文件。
- 采用实际浏览器导出的工程结构,不直接复制目录对象。例如信号端口快照须省略 `positiveFlowDirection: null`;连线须保存 `data.isContactEdge` 布尔字段;后端转 XML 成功不能代替浏览器导入检查,见[目录与快照差异](component-library-spec-v1.md#143-目录对象与工程快照不是同一协议)。
- 网页当前要求所有显示端口恰好连接一次。后端单元件算例的未接端口警告不能替代网页完整网络验收。
- 发布后保持模型/端口/参数/结果机器名稳定。提高版本可能使旧 XML 被拒绝;当前没有通用自动迁移框架,前端仅存在部分特定型号迁移逻辑。
- `RESULT_VARIABLES` 中可见项与活动端口的可见变量必须在实际 C 输出中全部赋值,结果键/单位应与网页、缓存恢复、CSV 一致。
### 第九步:分层测试和双平台验收(必做,按物理适用性选择案例)
| 层次 | 最少核验内容 | 现有入口示例 |
| --- | --- | --- |
| 声明与注册 | 全局类型唯一、版本、默认可创建、非法/边界参数、端口/显示/结果一致 | `test_component_registry`、`test_component_catalog`、`test_component_metadata` |
| 接口与文件 | 正确连接、缺口/错误参考被拒绝、JSON/XML 往返、版本不兼容 | `test_port_computation`、`test_system_xml_v3`、型号专项 |
| 元件数值 | 独立公式或可信参考、正常/零值/边界/逆向、守恒和明确失败 | 型号 C 专项、`--init` / `--probe` |
| 系统求解 | 包含新元件的最小闭合网络,RK45/BDF(适用时),事件、顺序打乱与输出完整 | `test_native_codegen`、`test_native_schedule`、`test_native_catalog` |
| 雅可比 | 状态依赖覆盖、模式切换、必要的完整矩阵核对 | `test_native_jacobian_structure`、`test_native_jacobian_runtime` |
| 编译与缓存 | 空缓存构建、完整命中、参数/模块变化后正确失效、缺失模块链接失败能被发现 | `test_native_build_cache`、`test_native_cache_platform` |
| 浏览器 | 发现/拖入/编辑/连线/旋转镜像、运行、结果可查看、IndexedDB 保存、下载/CSV/刷新恢复分别核查 | `frontend/tests/e2e/` 的相关测试 |
| 双平台 | Windows 与 Linux 实际构建和运行;Windows `.exe` / DLL / 路径 / 文件系统能力 | `native-windows` CI、跨平台集成测试 |
新增模型要有自己的可执行案例,不能只改变“现有 27 类”的数量断言。已有测试包含固定类型清单/数量,新增后需合理更新覆盖期望,并证明新型号确实执行。
数值参考来自独立解析结果、可信外部结果或经过审查的冻结基准;不能从待验实现自动生成“预期值”来证明自身正确。保留原有参考,不直接覆盖历史基线掩盖差异。
对共享内核/求解器的修改,沿用修正八路作为系统回归与性能案例;新元件自身仍需最小闭合案例。只有八路无法运行且短期无解,才退回四路。性能对照分别记录构建、求解、结果处理与网页可查看/保存;无可信 Amesim 曲线或耗时则明确跳过相应对比。
Windows 验收遵循 [跨平台交付约定](跨平台交付约定.md)。模拟测试与代码适配不能代称 Windows 实机运行通过。
## 3. 常见修改范围
| 文件/区域 | 新增普通型号时是否必改 |
| --- | --- |
| 所属组件库中的 Python 声明 | 必改 |
| 所属库 `library.py` | 必改 |
| `native_codegen/extended.py` 或已确定的生成适配入口 | 必须接入;当前通常需修改 |
| `native_codegen/contracts.py` | 必改 |
| `native/components/modules/*.c` / `kernels.h` | 新数值函数才改;已有内核可复用 |
| `native_codegen/modules.py` | 新导出函数或新模块依赖时改 |
| `native_codegen/compiler.py` 紧凑路径 | 新型号纳入该路径或共享行为受影响时改 |
| `schedule.py` / `jacobian.py` / `tolerances.py` | 核对生成器接入;现有机制不足、新函数依赖或新状态尺度需要时改 |
| 前端图形注册与渲染 | 需要专用图形或布局时改 |
| `App.tsx` / `ParameterTable.tsx` | 动态端口、新编辑器、新单位等现有元数据能力不足时改 |
| `app/main.py` / `app/system_xml.py` / XSD | 普通型号通常不改;新协议/物理域/介质机制时改 |
| 注册、型号数值、系统和浏览器测试 | 增加相应案例并更新受影响的覆盖期望 |
## 4. 组件接入交付资料
每个拟交付的新型号提供“组件接入说明”,按适用性填写以下栏目;未涉及的能力写明不适用:
1. **身份与版本**:类型、类路径、所属库/分类、模型版本、图形键、关联已有型号及兼容性处理。
2. **物理依据**:参考来源、完整方程、假设、适用范围、未支持功能。
3. **参数表**:SI 值、默认值、边界、枚举、条件显示、几何常量预处理。
4. **端口表与标号图**:变量、正方向、供需、参考口、动态启停和必接要求。
5. **状态/事件/结果表**:初值、导数、误差尺度、事件行为、输出键和单位。
6. **原生接入表**:C 函数、所属模块、依赖、生成路径、计算顺序、雅可比依赖与缓存失效范围。
7. **前端/文件合同**:图形及锚点、单位和编辑器、JSON/XML 示例、旧工程处理。
8. **验证记录**:最小算例、独立参考来源、误差门槛、失败路径、Windows/Linux 实际结果和网页验证;性能数据按阶段区分。
验证记录区分三个完成状态:
- **已注册**:目录/工厂/XML 类型与参数校验通过。
- **可求解**:原生构建、独立数值对照、闭合网络运行和输出检查通过。
- **可交付**:浏览器交互/文件往返/保存及 Windows/Linux 验证通过,兼容性和资料齐全。
这些是文档中的验收状态,当前目录 API 尚未提供对应的统一字段;不要向工程 JSON 添加未实现的状态协议。
## 5. 本版修订与维护
本版已在专项规范中修正:旧 C 文件入口、移除的 Python 结果接口、新库的两条发现入口、原生版本与方程的独立接入、前端条件能力、目录与工程快照差异、网页完整接线要求、精确版本和专用迁移的边界,以及模块缓存、雅可比依赖和 Windows 验证要求。
具体逐项对照、失败阶段和最终测试结果见[注册示例与验证](component-registration-example-v1.md)。后续代码架构变更时更新对应专项规范及本流程;不在文档中维护第二份可执行型号参数表。
+29 -1
View File
@@ -1,5 +1,9 @@
# C 求值的依赖排序与局部求解
文档版本:1.1.1
修订日期:2026-09-12
核对代码基线:`22579e5`;本次版本号仅标注文档,不改变模型版本或协议版本。
本文说明当前内置模型的扩展 C 生成路径。Python 仅在编译时整理计算关系,运行时仍由独立 C 程序完成物性、连接量、局部迭代和积分。没有恢复旧 Python 数值内核或旧 IR 包。
## 执行过程
@@ -41,7 +45,7 @@
- 在实际返回候选点检查方程相对残差不超过 `1e-9`,同时保留流量变化的绝对 `1e-13 kg/s` 或相对 `1e-10` 判据;比较过程避免流量尺度溢出造成误判。解析低雷诺数分支也核对残差及返回流量的有限性。
- 求根最多128轮。区间已缩至相邻浮点值时核验两端候选,仍不满足上述条件则失败;非有限方程、无法夹根、迭代耗尽均返回失败值,不将最后一次试算当成解。
PNL00R原有的直接层流分支(对应 `Re ≤ 1000`)继续按其解析式计算;该保留分支不经过上述混合摩擦关系的求根检查。区间法的收敛仍以区间内方程连续等条件为前提,不代表任意经验公式、任意有限输入或整套耦合系统都能成功求解。上面的系统压力、焓闭合策略,以及积分器和系统雅可比策略均保留。系统循环的来源检查和局部划分不代表任意新非线性方程都已得到数值求解支持。
PNL00R原有的直接层流分支(对应 `Re ≤ 1000`)继续按其解析式计算;该保留分支不经过上述混合摩擦关系的求根检查。区间法的收敛仍以区间内方程连续等条件为前提,不代表任意经验公式、任意有限输入或整套耦合系统都能成功求解。上述压力/焓闭合与管路求根是不同层次;当前系统 BDF 的雅可比已采用下节说明的有条件着色差分。系统循环的来源检查和局部划分不代表任意新非线性方程都已得到数值求解支持。
每次 `model_eval` 还建立调用者持有的 `NativePropertyCache`,并显式传给局部辅助函数。气体状态准备后登记已有温度、焓和密度;阀口、管路及诊断计算共享相同状态中的有效物性。压力、焓或介质参数改变时按完整输入重新查询,缓存不跨系统试算复用。记录容量满时使用正常计算回退。
@@ -65,3 +69,27 @@ operations.append(Computation.assignment(
对应测试:`tests/test_native_schedule.py`、`tests/test_native_catalog.py`。本轮模型对照记录见 [计算排序验证记录](../other/C计算依赖排序验证-2026-09-10.md)。
物性复用和管流求根还由 `tests/test_native_properties.py`、`tests/test_native_pipe_physics.py` 覆盖。`tests/test_native_pipe_solver.py` 仅依赖Python标准库和C编译器,通过独立二分、切换邻点及临时测试副本中的斜率/方程故障注入,验证区间保护、残差、回退与明确失败。实现边界与实测结果见 [物性复用与管流求根实现及验证](../other/物性复用与管流求根实现及验证-2026-09-11.md)。
## 新注册模型的构建与数值检查(2026-09-12 修订)
公共数值函数位于 `native/components/modules/`,导出与依赖由 `native_codegen/modules.py` 管理;`kernels.c` 仅为诊断聚合入口。增加 C 调用后检查函数所属模块实际进入链接。
求值排序的输入输出声明与 BDF 雅可比的状态依赖分析需要分别核对:`jacobian.py` 识别新的受控函数时必须知道全部状态输入,分支及投影取保守依赖;不能因调度无环就断言着色正确。无法证明结构时保守回退,动态模型适用时对生成的 `model`/`model.exe` 执行 `--verify-jacobian`;它不是 Python 包装 CLI 的参数。新增状态还须审查 `tolerances.py` 的量纲尺度。完整步骤见[注册流程](component-registration-workflow-v1.md)。本次无状态斜坡源演练未覆盖新的非线性环、动态端口或非平凡的雅可比结构。
## 当前 BDF 雅可比与误差尺度
`jacobian.py` 在编译期追踪状态依赖。仅当依赖可证明且 `0 < colorCount < stateCount` 时,CVODE 安装着色前向差分;未知依赖或无分组收益时保留默认稠密差分。紧凑路径当前明确使用稠密回退。着色减少 RHS 评估次数,底层仍是 `SUNMatrix_Dense` 与 `SUNLinSol_Dense`,不是稀疏矩阵分解器。
扩展路径为雅可比生成 `model_eval_jacobian()`:差分基点和扰动点统一采用 canonical 物性求值方式,避免初始缓存填充与后续查询的细小舍入差影响导数;普通 `model_eval()` 的物性复用行为不因此更改。运行策略以构建清单 `jacobianStructure.defaultRuntimePolicy/runtimeEligible/runtimeFallbackReason` 为准。
网页、XML API 和原生 Python CLI 默认经 `backends.simulation_config()` 取得 `rtol=1e-8`;CLI 可显式覆盖。单独构造 `SolveIVPConfig()` 或直接运行未指定容差的生成 EXE,其默认 `rtol` 仍是 `1e-6`;直接 EXE 默认方法还是 RK45,不能混作网页的 BDF 默认。原生 runner 不支持自定义 `atol/first_step`,保留的配置 `atol=1e-8` 只是入口约定;实际 `model_atol` 为质量字段 `m/m1/m2:1e-14`、位移/速度 `x/v:1e-12`,其余 `1e-8`。
## 构建缓存的实际边界
默认根目录为 `app/data/native-builds/`。`objects/<hash>` 保存可复用编译单元(cacheVersion 1),`models/<hash>` 保存完整模型程序及合同(cacheVersion 2);它们与工程/结果存储分开,当前不会随 `SIMULATIONAPP_DATA_DIR` 自动迁移。
按需选择只作用于 `modules.py` 列出的组件功能模块,粒度是 C 模块文件,不是某实例或单个函数;运行库的六个 C 单元总会参与构建身份计算。即使完整模型命中,每次仍检查工具链、预处理所选源码、计算内容/依赖哈希并核验产物,再跳过编译和链接。因此缓存命中不是零构建成本。参数或元件组合改变通常改变生成的 `model.c`,公共单元仍可复用。
默认模型预算 256 MiB、对象预算 128 MiB,分别由 `SIMULATION_NATIVE_MODEL_CACHE_MB`、`SIMULATION_NATIVE_OBJECT_CACHE_MB` 设置非负整数。清理按目录最近使用时间进行,并保护正在使用、并发变化或非受管条目;单个剩余超大条目会保留,因此是可报告超限的预算,不是硬磁盘配额。设 0 也不是关闭缓存开关。启动预热(默认启用,`SIMULATIONAPP_WARMUP=off` 可禁用)只检查工具链和 XML Schema,不预先编译全部组件;`build.py` 仍会验证命中缓存的工具链条件。
Windows/Linux 编译、依赖布局和实际验证范围见[跨平台约定](跨平台交付约定.md)。
+24 -2
View File
@@ -1,5 +1,7 @@
# 优化验证的模型、数据和计时口径
文档版本:1.0.0;核对日期:2026-09-12;代码基线:`22579e5`。本轮补充实际计时字段和运行入口,保留用户指定的八路优先及精度要求。
本约定记录用户于 2026-09-11 明确的长期偏好,适用于后续仿真正确性检查与性能优化。
## 默认模型与退路
@@ -20,12 +22,32 @@ Amesim 速度比较需要同模型、同设置、完整区间的真实 CPU/墙
## 固定精度与运行条件
当前用户已批准网页/API 默认 `rtol = 1e-8`。同一性能比较中的精度必须固定,记录实际生效的 `rtol`、`atol`/状态误差下限、求解器、最大步长、输出间隔、起止时间、事件策略及雅可比策略。不能通过放宽精度、缩短区间、减少输出或改变物理参数制造加速结论。精度或模型变化后另建比较组,不能直接用旧组耗时计算优化比例。
当前用户已批准网页/API 默认 `rtol = 1e-8`,原生 Python CLI 也经同一设置入口;单独构造 `SolveIVPConfig()` 默认仍为 `1e-6`。同一性能比较中的精度必须固定,记录实际生效的 `rtol`、`atol`/状态误差下限、求解器、最大步长、输出间隔、起止时间、事件策略及雅可比策略。不能通过放宽精度、缩短区间、减少输出或改变物理参数制造加速结论。精度或模型变化后另建比较组,不能直接用旧组耗时计算优化比例。
正式计时记录源码版本、输入和原始结果 SHA、构建标识、编译器及积分库版本、硬件与运行环境。固定预热规则、缓存状态和重复次数,同机比较时避免并行求解、大编译等 CPU 干扰;报告逐次耗时及汇总口径。单次完整运行用于功能验证,不据此认定性能提升。
## 分阶段记录
分别记录输入加载/校验、代码生成、构建和缓存检查、进程启动、C 纯求解 CPU/墙钟、结果整理/序列化/传输,以及网页接收、持久化、曲线展示和导出。只测到总耗时就称为总耗时,不能把差值未经测量地归于某个阶段。
分别记录输入加载/校验、代码生成、构建和缓存检查、进程启动、C 积分阶段 CPU/墙钟、结果整理/序列化/传输,以及网页接收、持久化、曲线展示和导出。只测到总耗时就称为总耗时,不能把差值未经测量地归于某个阶段。
每次运行保存实际设置、成功或失败状态、实际终点、采样数量、求值次数、接受/拒绝步数、雅可比/线性分解次数、事件诊断和守恒结果。报告链接到原始产物,明确哪些阶段未测量、哪些比较因数据不足而跳过。后续优化以通过正确性检查后的同口径数据为依据。
## 实现中已有的计时与缺失项
| 字段 / 事件 | 当前范围 | 不能如何使用 |
| --- | --- | --- |
| CLI `preparationSeconds` | 加载/规范化输入、校验、构造网络、生成 C | 不能单称前端预处理或 C 编译 |
| `buildSeconds` | 工具链与依赖检查、预处理、缓存验证、缺失单元编译、链接和部分清理 | 命中时仍非零;不是单纯编译耗时 |
| `buildDetails.preprocessSeconds` | 并行预处理段墙钟 | 不包含全部工具链/依赖检查 |
| `compileSeconds` / `compileWallSeconds` | 各编译子进程耗时之和 / 编译单元阶段墙钟(也包含复用与复制) | 前者不是 CPU 计时,二者不能相加 |
| `linkSeconds` | 链接命令墙钟 | 不含整个构建发布阶段 |
| C `solveSeconds` / `solveCpuSeconds` | `native_solve` 内积分调用的墙钟 / CPU,含积分中的采样和事件工作 | 不包含调用前的初始化、首次样本,以及之后的最终追加和 JSON 编码;不能叫完整仿真耗时 |
| `processWallSeconds` | Python 从准备启动子进程,到进程退出、日志收集、读取/解析结果结束 | 不是只测 C 程序进程存活时间 |
| `phase="complete"` / “正在汇总仿真结果” | runner 已读完结果,后续还有响应组装、传输、网页解析及持久化 | 该文案持续时间不能全部归到后端汇总 |
| IndexedDB 事务完成 / sessionStorage 指针发布 | 当前网页结果持久化完成 | 不等于 OS 下载文件落盘 |
CLI 默认 `--runs 3` 实际执行 1 次预热加 3 次计时运行,`medianSolveSeconds` 不含预热;`--solve-only` 不记录完整曲线,仅适于积分成本观察,不能作为“点击运行→结果可查看/保存”的总耗时或完整曲线验收。
当前 C/原生适配报告求值、步数、事件计数及 `njev/nlu`,但并未自动输出所有物理量的守恒误差,也没有完整逐试算活动遥测。心跳中的活动字段不等于每一项都被原生程序实时更新;缺少的指标需独立测量/计算,不能把空值或未更新的 0 当作已验证结果。
网页 CSV 默认在 Web Worker 本地生成;后端保留的 CSV 路由不代表网页实际使用该路由。图表还有按事件诊断分离部分孤立机械力样本的展示逻辑;正确性比较使用原始 series、结果文件或 CSV,并保留事件点,不能以图表截图代替原始数据。
+14 -2
View File
@@ -1,5 +1,9 @@
# 气动端口变量供需合同
文档版本:1.1.1
修订日期:2026-09-12
核对代码基线:`22579e5`;本次版本号仅标注文档,不改变模型版本或协议版本。
本合同在 Python 编译阶段和前端建模阶段使用,数值计算仍由 C 执行。它不改变状态、输出键、积分器、雅可比策略或管流算法。
## 物理连接与计算供需
@@ -75,7 +79,7 @@
- 画布:供需不匹配的目标口不会作为可连接目标;接触吸附使用同一规则。悬停显示需要/提供的量和不兼容原因。
- 旧工程:保留供需不匹配的现有连线供检查、修改,不自动交换参考口或删线。原有的无效端口、重复占用等结构损坏处理不变。
- 检查模型及运行仿真:报告具体部件、端口和缺少的量,错误未处理前不启动求解。
- JSON/XML/API/直接构造网络:后端独立检查,不能靠删除或伪造前端快照绕过。
- XML 语义、JSON 执行入口及直接构造网络:后端独立检查,不能靠删除或伪造前端快照绕过。工程 JSON 的保存/加载接口仅做存储结构检查,不执行完整供需检查。
- 两条 C 生成入口:在生成前再次检查连接及参考来源。
错误码:`CONNECTION_VARIABLE_SUPPLY_MISSING`、`REFERENCE_SUPPLY_CYCLE`、`REFERENCE_SUPPLY_UNCONNECTED`。
@@ -84,6 +88,14 @@
供需数据结构与后端检查位于 `app/simulation/core/port_computation.py`,前端对应实现位于 `frontend/src/portComputation.ts`。新增固定接口应在模型 `PORTS` 中声明,并提供合法连接、供需冲突、流向/连线顺序反转和参考链的测试。
本次没有修改浏览器工程、正式 `test-mql-8.json` 或历史数值基准。旧节点 smoke 测试改为将参考口接到储气状态;另保留明确的错误接线拒绝测试。历史 50 个冻结网络仍能通过新合同并执行原 C 数值对照。
当前正确性/性能活动输入按[优化基准模型约定](optimization-benchmark-model.md)使用修正八路文件。历史 50 个冻结网络是独立回归基准,不是四路工程,不能替代当前 AME/JSON 的逐项核对。
浏览器测试从实际 Python 组件目录读取供需信息,覆盖目标筛选、悬停原因、错误旧连线保留和仿真前拦截,防止前后端合同不一致。
## 注册与工程导入边界(2026-09-12 修订)
供需规则以当前注册表为准,但工程快照首先必须通过浏览器结构解析。目录返回的信号端口可能带 `positiveFlowDirection: null`,工程解析器不接受该值;生成快照时省略此字段,不用目录对象直接替代工程对象,见[目录协议](component-library-spec-v1.md)。
动态端口须同时实现后端有效端口和前端参数变更/连线处理;当前 LMECHN1 有专用逻辑,供需合同不自动提供通用动态端口能力。注册演练的信号源不涉及气动固定参考口,不能用该演练代替参考链或逆流验收。
当前 XML/后端网络允许信号输出扇出,网页模型检查仍限制每个显示端口恰好一条连接。后端对普通未接活动端口的 XML 警告、原生模型的必接要求及前端错误提示应分别测试,不能以一个入口的允许行为推断其他入口。
+16 -8
View File
@@ -1,5 +1,7 @@
# System XML v3 协议
文档版本:1.1.0;核对日期:2026-09-12;代码基线:`22579e5`。此版本标注文档修订,不改变 XML Schema 3。
System XML v3 是 SystemSimulationApp 当前唯一的 XML 求解输入格式。它只描述可执行模型,不再承担 ReactFlow 画布存档职责。
机器可读结构见 [`schemas/system-simulation-v3.xsd`](../../schemas/system-simulation-v3.xsd)。当前校验、解析、编译和仿真接口固定按 v3 处理,不会根据 `schemaVersion` 自动切换到 v1 或 v2。
@@ -28,7 +30,7 @@ XML 不保存:
## 2. 完整结构示例
下面的例子包含一条信号连接和一条机械连接,展示 v3 的全部结构元素:
下面的例子包含一条信号连接和一条机械连接,展示 v3 的全部结构元素。它通过 XML 语义校验,但不是可求解算例:机械组没有惯性锚点,原生生成会报 `mechanical group has no inertia anchor`。实际仿真应增加符合方程的质量/惯性连接;不要用结构校验通过代替原生能力验收。
```xml
<?xml version="1.0" encoding="UTF-8"?>
@@ -103,7 +105,9 @@ System
| `tStop` | 仿真结束时刻 | 必须有限且大于 `tStart` |
| `sampleStep` | 结果相邻采样点的时间间隔 | 必须大于 0;不设置固定的采样点数上限 |
| `maxStep` | 自适应积分器单个内部步的上限 | 必须大于 0 |
| `method` | 积分方法 | `RK45/RK23/DOP853/Radau/BDF/LSODA` |
| `method` | 积分方法 | 当前语义校验及 C 执行仅接受 `RK45`、`BDF` |
XSD 的枚举仍包含 `RK23/DOP853/Radau/LSODA`,但它们会被 `app/system_xml.py::SUPPORTED_SOLVER_METHODS` 在语义层以 `SIMULATION_METHOD_UNSUPPORTED` 拒绝。不能将 XSD 的结构允许集合当成运行能力。XML 当前没有 `rtol/atol/firstStep` 属性;网页和 XML API 由 `backends.simulation_config()` 设置 `rtol=1e-8`,绝对误差采用生成的逐状态尺度,见[求值与精度规范](native-evaluation-schedule.md)。
`sampleStep` 和 `maxStep` 不是一回事:
@@ -138,7 +142,7 @@ v3 不保存 `name/componentType/x/y/rotation/mirrored`。其中:
### 5.2 `Parameter`
平台压力统一采用绝压,默认显示单位为 `Pa`,可切换为 `kPa`、`MPa` 或 `bar`。这些显示单位只作比例换算,不增加或减去大气压偏移,`1 bar = 100000 Pa`。工程 JSON 中的数值参数保存 SI 值,`parameterUnits` 保存显示单位;表达式按保存的显示单位求值后换算到 SI。例如输入 `2.5 bar` 时,XML 的压力参数值为 `250000`。后端仿真输入与压力结果均使用绝压 Pa。
平台压力统一采用绝压,默认显示单位为 `Pa`,可切换为 `kPa`、`MPa` 或 `bar`。这些显示单位只作比例换算,不增加或减去大气压偏移,`1 bar = 100000 Pa`。工程 JSON v2 中数值和表达式均按 `parameterUnits` 解释,统一换算为 SI;v1 兼容保留旧数值的 SI 含义。例如输入 `2.5 bar` 时,XML 的压力参数值为 `250000`。后端仿真输入与压力结果均使用绝压 Pa。
```xml
<Parameter name="direction" value="-1"/>
@@ -153,6 +157,8 @@ v3 不保存 `name/componentType/x/y/rotation/mirrored`。其中:
前端和后端导出器会先用注册默认值补齐工程 JSON 中省略的参数,再写入 XML。XML 解析器本身不替缺失参数猜默认值。
网页、HTTP 和 CLI 的 JSON 输入均在适配层计算受限数学表达式并按格式版本换算到 SI。v2 的 `p0: 2.5`、`p0: "2.5"`、`p0: "=2.5"` 配合 `bar` 均为 250000 Pa;v1 数字仍为旧 SI 存档含义,不可直接更改格式版本。组件旧版本在 JSON 输入层警告后采用当前模型生成 XML,XML 本身继续严格检查版本及有限 SI 数字。完整规则见[接口规范](backend-interface-version-spec-v1.md#61-工程存储与执行入口)。
### 5.3 AMESim 介质引用
介质仍用普通参数表达,不增加额外 XML 层级:
@@ -195,7 +201,7 @@ XML 不能通过写一个新端口名来扩展组件,也不能通过修改字
- 两端 `domain` 和完整变量合同一致;
- 信号连接恰好连接一个 `output` 和一个 `input`;
- 固定气动接口的变量供需互补,节点参考温度/压力来源没有闭合引用环;详见 [气动端口变量供需合同](port-computation-contract.md);
- 一个信号输出可以驱动多个输入,但每个信号输入只能有一个驱动;
- XML 与后端网络允许一个信号输出驱动多个输入,但每个输入只能有一个驱动;当前网页模型检查仍要求每个显示端口恰好一条边,不能据此承诺网页也已支持扇出;
- 同一物理端口只使用一次;分支必须使用显式 Tee/节点组件;
- 不允许自连接或重复端点对。
@@ -229,7 +235,7 @@ XML 不能通过写一个新端口名来扩展组件,也不能通过修改字
| `useFriction` | 不启用摩擦 | 启用摩擦 |
| `strib` | 不使用 Stribeck 效应 | 使用 Stribeck 效应 |
工程 JSON v1 和 System XML v3 都直接保存上述值。后端不会把 `0/1` 自动换算成
工程 JSON v1/v2 和 System XML v3 都直接保存上述值。后端不会把 `0/1` 自动换算成
`1/2`,也不会根据缺失的兼容标记猜测工程含义;不在目录选项集合内的值会被拒绝。
## 8. 工程 JSON 与 System XML 的分工
@@ -238,9 +244,9 @@ XML 不能通过写一个新端口名来扩展组件,也不能通过修改字
| --- | --- | --- |
| 组件实例 ID、模型类型 | 保存 | 保存 |
| 模型版本 | 每个节点显式保存并与目录核对 | 每个组件显式保存 |
| 数值参数 | 保存编辑值及显示信息 | 保存完整 SI 数值 |
| 数值参数 | v2 数值和表达式均按所选单位;v1 数字兼容 SI | 保存完整 SI 数值 |
| 组件显示名、坐标、旋转、镜像 | 保存 | 不保存 |
| 端口快照、`side/order` | 保存供编辑器使用 | 不保存 |
| 端口快照与 `side` | 保存供编辑器使用;目录 `order` 不保证原样留在快照中 | 不保存 |
| ReactFlow `source/target/handle` | 保存 | 转成两个 `Endpoint` |
| 端口物理合同 | 目录快照用于前端检查 | 不重复保存,由注册表恢复 |
| 参数表达式、显示单位 | 保存 | 不保存 |
@@ -268,7 +274,9 @@ XML 不能通过写一个新端口名来扩展组件,也不能通过修改字
`POST /api/reactflow/system-xml` 可将工程 JSON 导出为 v3;当前前端也能在浏览器中直接生成同一结构。
通过 XML/XSD/语义校验只说明输入合同正确。完整仿真前仍会检查动态储能锚点、未连接物理端口、方程结构和不允许的理想储能直连等可求解条件。物理连通岛只由物理组件和物理连接构成;控制信号扇出不会把两个独立气路或机械网络合并成一个物理岛。
通过 XML/XSD/语义校验只说明输入合同正确。`compile-model` 构造的是 Python 网络和接口信息,不编译 C 可执行文件;仿真时还要通过具体原生生成路径的状态/惯性来源、必接端口、方程及输出映射检查。紧凑路径不支持的部分拓扑会转入扩展路径,不应把紧凑路径的储气直连限制写成全系统统一禁令。信号源可使用无物理状态的内部占位状态,不是所有模型都必须有动态储能组件。
前端检查、XML 语义检查和原生能力检查相互独立。XML 对一般未连接活动端口报告警告;缺少固定参考来源等情况仍可报错。原生生成按模型的必接要求检查,例如 PNVO 信号口有 `opening0` 的专用缺省处理;网页仍会拦截未连接显示端口。
## 10. 旧版本处理边界
+16
View File
@@ -1,5 +1,9 @@
# Windows 与 Linux 功能交付约定
文档版本:1.1.1
修订日期:2026-09-12
核对代码基线:`22579e5`;本次版本号仅标注文档,不改变模型版本或协议版本。
2026-09-12 用户明确要求:后续功能补全同时注意 Windows 平台适配。
Windows x64 与 Linux x86_64 都是当前应用的使用平台。涉及文件系统、缓存、编译器、进程、动态库、环境安装或启动脚本的功能修改,应同时检查两侧的实现和测试入口。
@@ -18,3 +22,15 @@ $env:SIMULATION_NATIVE_REQUIRE_TOOLCHAIN = '1'
```
该入口使用现有 `SIMULATION_NATIVE_CC` / `SUNDIALS_ROOT` 或构建器的默认探测;CI 配置在 `.github/workflows/solver-regression.yml` 的 `native-windows` 作业。
新增组件同样适用本约定:新增 C 模块应使用现有构建抽象和严格浮点设置,检查模块导出、传递依赖、对象复用、完整模型缓存、Windows 可执行文件与 DLL 发现;不要在组件脚本中写死 Linux 编译器、路径分隔或符号链接能力。
[注册演练](component-registration-example-v1.md)在 Linux 使用真实浏览器和 C 工具链执行;本次没有 Windows 实机环境,未宣称演练已通过 Windows 验收。复现工具使用 `sys.executable`、`pathlib`、无 shell 的编译调用;Windows 验收仍需实际执行。
## 当前工具链边界(2026-09-12 核对)
构建器只接受 Windows/Linux,默认寻找 `gcc`,或读取 `SIMULATION_NATIVE_CC`;使用 GCC 风格的预处理、目标查询和编译参数,不承诺 MSVC/macOS 支持。公共参数包括 C11、`-O3 -Wall -Wextra -Werror -ffp-contract=off -fno-fast-math`。Windows 另加 MinGW printf 与静态 libgcc 选项,Linux 添加 POSIX 宏。
`SUNDIALS_ROOT` 指向开发文件根目录:Windows 使用 `lib/sundials_*.lib` 和 `bin/sundials_*.dll`,将依赖 DLL 随模型复制;Linux 使用探测目录中的 `libsundials_*.a` 静态链接。当前五个链接库为 cvode、core、nvecserial、sunmatrixdense、sunlinsoldense,RK45 构建也使用这套共用程序。
CI 的 `native-windows` 配置了 MinGW、SUNDIALS、缓存回归、后端完整回归和原生 RK45 夹具;其存在不证明任意一次修改已经在 Windows 运行通过。本次仅核对配置并运行本机可执行检查,未取得新的 Windows 实机结果。
+84 -87
View File
@@ -1,3 +1,5 @@
import { componentIndex, componentVersionWarning, decodeProjectParameters, encodeProjectParameters,
DECIMAL_LITERAL, parameterUnitConversion, sharedUnitTable } from "./projectCompatibility";
import {
ChangeEvent,
DragEvent,
@@ -472,14 +474,15 @@ type ProjectEdgePayload = {
};
type ReactFlowProjectPayload = {
projectSchemaVersion: 1;
projectSchemaVersion: 1 | 2;
name: string;
nodes: ProjectNodePayload[];
edges: ProjectEdgePayload[];
simulation: SimulationConfig;
};
type ExecutableProjectPayload = Omit<ReactFlowProjectPayload, "simulation"> & {
type ExecutableProjectPayload = Omit<ReactFlowProjectPayload, "simulation" | "projectSchemaVersion"> & {
projectSchemaVersion: 1;
simulation: ResolvedSimulationConfig;
};
@@ -991,51 +994,14 @@ function paletteCategoryItemsId(libraryId: string, categoryId: string) {
}
const identityUnit = (value: number) => value;
const scaleUnit = (factor: number) => ({
toBase: (value: number) => value * factor,
fromBase: (value: number) => value / factor,
});
const unitOptions: Record<UnitQuantity, UnitOption[]> = {
area: [
{ value: "m2", label: "m²", toBase: identityUnit, fromBase: identityUnit },
{ value: "cm2", label: "cm²", ...scaleUnit(1e-4) },
{ value: "mm2", label: "mm²", ...scaleUnit(1e-6) },
],
heat_transfer_coefficient: [
{
value: "W/(m2*K)",
label: "W/(m²·K)",
toBase: identityUnit,
fromBase: identityUnit,
},
],
pressure: [
{ value: "Pa", label: "Pa", toBase: identityUnit, fromBase: identityUnit },
{ value: "kPa", label: "kPa", ...scaleUnit(1_000) },
{ value: "MPa", label: "MPa", ...scaleUnit(1_000_000) },
{ value: "bar", label: "bar", ...scaleUnit(100_000) },
],
volume: [
{ value: "m3", label: "m³", toBase: identityUnit, fromBase: identityUnit },
{ value: "L", label: "L", ...scaleUnit(0.001) },
{ value: "mL", label: "mL", ...scaleUnit(0.000001) },
],
temperature: [
{ value: "K", label: "K", toBase: identityUnit, fromBase: identityUnit },
{
value: "degC",
label: "°C",
toBase: (value) => value + 273.15,
fromBase: (value) => value - 273.15,
},
],
length: [
{ value: "m", label: "m", toBase: identityUnit, fromBase: identityUnit },
{ value: "cm", label: "cm", ...scaleUnit(0.01) },
{ value: "mm", label: "mm", ...scaleUnit(0.001) },
],
};
const unitOptions: Record<string, UnitOption[]> = Object.fromEntries(
Object.entries(sharedUnitTable).map(([quantity, options]) => [quantity,
Object.entries(options).map(([value, [scale, offset, label]]) => ({
value, label, toBase: (number: number) => number * scale + offset,
fromBase: (number: number) => (number - offset) / scale,
})),
]),
);
const defaultSimulationConfig: ResolvedSimulationConfig = {
t_start: 0,
@@ -6634,7 +6600,7 @@ function FlowWorkbench() {
const normalizedDisplayValue = displayValue.trim();
const numericValue = Number(normalizedDisplayValue);
const hasFiniteNumericValue =
normalizedDisplayValue !== "" && Number.isFinite(numericValue);
DECIMAL_LITERAL.test(normalizedDisplayValue) && Number.isFinite(numericValue);
const convertedBaseValue = hasFiniteNumericValue
? unit.toBase(numericValue)
: numericValue;
@@ -7385,8 +7351,14 @@ function FlowWorkbench() {
}));
};
const warnExecutionVersions = () => {
const warning = componentVersionWarning(nodesRef.current, componentDefinitions, true);
if (warning) appendConsoleEntry("warning", warning);
};
const generateXml = () => {
try {
warnExecutionVersions();
const xml = buildSystemXml(buildCurrentProject(), componentDefinitions, nodes);
appendConsoleEntry(
"success",
@@ -7400,6 +7372,7 @@ function FlowWorkbench() {
const downloadXml = () => {
try {
warnExecutionVersions();
const xml = buildSystemXml(buildCurrentProject(), componentDefinitions, nodes);
downloadText(`${safeFilename(projectName)}.xml`, xml);
appendConsoleEntry("success", `XML 已下载:${safeFilename(projectName)}.xml`);
@@ -7414,8 +7387,9 @@ function FlowWorkbench() {
if (addToHistory) {
recordHistory();
}
const versionWarning = componentVersionWarning(project.nodes, componentDefinitions);
const legacyMigration = migrateLegacyAmesimComponents(
project,
decodeProjectParameters(project, componentDefinitions),
componentDefinitions,
);
const normalizedNodes = legacyMigration.project.nodes.map((node) =>
@@ -7444,6 +7418,9 @@ function FlowWorkbench() {
setValidatedSignature("");
syncNextNodeNumbers(loadedNodes);
appendConsoleEntry("success", message);
if (versionWarning) appendConsoleEntry("warning", versionWarning);
if (project.projectSchemaVersion === 1) appendConsoleEntry("info",
"已按旧工程规则读取参数(数值为 SI);重新导出 JSON 后,数值和表达式均按所选单位解释。");
if (legacyMigration.migratedLmechn1NodeCount > 0) {
appendConsoleEntry(
"info",
@@ -7528,11 +7505,33 @@ function FlowWorkbench() {
const exportProject = () => {
const project = buildCurrentProject();
downloadFile(
`${safeFilename(project.name)}.json`,
JSON.stringify(project, null, 2),
"application/json;charset=utf-8",
);
const index = componentIndex(componentDefinitions);
const sourceById = new Map(nodesRef.current.map(node => [node.id, node]));
const upgraded = { ...project, nodes: project.nodes.map((node) => {
const definition = index.get(node.data.componentType);
const source = sourceById.get(node.id);
const compatible = definition && !(source?.data.modelContractIssues?.length) &&
loadedNodeModelContractIssues(node, definition).length === 0 &&
Object.entries(definition.parameters).every(([name, parameter]) =>
!componentParameterValidationMessage(parameter, node.data.parameters[name] ?? parameter.default,
node.data.parameterUnits?.[name] ?? parameter.unit ?? "", undefined, definition.type, name));
return compatible ? { ...node, data: { ...node.data, modelVersion: definition.modelVersion } } : node;
}) };
let exported = project;
try {
exported = encodeProjectParameters(upgraded, componentDefinitions);
} catch (error) {
appendConsoleEntry("warning", `工程含未能转换的数据,已保留旧格式和原版本供修复:${formatError(error)}`);
}
downloadFile(`${safeFilename(project.name)}.json`, JSON.stringify(exported, null, 2),
"application/json;charset=utf-8");
const sourceParameters = new Map(project.nodes.map(node => [node.id, node.data.parameterUnits]));
const exactSiCount = exported.projectSchemaVersion === 2 ? exported.nodes.reduce((count, node) => count +
Object.entries(node.data.parameterUnits ?? {}).filter(([name, unit]) =>
unit !== sourceParameters.get(node.id)?.[name]).length, 0) : 0;
if (exactSiCount) appendConsoleEntry("info", `为保持求解输入的完整精度,${exactSiCount} 个参数已使用 SI 单位导出`);
const remaining = componentVersionWarning(exported.nodes, componentDefinitions);
if (remaining) appendConsoleEntry("warning", `部分组件未通过兼容检查,保留原版本。${remaining}`);
appendConsoleEntry("success", `工程 JSON 已导出:${safeFilename(project.name)}.json`);
};
@@ -7733,8 +7732,10 @@ function FlowWorkbench() {
appendConsoleEntry("error", simulationResolution.message);
return;
}
warnExecutionVersions();
const project: ExecutableProjectPayload = {
...buildCurrentProject(),
projectSchemaVersion: 1,
simulation: simulationResolution.value,
};
setSimulationProgress({
@@ -9676,14 +9677,13 @@ function normalizeLoadedNode(
componentDefinitions: ComponentDefinition[],
): SimulationNode {
const definition =
componentDefinitions.find((item) => item.type === node.data.componentType) ??
componentIndex(componentDefinitions).get(node.data.componentType) ??
componentDefinitions.find((item) => item.modelType === node.data.modelType);
const rawParameters = node.data.parameters;
const modelContractCompatible = Boolean(
definition &&
node.data.componentType === definition.type &&
node.data.modelType === definition.modelType &&
node.data.modelVersion === definition.modelVersion,
node.data.modelType === definition.modelType,
);
const parameters = definition && modelContractCompatible
? { ...defaultParameters(definition), ...rawParameters }
@@ -9769,13 +9769,24 @@ function loadedNodeModelContractIssues(
`组件“${label}”的工程类型与当前目录不一致(工程 ${node.data.componentType}/${node.data.modelType},目录 ${definition.type}/${definition.modelType})`,
);
}
if (!node.data.modelVersion) {
issues.push(`组件“${label}”缺少 modelVersion,无法确认方程和参数含义`);
} else if (node.data.modelVersion !== definition.modelVersion) {
issues.push(
`组件“${label}”模型版本不匹配(工程 ${node.data.modelVersion},当前 ${definition.modelVersion})`,
);
const expectedPorts = new Map(definition.ports.map(port => [port.name, port]));
const seen = new Set<string>();
for (const port of node.data.ports) {
const expected = expectedPorts.get(port.name);
if (seen.has(port.name) || !expected || port.kind !== expected.kind ||
port.domain !== expected.domain || port.nominalRole !== expected.nominalRole) {
issues.push(`组件“${label}”端口 ${port.name} 与当前目录不兼容`);
}
seen.add(port.name);
}
for (const port of definition.ports) {
if (!seen.has(port.name)) issues.push(`组件“${label}”缺少当前端口 ${port.name}`);
}
for (const name of Object.keys(node.data.parameters)) {
if (!definition.parameters[name]) issues.push(`组件“${label}”包含目录未定义的参数:${name}`);
}
return issues;
}
@@ -10208,7 +10219,7 @@ function resolveNumericInput(value: ParameterValue): NumericInputResolution {
return { ok: false, message: "参数不能为空", expression: false };
}
const numericValue = Number(normalizedValue);
if (Number.isFinite(numericValue)) {
if (DECIMAL_LITERAL.test(normalizedValue) && Number.isFinite(numericValue)) {
return { ok: true, value: numericValue, expression: false };
}
@@ -10227,6 +10238,11 @@ function resolveParameterValue(
definition: ParameterDefinition,
selectedUnit: string,
): NumericInputResolution {
try {
parameterUnitConversion(definition, selectedUnit);
} catch (error) {
return { ok: false, message: formatError(error), expression: false };
}
const resolved = resolveNumericInput(value);
if (!resolved.ok) {
return resolved;
@@ -10505,15 +10521,6 @@ function validateModel(
`${node.data.label}:工程组件类型与当前目录定义不一致`,
);
}
if (!node.data.modelVersion) {
modelContractIssues.add(
`${node.data.label}:缺少 modelVersion,无法确认方程和参数含义`,
);
} else if (node.data.modelVersion !== definition.modelVersion) {
modelContractIssues.add(
`${node.data.label}:模型版本不匹配(工程 ${node.data.modelVersion},当前 ${definition.modelVersion})`,
);
}
if (definition.modelType === "amesim_mecmas21") {
(["useFriction", "strib"] as const).forEach((name) => {
const value = numericChoiceValue(node.data.parameters[name]);
@@ -10915,7 +10922,7 @@ function parseProjectPayload(raw: string | null): ReactFlowProjectPayload | null
return null;
}
if (
value.projectSchemaVersion !== 1 ||
(value.projectSchemaVersion !== 1 && value.projectSchemaVersion !== 2) ||
typeof value.name !== "string" ||
!Array.isArray(value.nodes) ||
!value.nodes.every(isProjectNodePayloadValue) ||
@@ -11005,9 +11012,7 @@ function projectExecutionContractIssues(
);
project.nodes.forEach((node) => {
const label = node.data.label || node.id;
const definition = componentDefinitions.find(
(candidate) => candidate.type === node.data.componentType,
);
const definition = componentIndex(componentDefinitions).get(node.data.componentType);
if (!definition) {
issues.add(`组件“${label}”找不到当前组件目录定义`);
return;
@@ -11017,13 +11022,6 @@ function projectExecutionContractIssues(
`组件“${label}”模型类型不匹配(工程 ${node.data.modelType},当前 ${definition.modelType})`,
);
}
if (!node.data.modelVersion) {
issues.add(`组件“${label}”缺少 modelVersion,无法确认方程和参数含义`);
} else if (node.data.modelVersion !== definition.modelVersion) {
issues.add(
`组件“${label}”模型版本不匹配(工程 ${node.data.modelVersion},当前 ${definition.modelVersion})`,
);
}
if (definition.modelType !== "amesim_mecmas21") {
return;
}
@@ -11044,6 +11042,7 @@ function buildSystemXml(
componentDefinitions: ComponentDefinition[],
sourceNodes: SimulationNode[] = [],
) {
if (project.projectSchemaVersion !== 1) throw new Error("生成 XML 前必须将工程参数转换为 SI");
const contractIssues = projectExecutionContractIssues(
project,
componentDefinitions,
@@ -11083,9 +11082,7 @@ function buildSystemXml(
if (registeredPortsByNodeId.has(node.id)) {
throw new Error(`工程包含重复的组件 ID:${node.id}`);
}
const definitionByComponentType = componentDefinitions.find(
(candidate) => candidate.type === node.data.componentType,
);
const definitionByComponentType = componentIndex(componentDefinitions).get(node.data.componentType);
const definitionByModelType = componentDefinitions.find(
(candidate) => candidate.modelType === node.data.modelType,
);
+92
View File
@@ -0,0 +1,92 @@
import unitTable from "../../schemas/parameter-units.json" with { type: "json" };
export const DECIMAL_LITERAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
export const sharedUnitTable = unitTable as unknown as Record<string, Record<string, [number, number, string]>>;
type Parameter = { unit?: string; quantity?: string };
type Definition = { type: string; modelType: string; modelVersion: string; parameters: Record<string, Parameter> };
type Node = { id: string; data: { label: string; componentType: string; modelType: string; modelVersion?: string;
parameters: Record<string, number | string>; parameterUnits?: Record<string, string> } };
type Project = { projectSchemaVersion: 1 | 2; nodes: Node[] };
const indices = new WeakMap<readonly Definition[], Map<string, Definition>>();
export function componentIndex<D extends Definition>(definitions: readonly D[]): Map<string, D> {
let index = indices.get(definitions);
if (!index) {
index = new Map(definitions.map(definition => [definition.type, definition]));
indices.set(definitions, index);
}
return index as Map<string, D>;
}
export function componentVersionNotices(nodes: readonly Node[], definitions: readonly Definition[]) {
const index = componentIndex(definitions);
return nodes.flatMap(node => {
const definition = index.get(node.data.componentType);
if (!definition || definition.modelType !== node.data.modelType ||
definition.modelVersion === node.data.modelVersion) return [];
return [{ componentId: node.id, label: node.data.label || node.id,
storedVersion: node.data.modelVersion, currentVersion: definition.modelVersion }];
});
}
export function componentVersionWarning(nodes: readonly Node[], definitions: readonly Definition[], running = false) {
const notices = componentVersionNotices(nodes, definitions);
if (!notices.length) return null;
return `${running ? "正在使用旧版或版本未知的工程组件,将按当前模型执行,可能出现仿真失败或仿真结果与实际不符" : "工程包含旧版或版本未知的组件"}:` +
notices.map(n => `${n.label}(${n.storedVersion || "版本未知"} → ${n.currentVersion})`).join(";");
}
export function parameterUnitConversion(definition: Parameter, unit: string) {
const conversion = definition.unit && definition.quantity ? sharedUnitTable[definition.quantity]?.[unit] : undefined;
if (conversion) return { scale: conversion[0], offset: conversion[1] };
if (unit === (definition.unit ?? "")) return { scale: 1, offset: 0 };
throw new Error(`不支持的参数单位“${unit}”(${definition.quantity ?? definition.unit})`);
}
/** Editor numbers stay SI. v2 wire numbers and expressions both use selected units. */
export function decodeProjectParameters<P extends Project>(project: P, definitions: readonly Definition[]): P {
if (project.projectSchemaVersion === 1) return project;
const index = componentIndex(definitions);
return { ...project, projectSchemaVersion: 1, nodes: project.nodes.map(node => {
const definition = index.get(node.data.componentType);
if (!definition) throw new Error(`无法换算未知组件 ${node.data.label} 的单位`);
const parameters = Object.fromEntries(Object.entries(node.data.parameters).map(([name, value]) => {
const parameter = definition.parameters[name];
if (!parameter) throw new Error(`组件 ${node.data.label} 包含未知参数 ${name}`);
const { scale, offset } = parameterUnitConversion(parameter, node.data.parameterUnits?.[name] ?? parameter.unit ?? "");
const isNumber = typeof value === "number" || DECIMAL_LITERAL.test(value.trim());
const si = isNumber ? Number(value) * scale + offset : value;
if (typeof si === "number" && !Number.isFinite(si)) throw new Error(`${node.id}.${name} 必须为有限数值`);
return [name, si];
}));
return { ...node, data: { ...node.data, parameters } };
}) } as P;
}
/** Call only for editor SI data, never on an already-encoded external project. */
export function encodeProjectParameters<P extends Project>(project: P, definitions: readonly Definition[]): P {
if (project.projectSchemaVersion !== 1) throw new Error("工程已按显示单位编码,不能重复换算");
const index = componentIndex(definitions);
return { ...project, projectSchemaVersion: 2, nodes: project.nodes.map(node => {
const definition = index.get(node.data.componentType);
if (!definition) throw new Error(`无法导出未知组件 ${node.data.label} 的新版单位格式`);
const parameterUnits = { ...node.data.parameterUnits };
const parameters = Object.fromEntries(Object.entries(node.data.parameters).map(([name, value]) => {
const parameter = definition.parameters[name];
if (!parameter) throw new Error(`组件 ${node.data.label} 包含未知参数 ${name}`);
const { scale, offset } = parameterUnitConversion(parameter, node.data.parameterUnits?.[name] ?? parameter.unit ?? "");
const isNumber = typeof value === "number" || DECIMAL_LITERAL.test(value.trim());
let display = isNumber ? (Number(value) - offset) / scale : value;
// Some SI doubles have no exact representation after a unit round trip.
// Preserve the numerical input by writing that parameter in its SI unit.
// Never round the physical value or add a hidden second source of truth.
if (typeof display === "number" && display * scale + offset !== Number(value)) {
display = Number(value);
parameterUnits[name] = parameter.unit ?? "";
}
if (typeof display === "number" && !Number.isFinite(display)) throw new Error(`${node.id}.${name} 必须为有限数值`);
return [name, display];
}));
return { ...node, data: { ...node.data, parameters, parameterUnits } };
}) } as P;
}
+1 -1
View File
@@ -2864,7 +2864,7 @@ test("缺失或错误工程版本、字符串端口和缺失 Handle 均拒绝恢
if (invalidVariant === "missing-version") {
delete project.projectSchemaVersion;
} else if (invalidVariant === "wrong-version") {
project.projectSchemaVersion = 2;
project.projectSchemaVersion = 3;
} else if (invalidVariant === "string-port") {
project.nodes[0].data.ports = ["port_1"];
} else {
+8 -15
View File
@@ -883,7 +883,7 @@ test("MECMAS21 旧的 0/1 编码不转换并阻止执行", async ({
await expect(consolePanel).toContainText("模型检查发现");
});
test("缺失模型版本的工程可查看但不补默认参数且不能执行", async ({
test("缺失模型版本保留来源并补当前默认值,生成 XML 只警告", async ({
page,
}) => {
await page.goto("/");
@@ -923,8 +923,8 @@ test("缺失模型版本的工程可查看但不补默认参数且不能执行",
const node = page.locator(
'.flow-canvas .react-flow__node[data-id="amesim_mecmas21_1"]',
);
await expect(node.locator('[data-model-contract-compatible="false"]')).toBeVisible();
await expect(node.getByLabel(/模型合同不兼容/)).toBeVisible();
await expect(node.locator('[data-model-contract-compatible="true"]')).toBeVisible();
await expect(node.getByLabel(/模型合同不兼容/)).toHaveCount(0);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const persistedWithoutDefaults = await page.evaluate(() => {
const key = Object.keys(window.localStorage).find((candidate) =>
@@ -950,7 +950,7 @@ test("缺失模型版本的工程可查看但不补默认参数且不能执行",
data &&
data.modelVersion === undefined &&
data.parameters &&
!("strib" in data.parameters),
("strib" in data.parameters),
);
});
expect(persistedWithoutDefaults).toBe(true);
@@ -959,20 +959,13 @@ test("缺失模型版本的工程可查看但不补默认参数且不能执行",
name: "仿真控制台",
exact: true,
});
await page.getByRole("button", { name: "检查模型", exact: true }).click();
await expect(consolePanel).toHaveClass(/minimized/);
await expect(
consolePanel.locator(".simulation-console-dock-summary"),
).toContainText("模型检查发现");
await expandSimulationConsole(page);
await expect(consolePanel.locator(".simulation-console-dock-log")).toContainText(
"缺少 modelVersion",
);
await expect(consolePanel).toContainText("工程包含旧版或版本未知的组件");
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
await expect(consolePanel).toContainText("不能生成执行 XML");
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await expect(consolePanel).toContainText("模型检查发现");
await expect(consolePanel).toContainText("XML 已生成");
await expect(consolePanel).toContainText("可能出现仿真失败或仿真结果与实际不符");
await expect(consolePanel).not.toContainText("不能生成执行 XML");
});
test("详情浮层只在参数列悬停且下拉选择期间不触发", async ({ page }) => {
+1 -1
View File
@@ -103,7 +103,7 @@ test("压力默认显示 Pa,bar 与 Pa 换算保持绝压且无大气压偏移
await expectPressureXml(page, 250000);
}
const exported = await exportProject(page);
expect(exported.project.nodes[0].data.parameters.reference_pressure).toBe(250000);
expect(exported.project.nodes[0].data.parameters.reference_pressure).toBe(2.5);
expect(exported.project.nodes[0].data.parameterUnits.reference_pressure).toBe("bar");
});
@@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test";
import cases from "../../../tests/fixtures/parameter-expressions.json" with { type: "json" };
import { evaluateParameterExpression } from "../../src/parameterExpression";
import { componentVersionNotices, decodeProjectParameters, encodeProjectParameters } from "../../src/projectCompatibility";
const definitions = [{ type: "chamber", modelType: "chamber", modelVersion: "2", parameters: {
p0: { unit: "Pa", quantity: "pressure" }, T0: { unit: "K", quantity: "temperature" },
} }];
function project(value: number | string, version: 1 | 2 = 2) {
return { projectSchemaVersion: version, nodes: [{ id: "c", data: { label: "c", componentType: "chamber",
modelType: "chamber", modelVersion: "1", parameters: { p0: value, T0: 20 }, parameterUnits: { p0: "bar", T0: "degC" } } }] };
}
test("shared expression grammar and resource limits", () => {
for (const [source, expected] of cases.valid) {
const actual = evaluateParameterExpression(String(source));
expect(actual.ok, String(source)).toBe(true);
if (actual.ok) expect(actual.value).toBeCloseTo(Number(expected), 12);
}
for (const source of [...cases.invalid, "(".repeat(34) + "1" + ")".repeat(34), "1+".repeat(256) + "1", "1".repeat(513), `min(${Array(17).fill("1").join(",")})`]) {
expect(evaluateParameterExpression(source).ok, source).toBe(false);
}
});
test("v2 selected-unit numbers and expression values agree; legacy numbers retain SI", () => {
for (const value of [2.5, "2.5", "=2.5", "2+0.5"]) {
const input = project(value);
const original = structuredClone(input);
const internal = decodeProjectParameters(input, definitions);
const parameter = internal.nodes[0].data.parameters.p0;
const actual = typeof parameter === "number" ? parameter :
(() => { const e = evaluateParameterExpression(parameter); return e.ok ? e.value * 1e5 : NaN; })();
expect(actual).toBe(250000);
expect(internal.nodes[0].data.parameters.T0).toBe(293.15);
const exported = encodeProjectParameters(internal, definitions);
expect(exported.projectSchemaVersion).toBe(2);
expect(decodeProjectParameters(exported, definitions)).toEqual(internal);
expect(input).toEqual(original);
}
expect(decodeProjectParameters(project(250000, 1), definitions).nodes[0].data.parameters.p0).toBe(250000);
expect(() => encodeProjectParameters(project(2.5), definitions)).toThrow(/重复换算/);
});
test("version index finds stale and missing versions without mutating or per-node fetch", () => {
const input = project(2.5);
const original = structuredClone(input);
expect(componentVersionNotices(input.nodes, definitions)).toEqual([
{ componentId: "c", label: "c", storedVersion: "1", currentVersion: "2" },
]);
expect(input).toEqual(original);
input.nodes[0].data.modelVersion = "2";
expect(componentVersionNotices(input.nodes, definitions)).toEqual([]);
});
test("export preserves exact SI doubles when display-unit round trip loses a bit", () => {
const input = project(15299999.999999998, 1);
input.nodes[0].data.parameterUnits.p0 = "bar";
input.nodes[0].data.parameters.T0 = 293.15;
const encoded = encodeProjectParameters(input, definitions);
expect(encoded.nodes[0].data.parameterUnits.p0).toBe("Pa");
expect(encoded.nodes[0].data.parameters.p0).toBe(15299999.999999998);
expect(decodeProjectParameters(encoded, definitions).nodes[0].data.parameters).toEqual(input.nodes[0].data.parameters);
});
+8
View File
@@ -0,0 +1,8 @@
{
"area": {"m2": [1, 0, "m²"], "cm2": [0.0001, 0, "cm²"], "mm2": [0.000001, 0, "mm²"]},
"heat_transfer_coefficient": {"W/(m2*K)": [1, 0, "W/(m²·K)"]},
"pressure": {"Pa": [1, 0, "Pa"], "kPa": [1000, 0, "kPa"], "MPa": [1000000, 0, "MPa"], "bar": [100000, 0, "bar"]},
"volume": {"m3": [1, 0, "m³"], "L": [0.001, 0, "L"], "mL": [0.000001, 0, "mL"]},
"temperature": {"K": [1, 0, "K"], "degC": [1, 273.15, "°C"]},
"length": {"m": [1, 0, "m"], "cm": [0.01, 0, "cm"], "mm": [0.001, 0, "mm"]}
}
+10
View File
@@ -0,0 +1,10 @@
{
"valid": [
["=2.5", 2.5], ["2^3^2", 512], ["-2^2", -4], ["2**-3", 0.125],
["2.5E-3", 0.0025], ["sqrt(16)+abs(-2)", 6], ["SIN(pi/2)+ln(e)", 2],
["log(e)+log10(100)", 3], ["max(1,5,3)+pow(2,3)", 13],
["min(-1,0,2)", -1], ["cos(0)+tan(0)+asin(0)+acos(1)+atan(0)+exp(0)", 2],
["0.045/14", 0.0032142857142857142], ["2^20", 1048576]
],
"invalid": ["", "=", "1/0", "sqrt(-1)", "log(0)", "asin(2)", "1e309", "1e308*10", "2^100000000", "unknown+1", "__import__('os')", "window.alert(1)", "2(3)", "min()", "pow(2)", "sqrt(1,2)", "min(1,)", "[1][0]", "0x10", "1_000", "1%2", "True", "null", "1+*2"]
}
+49
View File
@@ -0,0 +1,49 @@
"""Measure JSON validation, input normalization and strict XML construction separately.
Run from the repository root with its Python environment; artifacts are ignored.
"""
import json
from pathlib import Path
from statistics import median
from time import perf_counter_ns
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
from app.project_parameters import prepare_project
def measure(fn, repeat=200):
for _ in range(10):
fn()
samples = []
for _ in range(repeat):
start = perf_counter_ns()
fn()
samples.append((perf_counter_ns() - start) / 1e6)
samples.sort()
return {"medianMs": median(samples), "p95Ms": samples[int(.95 * len(samples))], "samples": repeat}
def main():
source = Path("tests/data/test-mql-8-corrected.json")
raw = json.loads(source.read_text(encoding="utf-8"))
current = ReactFlowProjectPayload.model_validate(raw)
old = current.model_copy(deep=True)
for node in old.nodes:
node.data.modelVersion = "0.0.1"
normalized, _ = prepare_project(current)
metrics = {
"source": str(source), "nodes": len(current.nodes),
"parameters": sum(len(n.data.parameters) for n in current.nodes),
"pydanticInput": measure(lambda: ReactFlowProjectPayload.model_validate(raw)),
"prepareCurrent": measure(lambda: prepare_project(current)),
"prepareAllOld": measure(lambda: prepare_project(old)),
"strictXmlAfterPreparation": measure(lambda: build_reactflow_system_xml(normalized)),
"notes": "prepare includes a defensive copy, parameter/expression/unit conversion, version comparison and simulation time normalization; not version checking alone.",
}
destination = Path("test/project-contract-20260912/backend-input-timing.json")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(metrics, indent=2), encoding="utf-8")
print(json.dumps(metrics, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,125 @@
// Real catalog, project UI, HTTP and native execution in the rehearsal sandbox.
import { chromium } from '../../frontend/node_modules/playwright/index.mjs';
import fs from 'node:fs/promises';
import path from 'node:path';
import assert from 'node:assert/strict';
const [input, output, url = 'http://127.0.0.1:8036'] = process.argv.slice(2);
if (!input || !output) throw new Error('Usage: node browser_component_registration.mjs PROJECT.json OUTPUT_DIR [URL]');
await fs.mkdir(output, { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true });
page.setDefaultTimeout(15000);
const errors = [];
page.on('pageerror', e => errors.push(String(e)));
async function download(label, file) {
const promise = page.waitForEvent('download');
await page.getByRole('button', { name: label, exact: true }).click();
await (await promise).saveAs(path.join(output, file));
return path.join(output, file);
}
try {
await page.goto(url);
const catalog = await (await page.request.get(`${url}/api/components/catalog`)).json();
assert(catalog.libraries.some(x => x.id === 'registration_demo'));
const palette = page.getByRole('button', { name: '注册演练斜坡信号', exact: true });
await palette.waitFor();
await palette.dragTo(page.locator('.react-flow__pane').first(), { targetPosition: { x: 500, y: 300 } });
await page.locator('.react-flow__node').first().waitFor();
const paletteProject = JSON.parse(await fs.readFile(await download('导出工程 JSON', 'palette-created.json'), 'utf8'));
assert.equal(paletteProject.nodes.length, 1);
assert.equal(paletteProject.nodes[0].data.modelType, 'registration_demo_ramp');
assert.deepEqual(paletteProject.nodes[0].data.parameters, { offset: 2, amplitude: 3, duration: 1 });
const validProject = JSON.parse(await fs.readFile(input, 'utf8'));
const invalidProject = structuredClone(validProject);
invalidProject.nodes[0].data.ports[0].positiveFlowDirection = null;
await page.locator('input[type="file"][accept*=".json"]').setInputFiles({
name: 'invalid-catalog-port.json', mimeType: 'application/json',
buffer: Buffer.from(JSON.stringify(invalidProject)),
});
await page.getByText('导入失败:文件不是有效的 ReactFlow 工程 JSON', { exact: true }).waitFor();
assert.equal(await page.locator('.react-flow__node').count(), 1);
await page.locator('input[type="file"][accept*=".json"]').setInputFiles(path.resolve(input));
await page.locator('.react-flow__node[data-id="ramp_1"]').click();
const timeUnits = await page.getByRole('combobox', { name: '时长单位', exact: true }).locator('option').evaluateAll(options => options.map(o => o.value));
assert.deepEqual(timeUnits, ['s']);
const duration = page.getByRole('textbox', { name: '时长', exact: true });
assert.equal(await duration.inputValue(), '1');
await duration.fill('2');
await duration.press('Tab');
const projectPath = await download('导出工程 JSON', 'edited-project.json');
const project = JSON.parse(await fs.readFile(projectPath, 'utf8'));
assert.equal(project.nodes[0].data.parameters.duration, 2);
assert.equal(project.nodes[0].data.parameterUnits.duration, 's');
assert.equal(project.nodes[0].data.ports[0].name, 'out');
assert.equal(project.nodes.length, 4);
assert.equal(project.edges.length, 3);
await page.getByRole('button', { name: '生成系统 XML', exact: true }).click();
const xml = await fs.readFile(await download('下载系统 XML', 'browser-input.xml'), 'utf8');
assert.match(xml, /name="duration" value="2"/);
assert.match(xml, /type="registration_demo_ramp"/);
await page.screenshot({ path: path.join(output, 'model.png'), fullPage: true });
const rows = [];
let lastResult;
for (let index = 0; index < 2; index++) {
const marker = await page.evaluate(() => sessionStorage.getItem('system-simulation-flow:latest-result'));
const responsePromise = page.waitForResponse(r => r.url().endsWith('/api/system-xml/simulate-stream'));
await page.getByRole('button', { name: '运行仿真', exact: true }).click();
const response = await responsePromise;
await response.finished();
assert(response.ok());
await page.waitForFunction(old => {
const value = sessionStorage.getItem('system-simulation-flow:latest-result');
return value && value !== old && JSON.parse(value).storage === 'indexeddb';
}, marker);
await page.getByRole('tab', { name: /^结果/ }).click();
const data = JSON.parse(await fs.readFile(await download('下载结果文件', `run-${index}.simresult`), 'utf8'));
const result = data.snapshot.result;
assert(result.success);
assert.equal(result.simulatedUntil, 1);
assert.equal(result.series.time.length, 11);
assert(result.series['force_1.force']);
result.series.time.forEach((t, i) => assert(Math.abs(result.series['force_1.force'][i] - (2 + 1.5*t)) < 1e-12));
result.series.time.forEach((t, i) => {
assert(Math.abs(result.series['ramp_1.y'][i] - (2 + 1.5*t)) < 1e-12);
assert.equal(result.series['ramp_1.y'][i], result.series['ramp_1.out.signal'][i]);
});
const massErrors = { x: 0, v: 0 };
result.series.time.forEach((t, i) => {
// m=10, F=2+1.5t, x0=v0=0; analytic free-mass motion.
massErrors.x = Math.max(massErrors.x, Math.abs(result.series['mass_1.x'][i] - (.1*t*t + .025*t*t*t)));
massErrors.v = Math.max(massErrors.v, Math.abs(result.series['mass_1.v'][i] - (.2*t + .075*t*t)));
});
assert(massErrors.x < 1e-7 && massErrors.v < 1e-7);
if (index === 1) assert(result.diagnostics.native.cacheHit);
rows.push({ run: index, samples: 11, cacheHit: result.diagnostics.native.cacheHit, analyticPassed: true, massErrors });
lastResult = result;
if (!index) await page.getByRole('tab', { name: '建模', exact: true }).click();
}
await page.getByRole('button', { name: '适应系统图窗口', exact: true }).click();
await page.locator('.results-system-panel .react-flow__node[data-id="ramp_1"]').click();
await page.locator('.results-variable-list button').first().click();
await page.locator('.results-chart-panel .result-chart-window svg').first().waitFor();
const csv = await fs.readFile(await download('下载结果 CSV', 'result.csv'), 'utf8');
const records = csv.replace(/^\uFEFF/, '').trim().split(/\r?\n/).map(s => s.split(','));
assert.equal(records.length, 12);
const signalColumns = records[0].flatMap((key, i) => /ramp_1[.](y|out[.]signal)/.test(key) ? [i] : []);
assert.equal(signalColumns.length, 2);
for (let i = 1; i < records.length; i++) {
const values = records[i].map(Number);
for (const j of signalColumns) assert(Math.abs(values[j] - (2 + 1.5*values[0])) < 1e-12);
}
await page.screenshot({ path: path.join(output, 'result.png'), fullPage: true });
await page.reload();
await page.getByRole('tab', { name: /^结果/ }).click();
const restored = JSON.parse(await fs.readFile(await download('下载结果文件', 'restored.simresult'), 'utf8'));
assert.deepEqual(restored.snapshot.result, lastResult);
assert.deepEqual(errors, []);
await fs.writeFile(path.join(output, 'summary.json'), JSON.stringify({ url, browser: browser.version(),
mocked: false, paletteCreation: true, catalogDrivenParameters: true, timeUnits, siParameterEdit: true,
invalidCatalogSnapshotRejected: true, projectExport: true, xmlExport: true, chartVisible: true, rows, csvAnalyticPassed: true, restoredIdentical: true, errors }, null, 2));
console.log('Browser registration rehearsal passed.');
} catch (error) {
await page.screenshot({ path: path.join(output, 'failure.png'), fullPage: true });
await fs.writeFile(path.join(output, 'failure.txt'), `${error.stack}\n${await page.locator('body').innerText()}`);
throw error;
} finally { await browser.close(); }
+122
View File
@@ -0,0 +1,122 @@
// Acceptance against the real built frontend, HTTP adapter, and native eight-branch solver.
import { chromium } from '../../frontend/node_modules/playwright/index.mjs';
import { stripTypeScriptTypes } from 'node:module';
import fs from 'node:fs/promises';
import path from 'node:path';
import assert from 'node:assert/strict';
const [baseURL='http://127.0.0.1:8036', output='test/project-contract-20260912/browser'] = process.argv.slice(2);
await fs.mkdir(output,{recursive:true});
const original=JSON.parse(await fs.readFile('tests/data/test-mql-8-corrected.json','utf8'));
const catalog=await (await fetch(`${baseURL}/api/components/catalog`)).json();
const definitions=catalog.libraries.flatMap(l=>l.components).map(d=>({...d,parameters:Object.fromEntries(d.parameters.map(p=>[p.name,p]))}));
const index=new Map(definitions.map(d=>[d.type,d]));
const browser=await chromium.launch({headless:true});
const page=await browser.newPage({viewport:{width:1600,height:1000},acceptDownloads:true});
page.setDefaultTimeout(30000);
const errors=[];page.on('pageerror',e=>errors.push(String(e)));
const unitsOnly=process.argv.includes('--units-only');
const summary=unitsOnly ? JSON.parse(await fs.readFile(path.join(output,'partial-summary.json'),'utf8')) :
{rows:[],unitCases:[],browser:browser.version(),nodeCount:original.nodes.length};
async function expand(){const button=page.getByRole('button',{name:'展开仿真控制台',exact:true});if(await button.count())await button.click();}
async function importProject(project,name){
await page.locator('input[type="file"][accept*=".json"]').setInputFiles({name:`${name}.json`,mimeType:'application/json',buffer:Buffer.from(JSON.stringify(project))});
await page.waitForFunction(name=>[...document.querySelectorAll('input')].some(input=>input.value===name),name);
}
async function exportJson(filename){
const waiting=page.waitForEvent('download');
await page.getByRole('button',{name:'导出工程 JSON',exact:true}).click();
await(await waiting).saveAs(path.join(output,filename));
return JSON.parse(await fs.readFile(path.join(output,filename),'utf8'));
}
try{
await page.goto(baseURL);
await page.getByRole('button',{name:'运行仿真',exact:true}).waitFor();
if(!unitsOnly){
// Run the exact source function in Chromium, separately from React rendering and XML validation.
let source=await fs.readFile('frontend/src/projectCompatibility.ts','utf8');
source=source.replace(/^import unitTable[^\n]+/m,`const unitTable=${await fs.readFile('schemas/parameter-units.json','utf8')};`);
const js=stripTypeScriptTypes(source);
summary.versionCheck=await page.evaluate(async({js,definitions,original})=>{
const api=await import(`data:text/javascript;base64,${btoa(unescape(encodeURIComponent(js)))}`);
const stats=(fn)=>{const samples=[];for(let i=0;i<100;i++)fn();for(let i=0;i<200;i++){const start=performance.now();for(let j=0;j<10;j++)fn();samples.push((performance.now()-start)/10);}samples.sort((a,b)=>a-b);return{medianMs:samples[100],p95Ms:samples[190]};};
const start=performance.now();api.componentIndex(definitions);const coldIndexMs=performance.now()-start;
return{coldIndexMs,cases:[157,1000,10000].map(count=>{
const current=Array.from({length:count},(_,i)=>({...original.nodes[i%original.nodes.length],id:`node-${i}`}));
const old=current.map(n=>({...n,data:{...n.data,modelVersion:'0.0.1'}}));
return{count,current:stats(()=>api.componentVersionNotices(current,definitions)),old:stats(()=>api.componentVersionNotices(old,definitions)),oldWithMessage:stats(()=>api.componentVersionWarning(old,definitions,true))};
})};
},{js,definitions,original});
await fs.writeFile(path.join(output,'version-check.json'),JSON.stringify(summary.versionCheck,null,2));
// Full eight-branch run and result persistence; repeat with old model versions, then exported v2.
let baseline=null;
const stale=structuredClone(original);stale.nodes.forEach(n=>n.data.modelVersion='0.0.1');
const projects=[['current',original],['old',stale]];
for(let i=0;i<3;i++){
const [name,project]=projects[i];
await page.getByRole('tab',{name:'建模',exact:true}).click();
const importedAt=performance.now();await importProject(project,name);const importMs=performance.now()-importedAt;
await expand();
const consolePanel=page.getByRole('complementary',{name:'仿真控制台',exact:true});
if(name==='old')assert((await consolePanel.innerText()).includes('工程包含旧版或版本未知的组件'));
const previous=await page.evaluate(()=>sessionStorage.getItem('system-simulation-flow:latest-result'));
let xml;
const requestPromise=page.waitForRequest(r=>r.url().endsWith('/api/system-xml/simulate-stream'));
const responsePromise=page.waitForResponse(r=>r.url().endsWith('/api/system-xml/simulate-stream'),{timeout:180000});
const started=performance.now();await page.getByRole('button',{name:'运行仿真',exact:true}).click();
xml=(await requestPromise).postData();
const response=await responsePromise;await response.finished();assert(response.ok());
const receiveMs=performance.now()-started;
await page.waitForFunction(previous=>{const current=sessionStorage.getItem('system-simulation-flow:latest-result');return current&&current!==previous&&JSON.parse(current).storage==='indexeddb';},previous,{timeout:180000});
const savedMs=performance.now()-started;
await page.getByRole('tab',{name:/^结果/}).click();
const resultDownload=page.waitForEvent('download');await page.getByRole('button',{name:'下载结果文件',exact:true}).click();
const resultFile=path.join(output,`${name}.simresult`);await(await resultDownload).saveAs(resultFile);
const result=JSON.parse(await fs.readFile(resultFile,'utf8')).snapshot.result;
assert(result.success,result.message);assert.equal(result.simulatedUntil,original.simulation.t_stop);
if(!baseline)baseline=result;
else {
let differences=0;
assert.equal(Object.keys(result.series).length,Object.keys(baseline.series).length);
for(const [key,values] of Object.entries(result.series)) {
assert.equal(values.length,baseline.series[key].length,key);
for(let j=0;j<values.length;j++) if(values[j]!==baseline.series[key][j]) differences++;
}
assert.equal(differences,0,`${name}: ${differences} result values differ`);
}
await fs.writeFile(path.join(output,`${name}.xml`),xml);
summary.rows.push({name,importMs,receiveMs,savedMs,sampleCount:result.series.time.length,identicalSeries:true,native:result.diagnostics.native});
await page.getByRole('tab',{name:'建模',exact:true}).click();await expand();
if(name==='old'){
assert((await consolePanel.innerText()).includes('可能出现仿真失败或仿真结果与实际不符'));
const exported=await exportJson('upgraded-eight.json');assert.equal(exported.projectSchemaVersion,2);
exported.nodes.forEach(n=>assert.equal(n.data.modelVersion,index.get(n.data.componentType).modelVersion));
projects.push(['exported-v2',exported]);
}
}
await fs.writeFile(path.join(output,'partial-summary.json'),JSON.stringify(summary,null,2));
}
// Lightweight real browser + HTTP checks isolate p0 numbers, strings, expression, and degC.
const chamber=definitions.find(d=>d.modelType==='amesim_pnch012');
assert(chamber);
for(const value of [2.5,'2.5','=2.5','2+0.5','sqrt(6.25)']){
const project={projectSchemaVersion:2,name:'units',simulation:{t_start:0,t_stop:0.002,step:0.001,max_step:0.001,method:'BDF'},edges:[],nodes:[{id:'chamber_1',type:'simulationComponent',position:{x:0,y:0},data:{label:'chamber_1',rotation:0,mirrored:false,componentType:chamber.type,modelType:chamber.modelType,modelVersion:chamber.modelVersion,ports:chamber.ports,parameters:{p0:value,T0:'=10+10'},parameterUnits:{p0:'bar',T0:'degC'}}}]};
await importProject(project,`units-${summary.unitCases.length}`);
await page.getByRole('button',{name:'生成系统 XML',exact:true}).click();await expand();
const blocks=page.getByLabel('生成的系统 XML');
const browserXml=await blocks.last().innerText();
const response=await fetch(`${baseURL}/api/reactflow/system-xml`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(project)});
assert(response.ok,await response.clone().text());const httpXml=await response.text();
const read=xml=>[...xml.matchAll(/<Parameter name="([^"]+)" value="([^"]+)"/g)].map(m=>[m[1],Number(m[2])]);
const webParams=Object.fromEntries(read(browserXml));const httpParams=Object.fromEntries(read(httpXml));
assert.equal(webParams.p0,250000);assert.equal(webParams.T0,293.15);assert.deepEqual(webParams,httpParams);
const exported=await exportJson(`units-${summary.unitCases.length}.json`);assert.equal(exported.projectSchemaVersion,2);
summary.unitCases.push({value,siPressure:webParams.p0,siTemperature:webParams.T0,exportedPressure:exported.nodes[0].data.parameters.p0});
}
summary.errors=errors;assert.deepEqual(errors,[]);
await page.screenshot({path:path.join(output,'acceptance.png'),fullPage:true});
await fs.writeFile(path.join(output,'summary.json'),JSON.stringify(summary,null,2));
await fs.rm(path.join(output,'failure.txt'),{force:true});
await fs.rm(path.join(output,'failure.png'),{force:true});
console.log(JSON.stringify(summary,null,2));
}catch(error){await fs.writeFile(path.join(output,'failure.txt'),`${error.stack}\n${await page.locator('body').innerText()}`);await page.screenshot({path:path.join(output,'failure.png'),fullPage:true});throw error;}
finally{await browser.close();}
@@ -0,0 +1,271 @@
"""Rehearse registration in an isolated source tree; never register the demo in production.
Run with the repository Python environment and a fresh --output-dir. The final
sandbox can be served with uvicorn for a real browser check. No API is mocked.
"""
from __future__ import annotations
import argparse
from dataclasses import replace
from hashlib import sha256
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
MODEL = '''from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
from app.simulation.core.ports import PortDefinition
class DemoRamp(AlgebraicComponent):
MODEL_TYPE = 'registration_demo_ramp'
MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.signal('out', nominal_role='output'),)
PARAMETERS = (
ParameterDefinition('offset', 2.0, label='初始值'),
ParameterDefinition('amplitude', 3.0, label='幅值'),
ParameterDefinition('duration', 1.0, label='时长', quantity='time', unit='s',
minimum=0.0, minimum_exclusive=True),
)
RESULT_VARIABLES = (ResultVariableDefinition('y', '输出', 'dimensionless', '', 'signal', 10),)
DISPLAY = ComponentDisplaySpec(label='注册演练斜坡信号', library_id='registration_demo',
category_id='signals', symbol='amesim_step0',
ports=(PortDisplaySpec('out', 'right', order=10),), order=10)
EQUATIONS = ()
def __init__(self, name, medium, *, offset, amplitude, duration):
super().__init__(name)
self.set_parameter_values(dict(offset=offset, amplitude=amplitude, duration=duration))
self.offset, self.amplitude, self.duration = offset, amplitude, duration
self.out = self.register_declared_port('out')
@classmethod
def create(cls, *, name, medium, parameters):
return cls(name, medium, **parameters)
'''
LIBRARY = '''from app.simulation.core.catalog import ComponentLibrarySpec, ComponentCategorySpec
LIBRARY = ComponentLibrarySpec(id='registration_demo', label='组件注册演练库',
version='1.0.0', source_package='app.simulation.components.registration_demo',
categories=(ComponentCategorySpec(id='signals', label='信号元件', order=10),),
models=('app.simulation.components.registration_demo.signals.ramp:DemoRamp',), order=300)
'''
STAGES = ['unlisted', 'catalog_only', 'native_discovery_only', 'contract_only',
'lowered_without_module_export', 'complete']
def write(root, name, value):
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value, encoding='utf-8', newline='\n')
def edit(root, name, before, after):
path = root / name
value = path.read_text(encoding='utf-8')
if value.count(before) != 1:
raise RuntimeError(f'Rehearsal source anchor changed: {name}: {before!r}')
write(root, name, value.replace(before, after))
def worker(sandbox, output, stage):
sys.path.insert(0, str(sandbox))
from app.simulation.registry import COMPONENT_MODEL_REGISTRY, build_component_catalog
from app.simulation.native_codegen.compiler import compile_native_program
from app.simulation.native_codegen.build import build_native
from app.simulation.native_codegen.input import project_xml
from app.simulation.native_codegen.runner import execute_native
from app.simulation.backends import simulation_config
from app.main import compile_system_xml_network
from app.system_xml import validate_system_xml_document
record = {'stage': stage, 'registered': 'registration_demo_ramp' in COMPONENT_MODEL_REGISTRY}
if stage == 'unlisted':
assert not record['registered']
return record
catalog = build_component_catalog()
model = next(c for lib in catalog['libraries'] for c in lib['components']
if c['modelType'] == 'registration_demo_ramp')
record['catalog'] = model
project = {'projectSchemaVersion': 1, 'name': 'registration-demo',
'simulation': {'t_start': 0, 't_stop': 1, 'step': .1, 'max_step': .05, 'method': 'BDF'},
'edges': [], 'nodes': [{'id': 'ramp_1', 'type': 'simulationComponent',
'position': {'x': 200, 'y': 200}, 'data': {'label': '注册演练斜坡信号',
'componentType': model['type'], 'modelType': model['modelType'],
'modelVersion': model['modelVersion'], 'symbol': model['symbol'],
# Project snapshots use the browser's normalized port shape, not raw catalog objects.
'ports': [{k: v for k, v in port.items()
if k in ('name', 'kind', 'domain', 'nominalRole', 'side')
or (k == 'positiveFlowDirection' and v == 'intoComponent')}
for port in model['ports']],
'parameters': {p['name']: p['default'] for p in model['parameters']},
'parameterUnits': {'duration': 's'}, 'rotation': 0, 'mirrored': False}}]}
xml = project_xml(project)
report = validate_system_xml_document(xml)
assert report.valid, report.as_dict()
write(output, 'project.json', json.dumps(project, ensure_ascii=False, indent=2))
write(output, 'catalog.json', json.dumps(catalog, ensure_ascii=False, indent=2))
(output / 'input.xml').write_bytes(xml)
# The browser requires all displayed ports to be connected, unlike the
# backend's warning-only standalone signal fixture.
import copy
browser_project = copy.deepcopy(project)
for index, (kind, name) in enumerate((('amesim_forc', 'force_1'),
('amesim_mecmas21', 'mass_1'),
('amesim_f000', 'zero_1')), 1):
item = next(c for lib in catalog['libraries'] for c in lib['components'] if c['modelType'] == kind)
node = copy.deepcopy(project['nodes'][0])
node.update(id=name, position={'x': 200 + index * 220, 'y': 200})
node['data'].update(label=name, componentType=kind, modelType=kind,
modelVersion=item['modelVersion'], symbol=item['symbol'],
ports=[{k: v for k, v in port.items()
if k in ('name', 'kind', 'domain', 'nominalRole', 'side')
or (k == 'positiveFlowDirection' and v == 'intoComponent')}
for port in item['ports']],
parameters={param['name']: param['default'] for param in item['parameters']},
parameterUnits={})
if kind == 'amesim_mecmas21':
node['data']['parameters'].update(stoptype=4, useFriction=1, mass=10, x0=0, v0=0)
browser_project['nodes'].append(node)
for index, (a, pa, b, pb) in enumerate((('ramp_1', 'out', 'force_1', 'res'),
('force_1', 'port_2', 'mass_1', 'port_2'), ('mass_1', 'port_1', 'zero_1', 'port_1'))):
browser_project['edges'].append(dict(id=f'edge_{index}', source=a, sourceHandle=pa,
target=b, targetHandle=pb, data={'isContactEdge': False}))
browser_report = validate_system_xml_document(project_xml(browser_project))
assert browser_report.valid, browser_report.as_dict()
write(output, 'browser-project.json', json.dumps(browser_project, ensure_ascii=False, indent=2))
network = compile_system_xml_network(report.document)
try:
program = compile_native_program(network)
record['generated'] = True
build = build_native(program, cache_dir=output / 'cache')
except (ValueError, RuntimeError) as exc:
record['error'] = str(exc)
if stage == 'complete':
raise
expected = {'catalog_only': 'no native contract',
'native_discovery_only': 'version does not match',
'contract_only': 'output mapping incomplete',
'lowered_without_module_export': 'undefined reference'}[stage]
assert expected.lower() in str(exc).lower(), str(exc)
return record
try:
assert stage == 'complete'
assert not build.cache_hit
assert 'demo_signal' in build.details['selectedModules']
record['firstCompleteBuild'] = build.details # Earlier failed link may warm shared objects.
record['runs'] = []
for method in ('RK45', 'BDF'):
config = replace(simulation_config(report.document.simulation), method=method)
result = execute_native(build, config, .1, run_dir=output / method)
assert result['success'] and result['simulatedUntil'] == 1
errors = [abs(y - (2 + 3*t)) for t, y in zip(result['series']['time'], result['series']['ramp_1.y'])]
assert max(errors) < 1e-12
assert result['series']['ramp_1.y'] == result['series']['ramp_1.out.signal']
record['runs'].append({'method': method, 'samples': len(errors), 'maxAnalyticError': max(errors)})
cached = build_native(program, cache_dir=output / 'cache')
try:
assert cached.cache_hit and cached.details['objectCompilations'] == 0
record['warmBuild'] = cached.details
finally:
cached.close()
project['nodes'][0]['data']['parameters']['duration'] = 2
changed_report = validate_system_xml_document(project_xml(project))
changed = compile_native_program(compile_system_xml_network(changed_report.document))
new_build = build_native(changed, cache_dir=output / 'cache')
try:
assert new_build.details['objectCompilations'] == 1
assert new_build.details['objectCacheHits'] > 0
result = execute_native(new_build, config, .1, run_dir=output / 'parameter-change')
assert result['success'] and abs(result['final']['ramp_1.y'] - 3.5) < 1e-12
record['parameterBuild'] = new_build.details
finally:
new_build.close()
invalid = {'wrong_version': xml.replace(b'modelVersion="1.0.0"', b'modelVersion="9.0.0"')}
# Build semantic mutations from the parsed tree; serializer whitespace
# and float spellings are not an input contract.
import xml.etree.ElementTree as ET
tree = ET.fromstring(xml)
comp = tree.find('./Components/Component')
duration = comp.find("Parameter[@name='duration']")
duration.set('value', '0')
invalid['zero_duration'] = ET.tostring(tree)
comp.remove(duration)
invalid['missing_parameter'] = ET.tostring(tree)
record['rejections'] = {}
for name, payload in invalid.items():
invalid_report = validate_system_xml_document(payload)
assert not invalid_report.valid, name
record['rejections'][name] = invalid_report.as_dict()
record['sourceSha256'] = sha256(program.source.encode()).hexdigest()
return record
finally:
build.close()
def prepare(output):
output.mkdir(parents=True, exist_ok=False)
sandbox = output / 'sandbox'
for name in ('app', 'native', 'schemas'):
shutil.copytree(ROOT / name, sandbox / name,
ignore=shutil.ignore_patterns('__pycache__', '*.pyc', 'data'))
if (ROOT / 'frontend/dist').exists():
shutil.copytree(ROOT / 'frontend/dist', sandbox / 'frontend/dist')
write(sandbox, 'app/simulation/components/registration_demo/__init__.py', '')
write(sandbox, 'app/simulation/components/registration_demo/signals/__init__.py', '')
write(sandbox, 'app/simulation/components/registration_demo/signals/ramp.py', MODEL)
write(sandbox, 'app/simulation/components/registration_demo/library.py', LIBRARY)
rows = []
for stage in STAGES:
if stage == 'catalog_only':
edit(sandbox, 'app/simulation/registry.py', 'ENABLED_COMPONENT_LIBRARIES = (',
'ENABLED_COMPONENT_LIBRARIES = (\n "app.simulation.components.registration_demo.library:LIBRARY",')
elif stage == 'native_discovery_only':
edit(sandbox, 'app/simulation/native_codegen/extended.py', 'for entry in (*a.models, *e.models):',
'from app.simulation.components.registration_demo.library import LIBRARY as demo\n for entry in (*a.models, *e.models, *demo.models):')
elif stage == 'contract_only':
edit(sandbox, 'app/simulation/native_codegen/contracts.py', 'SUPPORTED_VERSIONS = {',
"SUPPORTED_VERSIONS = {\n 'registration_demo_ramp': '1.0.0',")
elif stage == 'lowered_without_module_export':
edit(sandbox, 'app/simulation/native_codegen/extended.py', " elif c.model_type == 'amesim_ud00':",
" elif c.model_type == 'registration_demo_ramp':\n put(c, 'y', f'native_demo_ramp(t,{num(c.offset)},{num(c.amplitude)},{num(c.duration)})')\n elif c.model_type == 'amesim_ud00':")
edit(sandbox, 'native/include/kernels.h', '#include <stddef.h>',
'#include <stddef.h>\ndouble native_demo_ramp(double t, double offset, double amplitude, double duration);')
write(sandbox, 'native/components/modules/demo_signal.c',
'#include "kernels.h"\ndouble native_demo_ramp(double t, double offset, double amplitude, double duration) { return offset + amplitude * t / duration; }\n')
elif stage == 'complete':
edit(sandbox, 'app/simulation/native_codegen/modules.py', 'EXPORTS = {',
"EXPORTS = {\n 'demo_signal': frozenset({'native_demo_ramp'}),")
edit(sandbox, 'native/components/kernels.c', '#define NATIVE_COMPONENT_AMALGAMATION 1',
'#define NATIVE_COMPONENT_AMALGAMATION 1\n#include "../components/modules/demo_signal.c"')
edit(sandbox, 'app/simulation/native_codegen/jacobian.py', '_FUNCTIONS = frozenset({',
"_FUNCTIONS = frozenset({\n 'native_demo_ramp',")
record_path = output / f'{stage}.json'
proc = subprocess.run([sys.executable, '-B', str(Path(__file__).resolve()),
'--worker', stage, '--sandbox', str(sandbox), '--output-dir', str(output)],
capture_output=True, text=True, encoding='utf-8', timeout=180)
write(output, f'{stage}.log', proc.stdout + proc.stderr)
if proc.returncode:
raise RuntimeError(f'{stage} failed; see {output / (stage + ".log")}')
rows.append(json.loads(record_path.read_text(encoding='utf-8')))
summary = {'baselineHead': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(),
'platform': sys.platform, 'windowsExecuted': os.name == 'nt', 'stages': rows,
'exampleScope': 'Stateless signal source, third library, new C module, existing symbol and parameter controls. No new physical domain, dynamic ports or nontrivial Jacobian coloring.',
'sandbox': str(sandbox), 'productionModelRegistered': False}
write(output, 'summary.json', json.dumps(summary, ensure_ascii=False, indent=2))
print(json.dumps({'output': str(output), 'stages': len(rows), 'complete': True}))
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--output-dir', type=Path, required=True)
parser.add_argument('--worker', choices=STAGES)
parser.add_argument('--sandbox', type=Path)
args = parser.parse_args()
out = args.output_dir.resolve()
if args.worker:
result = worker(args.sandbox.resolve(), out, args.worker)
write(out, f'{args.worker}.json', json.dumps(result, ensure_ascii=False, indent=2))
else:
prepare(out)
+3 -1
View File
@@ -37,7 +37,9 @@ class AsgiClient:
await self.application(scope,receive,send)
status = next(m['status'] for m in messages if m['type']=='http.response.start')
body = b''.join(m.get('body',b'') for m in messages if m['type']=='http.response.body')
return SimpleNamespace(status_code=status,content=body,json=lambda:json.loads(body))
response_headers = next(m.get("headers", []) for m in messages if m["type"] == "http.response.start")
return SimpleNamespace(status_code=status,content=body,json=lambda:json.loads(body),
headers={k.decode():v.decode() for k,v in response_headers})
return asyncio.run(run())
+151
View File
@@ -0,0 +1,151 @@
from copy import deepcopy
import json
from pathlib import Path
import unittest
from urllib.parse import unquote
from xml.etree import ElementTree as ET
from tests.test_native_result_transport import AsgiClient
from app.main import app, ReactFlowProjectPayload, build_reactflow_system_xml
from app.project_parameters import expression_value, prepare_project
from app.simulation.native_codegen.input import project_xml
from tests.test_pressure_units import pressure_project
ROOT = Path(__file__).resolve().parents[1]
def param(xml, name="p0"):
return float(ET.fromstring(xml).find(f"./Components/Component[@id='chamber_1']/Parameter[@name='{name}']").get("value"))
class ProjectInputContractTests(unittest.TestCase):
def test_shared_expression_grammar(self):
cases = json.loads((ROOT / "tests/fixtures/parameter-expressions.json").read_text())
for source, expected in cases["valid"]:
with self.subTest(source=source):
self.assertAlmostEqual(expression_value(source), expected, places=12)
for source in cases["invalid"] + ["(" * 34 + "1" + ")" * 34, "1+" * 256 + "1", "1" * 513, "min(" + ",".join(["1"] * 17) + ")"]:
with self.subTest(source=source):
with self.assertRaises(ValueError):
expression_value(source)
def test_v2_numeric_string_and_expression_have_same_si_value_on_http_and_cli(self):
client = AsgiClient(app)
for unit, magnitude in (("bar", 2.5), ("kPa", 250), ("MPa", .25), ("Pa", 250000)):
for value in (magnitude, str(magnitude), f"={magnitude}", f"sqrt({magnitude}^2)"):
with self.subTest(unit=unit, value=value):
project = pressure_project(value, unit)
project["projectSchemaVersion"] = 2
original = deepcopy(project)
xml = project_xml(project)
response = client.post("/api/reactflow/system-xml", content=json.dumps(project).encode(), headers={"content-type": "application/json"})
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.content, xml)
self.assertAlmostEqual(param(xml), 250000)
self.assertEqual(project, original)
def test_affine_temperature_and_si_defaults_are_not_double_converted(self):
for value in (20, "20", "=10+10"):
project = pressure_project(2.5)
project["projectSchemaVersion"] = 2
data = project["nodes"][0]["data"]
data["parameters"]["T0"] = value
data["parameterUnits"]["T0"] = "degC"
self.assertEqual(param(project_xml(project), "T0"), 293.15)
del data["parameters"]["T0"]
# Omitted defaults remain defined in SI, independent of display metadata.
self.assertEqual(param(project_xml(project), "T0"), 293.15)
def test_old_versions_warn_at_adapter_but_core_remains_strict(self):
client = AsgiClient(app)
for version in (None, "0.0.1", "99.0.0"):
project = pressure_project(250000)
project["nodes"][0]["data"]["modelVersion"] = version
original = deepcopy(project)
response = client.post("/api/reactflow/system-xml", content=json.dumps(project).encode(), headers={"content-type": "application/json"})
self.assertEqual(response.status_code, 200, response.content)
warning = json.loads(unquote(response.headers["x-component-version-warnings"]))
self.assertEqual(warning["components"][0]["storedVersion"], version)
self.assertEqual(param(response.content), 250000)
self.assertEqual(project, original)
with self.assertRaisesRegex(ValueError, "MODEL_VERSION"):
build_reactflow_system_xml(ReactFlowProjectPayload.model_validate(project))
def test_compile_http_adapter_uses_current_version_and_si(self):
client = AsgiClient(app)
project = pressure_project("sqrt(6.25)")
project["projectSchemaVersion"] = 2
project["nodes"][0]["data"]["modelVersion"] = "0.0.1"
response = client.post("/api/reactflow/compile-model", content=json.dumps(project).encode(),
headers={"content-type": "application/json"})
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["warnings"][0]["components"][0]["storedVersion"], "0.0.1")
def test_header_warning_stays_bounded_for_large_old_projects(self):
data = json.loads((ROOT / "tests/data/test-mql-8-corrected.json").read_text())
for node in data["nodes"]:
node["data"]["modelVersion"] = "0.0.1"
response = AsgiClient(app).post("/api/reactflow/system-xml", content=json.dumps(data).encode(),
headers={"content-type": "application/json"})
self.assertEqual(response.status_code, 200, response.content)
header = response.headers["x-component-version-warnings"]
self.assertLessEqual(len(header), 3800)
self.assertEqual(json.loads(unquote(header))["totalCount"], len(data["nodes"]))
def test_old_version_does_not_bypass_structural_or_parameter_errors(self):
client = AsgiClient(app)
for change in ("type", "port", "parameter", "range", "unit", "discrete"):
project = pressure_project(250000)
data = project["nodes"][0]["data"]
data["modelVersion"] = "0.0.1"
if change == "type": data["componentType"] = "tank"
if change == "port": data["ports"][0]["name"] = "unknown"
if change == "parameter": data["parameters"]["unknown"] = 1
if change == "range": data["parameters"]["p0"] = -1
if change == "unit": data["parameterUnits"]["p0"] = "psi"
if change == "discrete": data["parameters"]["gi"] = "=0"
with self.subTest(change=change):
self.assertEqual(client.post("/api/reactflow/system-xml", content=json.dumps(project).encode(), headers={"content-type": "application/json"}).status_code, 400)
def test_strict_execution_boundary_rejects_expressions_and_nonfinite(self):
for value in ("=2.5", "250000", float("inf"), float("nan"), True):
with self.subTest(value=value):
with self.assertRaisesRegex(ValueError, "finite SI number"):
build_reactflow_system_xml(ReactFlowProjectPayload.model_validate(pressure_project(value)))
project = ReactFlowProjectPayload.model_validate(pressure_project(2.5))
project.projectSchemaVersion = 2
with self.assertRaisesRegex(ValueError, "normalized SI"):
build_reactflow_system_xml(project)
def test_simulation_expressions_are_also_normalized_before_execution(self):
project = ReactFlowProjectPayload.model_validate(pressure_project(250000))
project.simulation.t_stop = "=1/500"
normalized, _ = prepare_project(project)
self.assertEqual(normalized.simulation.t_stop, .002)
self.assertEqual(project.simulation.t_stop, "=1/500")
with self.assertRaisesRegex(ValueError, "finite SI"):
build_reactflow_system_xml(project)
def test_legacy_lmechn1_does_not_guess_a_missing_dynamic_port_count(self):
data = json.loads((ROOT / "tests/data/test-mql-8-corrected.json").read_text())
legacy = next(n for n in data["nodes"] if n["data"]["modelType"] == "amesim_lmechn1")
legacy["data"]["modelVersion"] = "0.1.0"
legacy["data"]["ports"] = legacy["data"]["ports"][:9]
del legacy["data"]["parameters"]["v1"]
normalized, notices = prepare_project(ReactFlowProjectPayload.model_validate(data))
self.assertTrue(notices)
with self.assertRaisesRegex(ValueError, "port names"):
build_reactflow_system_xml(normalized)
def test_eight_branch_legacy_project_stays_unchanged(self):
data = json.loads((ROOT / "tests/data/test-mql-8-corrected.json").read_text())
original = deepcopy(data)
normalized, warnings = prepare_project(ReactFlowProjectPayload.model_validate(data))
self.assertFalse(warnings)
self.assertEqual(data, original)
for source, target in zip(data["nodes"], normalized.nodes):
for name, value in source["data"]["parameters"].items():
if isinstance(value, (int, float)):
self.assertEqual(value, target.data.parameters[name])
self.assertIn(b'unitSystem="SI"', build_reactflow_system_xml(normalized))
+2 -2
View File
@@ -98,7 +98,7 @@ class ReactFlowProjectSchemaTests(unittest.TestCase):
execute(project)
def test_unsupported_project_version_is_rejected(self) -> None:
for version in (0, 2, -1, "1"):
for version in (0, 3, -1, "1"):
with self.subTest(version=version):
with self.assertRaises(ValueError):
medium_project(projectSchemaVersion=version)
@@ -209,7 +209,7 @@ class ReactFlowProjectSchemaTests(unittest.TestCase):
storage = Path(directory)
invalid_cases = {
"corrupt": "{not-json",
"future": json.dumps({"projectSchemaVersion": 2}),
"future": json.dumps({"projectSchemaVersion": 3}),
}
with patch("app.main.PROJECT_STORAGE_DIR", storage):
for project_id, text in invalid_cases.items():