diff --git a/README.md b/README.md index a014a55..6c86118 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/main.py b/app/main.py index dcfef69..00d41c2 100644 --- a/app/main.py +++ b/app/main.py @@ -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.") diff --git a/app/project_parameters.py b/app/project_parameters.py new file mode 100644 index 0000000..51ffaba --- /dev/null +++ b/app/project_parameters.py @@ -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} diff --git a/app/simulation/components/example.md b/app/simulation/components/example.md deleted file mode 100644 index 1571ebd..0000000 --- a/app/simulation/components/example.md +++ /dev/null @@ -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 数值实现。 diff --git a/app/simulation/native_codegen/input.py b/app/simulation/native_codegen/input.py index 7739918..d07e98b 100644 --- a/app/simulation/native_codegen/input.py +++ b/app/simulation/native_codegen/input.py @@ -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): diff --git a/docs/other/2026-09-12-project-contract-acceptance.md b/docs/other/2026-09-12-project-contract-acceptance.md new file mode 100644 index 0000000..79cf8b8 --- /dev/null +++ b/docs/other/2026-09-12-project-contract-acceptance.md @@ -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 始终未启动。 diff --git a/docs/standard/README.md b/docs/standard/README.md new file mode 100644 index 0000000..3bbaf0e --- /dev/null +++ b/docs/standard/README.md @@ -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)。端口快照精简及其他求解优化未纳入此改动。 diff --git a/docs/standard/backend-interface-version-spec-v1.md b/docs/standard/backend-interface-version-spec-v1.md index a0b2569..981b8b3 100644 --- a/docs/standard/backend-interface-version-spec-v1.md +++ b/docs/standard/backend-interface-version-spec-v1.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`。 diff --git a/docs/standard/component-library-spec-v1.md b/docs/standard/component-library-spec-v1.md index 385287f..c1ff2b6 100644 --- a/docs/standard/component-library-spec-v1.md +++ b/docs/standard/component-library-spec-v1.md @@ -1,8 +1,12 @@ # 组件库分类、发现与读取规范 v1 -状态:已在 `experimental` 临时组件库实施 -适用范围:`app/simulation/components`、组件注册中心、System XML 和 React Flow 组件库 -当前试验库:`experimental`(仅用于注册契约验证,不在前端组件库中显示) +文档版本:1.2.0 +修订日期:2026-09-12 +核对代码基线:`22579e5` 加本次输入合同实现;配套工程 JSON v2,XML v3 和模型版本不变。 + +状态:已在 `experimental` 与 `amesim` 组件库实施;本版按注册演练校正 +适用范围:`app/simulation/components`、组件注册中心、System XML 和 React Flow 组件库 +当前启用库:`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 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。删除生成目录即可清理演练源码、结果与缓存;生产组件及现行规范不依赖这些生成物。 diff --git a/docs/standard/component-registration-workflow-v1.md b/docs/standard/component-registration-workflow-v1.md new file mode 100644 index 0000000..169a544 --- /dev/null +++ b/docs/standard/component-registration-workflow-v1.md @@ -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///`,参照同类模型。现有代码存在一个文件容纳多个相关型号的情况;“每模型单文件”是整理建议,不是注册器的硬性要求。 + +公开模型类必须在自己的类体显式声明: + +```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)。后续代码架构变更时更新对应专项规范及本流程;不在文档中维护第二份可执行型号参数表。 diff --git a/docs/standard/native-evaluation-schedule.md b/docs/standard/native-evaluation-schedule.md index 7e77a9c..bacca3a 100644 --- a/docs/standard/native-evaluation-schedule.md +++ b/docs/standard/native-evaluation-schedule.md @@ -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/` 保存可复用编译单元(cacheVersion 1),`models/` 保存完整模型程序及合同(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)。 diff --git a/docs/standard/optimization-benchmark-model.md b/docs/standard/optimization-benchmark-model.md index bee909b..16d3d26 100644 --- a/docs/standard/optimization-benchmark-model.md +++ b/docs/standard/optimization-benchmark-model.md @@ -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,并保留事件点,不能以图表截图代替原始数据。 diff --git a/docs/standard/port-computation-contract.md b/docs/standard/port-computation-contract.md index 9198498..a592bd7 100644 --- a/docs/standard/port-computation-contract.md +++ b/docs/standard/port-computation-contract.md @@ -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 警告、原生模型的必接要求及前端错误提示应分别测试,不能以一个入口的允许行为推断其他入口。 diff --git a/docs/standard/system-xml-v3.md b/docs/standard/system-xml-v3.md index 5244c66..a72d4aa 100644 --- a/docs/standard/system-xml-v3.md +++ b/docs/standard/system-xml-v3.md @@ -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 @@ -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 @@ -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. 旧版本处理边界 diff --git a/docs/standard/跨平台交付约定.md b/docs/standard/跨平台交付约定.md index d88e03b..54e2be8 100644 --- a/docs/standard/跨平台交付约定.md +++ b/docs/standard/跨平台交付约定.md @@ -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 实机结果。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 93358ff..25d14c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 & { +type ExecutableProjectPayload = Omit & { + 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 = { - 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 = 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(); + 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, ); diff --git a/frontend/src/projectCompatibility.ts b/frontend/src/projectCompatibility.ts new file mode 100644 index 0000000..5cf8ba9 --- /dev/null +++ b/frontend/src/projectCompatibility.ts @@ -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>; +type Parameter = { unit?: string; quantity?: string }; +type Definition = { type: string; modelType: string; modelVersion: string; parameters: Record }; +type Node = { id: string; data: { label: string; componentType: string; modelType: string; modelVersion?: string; + parameters: Record; parameterUnits?: Record } }; +type Project = { projectSchemaVersion: 1 | 2; nodes: Node[] }; + +const indices = new WeakMap>(); +export function componentIndex(definitions: readonly D[]): Map { + let index = indices.get(definitions); + if (!index) { + index = new Map(definitions.map(definition => [definition.type, definition])); + indices.set(definitions, index); + } + return index as Map; +} + +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

(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

(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; +} diff --git a/frontend/tests/e2e/component-symbols.spec.ts b/frontend/tests/e2e/component-symbols.spec.ts index 38a56e3..93dd722 100644 --- a/frontend/tests/e2e/component-symbols.spec.ts +++ b/frontend/tests/e2e/component-symbols.spec.ts @@ -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 { diff --git a/frontend/tests/e2e/parameter-table.spec.ts b/frontend/tests/e2e/parameter-table.spec.ts index feb939d..e42845b 100644 --- a/frontend/tests/e2e/parameter-table.spec.ts +++ b/frontend/tests/e2e/parameter-table.spec.ts @@ -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 }) => { diff --git a/frontend/tests/e2e/pressure-units.spec.ts b/frontend/tests/e2e/pressure-units.spec.ts index 6b2151e..5904000 100644 --- a/frontend/tests/e2e/pressure-units.spec.ts +++ b/frontend/tests/e2e/pressure-units.spec.ts @@ -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"); }); diff --git a/frontend/tests/e2e/project-input-contract.spec.ts b/frontend/tests/e2e/project-input-contract.spec.ts new file mode 100644 index 0000000..e0f85d0 --- /dev/null +++ b/frontend/tests/e2e/project-input-contract.spec.ts @@ -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); +}); diff --git a/schemas/parameter-units.json b/schemas/parameter-units.json new file mode 100644 index 0000000..df485ae --- /dev/null +++ b/schemas/parameter-units.json @@ -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"]} +} diff --git a/tests/fixtures/parameter-expressions.json b/tests/fixtures/parameter-expressions.json new file mode 100644 index 0000000..6b60d20 --- /dev/null +++ b/tests/fixtures/parameter-expressions.json @@ -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"] +} diff --git a/tests/manual/benchmark_project_input.py b/tests/manual/benchmark_project_input.py new file mode 100644 index 0000000..7536ba8 --- /dev/null +++ b/tests/manual/benchmark_project_input.py @@ -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() diff --git a/tests/manual/browser_component_registration.mjs b/tests/manual/browser_component_registration.mjs new file mode 100644 index 0000000..fd0b973 --- /dev/null +++ b/tests/manual/browser_component_registration.mjs @@ -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(); } diff --git a/tests/manual/browser_project_contract.mjs b/tests/manual/browser_project_contract.mjs new file mode 100644 index 0000000..2f6380d --- /dev/null +++ b/tests/manual/browser_project_contract.mjs @@ -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&¤t!==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;jassert.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(/[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();} diff --git a/tests/manual/rehearse_component_registration.py b/tests/manual/rehearse_component_registration.py new file mode 100644 index 0000000..eee0ee3 --- /dev/null +++ b/tests/manual/rehearse_component_registration.py @@ -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 ', + '#include \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) diff --git a/tests/test_native_result_transport.py b/tests/test_native_result_transport.py index 1f271a1..535638b 100644 --- a/tests/test_native_result_transport.py +++ b/tests/test_native_result_transport.py @@ -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()) diff --git a/tests/test_project_input_contract.py b/tests/test_project_input_contract.py new file mode 100644 index 0000000..dc9e7f4 --- /dev/null +++ b/tests/test_project_input_contract.py @@ -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)) diff --git a/tests/test_reactflow_project_schema.py b/tests/test_reactflow_project_schema.py index 91e4c13..d78977c 100644 --- a/tests/test_reactflow_project_schema.py +++ b/tests/test_reactflow_project_schema.py @@ -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():