完善建模交互、组件图标与系统协议

This commit is contained in:
ljz committed 2026-08-15 17:40:18 +08:00
1 parent 456c29b3b6
commit 6572defaa4
66 files changed
+9927 -4023

No files matched your search

+6 -7
View File
@@ -5,12 +5,12 @@ ReactFlow 系统建模与 `app.simulation` 仿真后端。
## 后端接口 ## 后端接口
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。 - `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
- `POST /api/reactflow/system-xml`:导出 System XML v2。 - `POST /api/reactflow/system-xml`:导出精简的 System XML v3。
- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。 - `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。
- `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。 - `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。
- `POST /api/reactflow/simulate-test-mql`:返回固定拓扑 AMESim `test_mql` 的结构与采样摘要;132 状态数值对比使用独立 comparison 入口。AMESim 子模型已有 19 个第一版公开模型,但该接口本身不是任意拖拽拓扑求解器。 - `POST /api/reactflow/simulate-test-mql`:返回固定拓扑 AMESim `test_mql` 的结构与采样摘要;132 状态数值对比使用独立 comparison 入口。AMESim 子模型已有 19 个第一版公开模型,但该接口本身不是任意拖拽拓扑求解器。
- `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。 - `POST /api/system-xml/validate`:接收原始 System XML v3,返回 XML、XSD 和模型语义三层诊断。
- `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。 - `POST /api/system-xml/parse`:校验 XML,并返回可直接编译、求解的规范化模型数据;它不还原 ReactFlow 画布布局。
- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 `app.simulation` 组件网络。 - `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 `app.simulation` 组件网络。
- `POST /api/system-xml/simulate`:按 XML 中的组件、连接、参数和仿真设置运行当前支持的气动、标量信号及一维机械网络 MVP,并返回组件及端口时间序列。 - `POST /api/system-xml/simulate`:按 XML 中的组件、连接、参数和仿真设置运行当前支持的气动、标量信号及一维机械网络 MVP,并返回组件及端口时间序列。
- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。 - `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。
@@ -28,11 +28,10 @@ XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境
## 文档 ## 文档
- [开发文档索引](docs/README.md) - [开发文档索引](docs/README.md)
- [后端接口版本与定义规范 v1](docs/backend-interface-version-spec-v1.md)
- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md) - [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md)
- [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md) - [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md)
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json) - [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
- [System XML v2 协议](docs/system-xml-v2.md) - [System XML v3 协议(当前规范)](docs/system-xml-v3.md)
- [System XML v2 XSD](schemas/system-simulation-v2.xsd) - [System XML v3 XSD(当前 Schema)](schemas/system-simulation-v3.xsd)
- [System XML v1 协议(旧版)](docs/system-xml-v1.md)
- [System XML v1 XSD(旧版)](schemas/system-simulation-v1.xsd)
+341 -290
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator, Mapping
import csv import csv
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -19,7 +19,7 @@ from xml.etree import ElementTree as ET
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field, ValidationError
from app.system_xml import ( from app.system_xml import (
SystemXmlDocument, SystemXmlDocument,
@@ -30,16 +30,15 @@ from app.system_xml import (
if TYPE_CHECKING: if TYPE_CHECKING:
from app.simulation.components.amesim.gases import AmesimGasRegistry from app.simulation.components.amesim.gases import AmesimGasRegistry
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.registry import ComponentModelSpec
from app.simulation.systems.network import SimulationNetwork from app.simulation.systems.network import SimulationNetwork
app = FastAPI(title="System Simulation ReactFlow App") app = FastAPI(title="System Simulation ReactFlow App")
FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist" FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects" PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
SYSTEM_XML_SCHEMA_VERSION = "2" SYSTEM_XML_SCHEMA_VERSION = "3"
SYSTEM_XML_UNIT_SYSTEM = "SI" SYSTEM_XML_UNIT_SYSTEM = "SI"
AMESIM_PARAMETER_ENCODING_VERSION = 1
MECMAS21_LEGACY_BINARY_PARAMETERS = ("useFriction", "strib")
SimulationProgressEmitter = Callable[ SimulationProgressEmitter = Callable[
[int, str, str, float | None, float | None], [int, str, str, float | None, float | None],
@@ -103,12 +102,29 @@ class ReactFlowPortDefinition(BaseModel):
side: Literal["left", "right"] = "left" side: Literal["left", "right"] = "left"
class ReactFlowParameterScientificNotation(BaseModel):
text: str
unit: str
class ReactFlowNodeData(BaseModel): class ReactFlowNodeData(BaseModel):
# Editor-only nested metadata is preserved on a storage round trip even
# when a newer frontend adds fields the current backend does not consume.
model_config = ConfigDict(extra="allow")
label: str = "" label: str = ""
componentType: str = "component" componentType: str = "component"
modelType: str = "component" modelType: str = "component"
ports: list[ReactFlowPortDefinition | str] = Field(default_factory=list) # 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.
modelVersion: str | None = None
ports: list[ReactFlowPortDefinition] = Field(default_factory=list)
parameters: dict[str, Any] = Field(default_factory=dict) parameters: dict[str, Any] = Field(default_factory=dict)
parameterUnits: dict[str, str] = Field(default_factory=dict)
parameterScientificNotation: dict[str, ReactFlowParameterScientificNotation] = Field(
default_factory=dict
)
rotation: Literal[0, 90, 180, 270] = 0 rotation: Literal[0, 90, 180, 270] = 0
mirrored: bool = False mirrored: bool = False
@@ -120,12 +136,21 @@ class ReactFlowNodePayload(BaseModel):
data: ReactFlowNodeData data: ReactFlowNodeData
class ReactFlowEdgeData(BaseModel):
model_config = ConfigDict(extra="allow")
isContactEdge: bool = False
class ReactFlowEdgePayload(BaseModel): class ReactFlowEdgePayload(BaseModel):
model_config = ConfigDict(extra="allow")
id: str id: str
source: str source: str
target: str target: str
sourceHandle: str | None = None sourceHandle: str | None = None
targetHandle: str | None = None targetHandle: str | None = None
data: ReactFlowEdgeData = Field(default_factory=ReactFlowEdgeData)
class ReactFlowSimulationConfig(BaseModel): class ReactFlowSimulationConfig(BaseModel):
@@ -137,24 +162,45 @@ class ReactFlowSimulationConfig(BaseModel):
class ReactFlowProjectPayload(BaseModel): class ReactFlowProjectPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
projectSchemaVersion: Literal[1]
name: str = "untitled" name: str = "untitled"
mediumReferenceVersion: Literal[1] | None = None
# 仅用于前端展示布局迁移;求解器和 System XML 不读取该字段。
presentationLayoutVersion: Literal[1] | None = None
# Missing/None is reserved for projects written before canonical AMESim
# option codes were introduced. Every current writer emits version 1.
amesimParameterEncodingVersion: Literal[1] | None = Field(
default=None,
description=(
"Version 1 is required for current writers; omission is accepted "
"only when importing legacy MECMAS21 0/1 option codes."
),
)
nodes: list[ReactFlowNodePayload] = Field(default_factory=list) nodes: list[ReactFlowNodePayload] = Field(default_factory=list)
edges: list[ReactFlowEdgePayload] = Field(default_factory=list) edges: list[ReactFlowEdgePayload] = Field(default_factory=list)
simulation: ReactFlowSimulationConfig = Field(default_factory=ReactFlowSimulationConfig) simulation: ReactFlowSimulationConfig = Field(default_factory=ReactFlowSimulationConfig)
@dataclass(frozen=True)
class SolverComponentInput:
"""One component instance in the editor-independent execution model."""
id: str
model_type: str
model_version: str | None
parameters: Mapping[str, float]
@dataclass(frozen=True)
class SolverConnectionInput:
"""One neutral two-endpoint connection in the execution model."""
id: str | None
endpoint_a_component: str
endpoint_a_port: str
endpoint_b_component: str
endpoint_b_port: str
@dataclass(frozen=True)
class SolverModelInput:
"""Small common contract compiled from either project JSON or System XML."""
name: str
components: tuple[SolverComponentInput, ...]
connections: tuple[SolverConnectionInput, ...]
class SimulationResultVariablePayload(BaseModel): class SimulationResultVariablePayload(BaseModel):
key: str key: str
componentId: str componentId: str
@@ -180,43 +226,15 @@ class SimulationCancellationPayload(BaseModel):
def pydantic_to_jsonable(model: BaseModel) -> dict[str, Any]: def pydantic_to_jsonable(model: BaseModel) -> dict[str, Any]:
if hasattr(model, "model_dump"):
return model.model_dump(mode="json", exclude_none=True) return model.model_dump(mode="json", exclude_none=True)
return model.dict(exclude_none=True)
def reactflow_project_storage_data( def reactflow_project_storage_data(
project: ReactFlowProjectPayload, project: ReactFlowProjectPayload,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return a current, self-identifying project payload for persistence.""" """Serialize a project without changing model or parameter semantics."""
data = pydantic_to_jsonable(project) return pydantic_to_jsonable(project)
if project.amesimParameterEncodingVersion is None:
from app.simulation.registry import get_component_model_spec
stored_nodes = data["nodes"]
for index, node in enumerate(project.nodes):
if node.data.modelType != "amesim_mecmas21":
continue
parameter_definitions = get_component_model_spec(
node.data.modelType
).parameter_by_name
stored_parameters = stored_nodes[index]["data"].setdefault(
"parameters",
{},
)
for name in MECMAS21_LEGACY_BINARY_PARAMETERS:
stored_parameters[name] = canonical_amesim_parameter_value(
project,
node,
name,
node.data.parameters.get(
name,
parameter_definitions[name].default,
),
)
data["amesimParameterEncodingVersion"] = AMESIM_PARAMETER_ENCODING_VERSION
return data
@app.get("/") @app.get("/")
@@ -385,9 +403,7 @@ def simulation_results_csv_filename(project_name: str) -> str:
@app.get("/api/reactflow/projects") @app.get("/api/reactflow/projects")
def list_reactflow_projects() -> dict[str, object]: def list_reactflow_projects() -> dict[str, object]:
PROJECT_STORAGE_DIR.mkdir(parents=True, exist_ok=True) PROJECT_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
projects = [] projects = [
for path in sorted(PROJECT_STORAGE_DIR.glob("*.json")):
projects.append(
{ {
"id": path.stem, "id": path.stem,
"updatedAt": datetime.fromtimestamp( "updatedAt": datetime.fromtimestamp(
@@ -395,7 +411,8 @@ def list_reactflow_projects() -> dict[str, object]:
timezone.utc, timezone.utc,
).isoformat(), ).isoformat(),
} }
) for path in sorted(PROJECT_STORAGE_DIR.glob("*.json"))
]
return {"projects": projects} return {"projects": projects}
@@ -404,7 +421,20 @@ def load_reactflow_project(project_id: str) -> dict[str, object]:
path = reactflow_project_path(project_id) path = reactflow_project_path(project_id)
if not path.exists(): if not path.exists():
raise HTTPException(status_code=404, detail="Project was not found.") raise HTTPException(status_code=404, detail="Project was not found.")
return json.loads(path.read_text(encoding="utf-8")) try:
raw_data = json.loads(path.read_text(encoding="utf-8"))
ReactFlowProjectPayload.model_validate(raw_data)
except (OSError, UnicodeError, json.JSONDecodeError, ValidationError) as exc:
raise HTTPException(
status_code=422,
detail=(
f"Stored project '{path.stem}' does not satisfy project schema 1: "
f"{exc}"
),
) from exc
if not isinstance(raw_data, dict): # Covered by model validation; narrows the type.
raise HTTPException(status_code=422, detail="Stored project must be an object.")
return raw_data
@app.post("/api/reactflow/projects/{project_id}") @app.post("/api/reactflow/projects/{project_id}")
@@ -468,7 +498,7 @@ async def parse_system_xml(request: Request) -> dict[str, object]:
return { return {
"success": True, "success": True,
"validation": report.as_dict(), "validation": report.as_dict(),
"project": document.as_project_data(), "model": document.as_dict(),
} }
@@ -476,11 +506,11 @@ async def parse_system_xml(request: Request) -> dict[str, object]:
async def compile_system_xml_model(request: Request) -> dict[str, object]: async def compile_system_xml_model(request: Request) -> dict[str, object]:
report = validate_system_xml_document(await request.body()) report = validate_system_xml_document(await request.body())
document = _validated_xml_document_or_422(report) document = _validated_xml_document_or_422(report)
project, network = _compile_xml_document_or_422(document) network = _compile_xml_document_or_422(document)
return { return {
"success": True, "success": True,
"validation": report.as_dict(), "validation": report.as_dict(),
"simulation": pydantic_to_jsonable(project.simulation), "simulation": document.as_model_data()["simulation"],
**network.as_interface_dict(), **network.as_interface_dict(),
} }
@@ -665,37 +695,37 @@ def run_system_xml_simulation(
report = validate_system_xml_document(xml_bytes) report = validate_system_xml_document(xml_bytes)
document = _validated_xml_document_or_422(report) document = _validated_xml_document_or_422(report)
emit(0, "compilation", "正在编译组件与连接关系") emit(0, "compilation", "正在编译组件与连接关系")
project, network = _compile_xml_document_or_422(document) network = _compile_xml_document_or_422(document)
emit(0, "initialization", "模型编译完成,正在准备求解器") emit(0, "initialization", "模型编译完成,正在准备求解器")
def report_system_progress(progress: float, phase: str) -> None: def report_system_progress(progress: float, phase: str) -> None:
bounded_progress = min(1.0, max(0.0, progress)) bounded_progress = min(1.0, max(0.0, progress))
simulated_time = project.simulation.t_start + bounded_progress * ( simulated_time = document.simulation.t_start + bounded_progress * (
project.simulation.t_stop - project.simulation.t_start document.simulation.t_stop - document.simulation.t_start
) )
emit( emit(
round(100 * bounded_progress), round(100 * bounded_progress),
phase, phase,
SIMULATION_PHASE_MESSAGES.get(phase, "正在运行仿真"), SIMULATION_PHASE_MESSAGES.get(phase, "正在运行仿真"),
simulated_time, simulated_time,
project.simulation.t_stop, document.simulation.t_stop,
) )
try: try:
system = GenericFluidSystem(network) system = GenericFluidSystem(network)
result = system.simulate( result = system.simulate(
SolveIVPConfig( SolveIVPConfig(
t_start=project.simulation.t_start, t_start=document.simulation.t_start,
t_stop=project.simulation.t_stop, t_stop=document.simulation.t_stop,
method=project.simulation.method, method=document.simulation.method,
# The pressure-flow closure is solved to a scaled 1e-7 # The pressure-flow closure is solved to a scaled 1e-7
# residual. State-specific mechanical absolute tolerances now # residual. State-specific mechanical absolute tolerances now
# keep ideal-stop Jacobian perturbations stable, so the outer # keep ideal-stop Jacobian perturbations stable, so the outer
# integrator can use its canonical 1e-6 relative accuracy. # integrator can use its canonical 1e-6 relative accuracy.
rtol=1.0e-6, rtol=1.0e-6,
max_step=project.simulation.max_step, max_step=document.simulation.max_step,
), ),
sample_step=project.simulation.step, sample_step=document.simulation.sample_step,
progress_callback=report_system_progress, progress_callback=report_system_progress,
cancel_check=cancel_check, cancel_check=cancel_check,
) )
@@ -764,7 +794,7 @@ def run_system_xml_simulation(
return { return {
"validation": report.as_dict(), "validation": report.as_dict(),
"simulation": pydantic_to_jsonable(project.simulation), "simulation": document.as_model_data()["simulation"],
"model": network.as_interface_dict(), "model": network.as_interface_dict(),
**result.as_dict(), **result.as_dict(),
} }
@@ -922,10 +952,9 @@ def _validated_xml_document_or_422(
def _compile_xml_document_or_422( def _compile_xml_document_or_422(
document: SystemXmlDocument, document: SystemXmlDocument,
) -> tuple[ReactFlowProjectPayload, "SimulationNetwork"]: ) -> "SimulationNetwork":
project = ReactFlowProjectPayload(**document.as_project_data())
try: try:
network = compile_reactflow_network(project) network = compile_system_xml_network(document)
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
@@ -941,118 +970,147 @@ def _compile_xml_document_or_422(
], ],
}, },
) from exc ) from exc
return project, network return network
def validate_reactflow_component_contract(
node: ReactFlowNodePayload,
component_spec: "ComponentModelSpec",
) -> dict[str, float]:
"""Validate the persisted model contract before consuming current defaults."""
if (
node.data.componentType != node.data.modelType
or node.data.modelType != component_spec.model_type
):
raise ValueError(
f"COMPONENT_MODEL_TYPE_MISMATCH: Component '{node.id}' declares "
f"componentType '{node.data.componentType}' and modelType "
f"'{node.data.modelType}', expected both to be "
f"'{component_spec.model_type}'."
)
declared_version = node.data.modelVersion
if declared_version is None or not declared_version.strip():
raise ValueError(
f"COMPONENT_MODEL_VERSION_MISSING: Component '{node.id}' must declare "
f"modelVersion '{component_spec.model_version}' before it can execute."
)
if declared_version != component_spec.model_version:
raise ValueError(
f"COMPONENT_MODEL_VERSION_MISMATCH: Component '{node.id}' declares "
f"modelVersion '{declared_version}', expected "
f"'{component_spec.model_version}'."
)
validate_component_port_interface(node, component_spec.ports)
unknown_parameters = set(node.data.parameters) - set(
component_spec.parameter_by_name
)
if unknown_parameters:
raise ValueError(
f"Component '{node.id}' contains unsupported parameters: "
+ ", ".join(sorted(unknown_parameters))
+ "."
)
parameter_values: dict[str, float] = {}
for parameter in component_spec.parameters:
value = parameter_float(node, parameter.name, parameter.default)
validation_message = parameter.validation_message(value)
if validation_message is not None:
raise ValueError(
f"Parameter '{parameter.name}' on component '{node.id}' "
f"{validation_message}."
)
parameter_values[parameter.name] = value
return parameter_values
def validate_reactflow_execution_contract(
project: ReactFlowProjectPayload,
) -> None:
from app.simulation.registry import get_component_model_spec
component_ids: set[str] = set()
for node in project.nodes:
if node.id in component_ids:
raise ValueError(f"COMPONENT_ID_DUPLICATE: {node.id}.")
component_ids.add(node.id)
validate_reactflow_component_contract(
node,
get_component_model_spec(node.data.modelType),
)
def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
from app.simulation.registry import get_component_model_spec from app.simulation.registry import get_component_model_spec
system = ET.Element( system_attributes = {
"System",
{
"name": project.name,
"schemaVersion": SYSTEM_XML_SCHEMA_VERSION, "schemaVersion": SYSTEM_XML_SCHEMA_VERSION,
"unitSystem": SYSTEM_XML_UNIT_SYSTEM, "unitSystem": SYSTEM_XML_UNIT_SYSTEM,
"mediumReferenceVersion": "1", }
"amesimParameterEncodingVersion": str( if project.name.strip():
AMESIM_PARAMETER_ENCODING_VERSION system_attributes["name"] = project.name.strip()
), system = ET.Element("System", system_attributes)
},
)
ET.SubElement( ET.SubElement(
system, system,
"Simulation", "Simulation",
{ {
"tStart": str(project.simulation.t_start), "tStart": str(project.simulation.t_start),
"tStop": str(project.simulation.t_stop), "tStop": str(project.simulation.t_stop),
"step": str(project.simulation.step), "sampleStep": str(project.simulation.step),
"maxStep": str(project.simulation.max_step), "maxStep": str(project.simulation.max_step),
"method": project.simulation.method, "method": project.simulation.method,
}, },
) )
components_node = ET.SubElement(system, "Components") components_node = ET.SubElement(system, "Components")
connections_node = ET.SubElement(system, "Connections") connections_node = ET.SubElement(system, "Connections")
port_index: dict[tuple[str, str], ReactFlowPortDefinition] = {} port_index: dict[tuple[str, str], "PortDefinition"] = {}
component_ids: set[str] = set()
connection_ids: set[str] = set()
for node in project.nodes: for node in project.nodes:
if node.id in component_ids:
raise ValueError(f"COMPONENT_ID_DUPLICATE: {node.id}.")
component_ids.add(node.id)
component_spec = get_component_model_spec(node.data.modelType)
parameter_values = validate_reactflow_component_contract(
node,
component_spec,
)
component_node = ET.SubElement( component_node = ET.SubElement(
components_node, components_node,
"Component", "Component",
{ {
"id": node.id, "id": node.id,
"name": node.data.label,
"type": node.data.modelType, "type": node.data.modelType,
"componentType": node.data.componentType, "modelVersion": node.data.modelVersion or "",
"x": f"{node.position.x:g}",
"y": f"{node.position.y:g}",
"rotation": str(node.data.rotation),
"mirrored": str(node.data.mirrored).lower(),
}, },
) )
for index, port in enumerate(node.data.ports): for port_definition in component_spec.ports:
port_definition = normalize_port_definition(
port,
index=index,
component_type=node.data.componentType,
)
port_key = (node.id, port_definition.name) port_key = (node.id, port_definition.name)
if port_key in port_index: if port_key in port_index:
raise ValueError( raise ValueError(
f"Component {node.id} contains duplicate port {port_definition.name}." f"Component {node.id} contains duplicate port {port_definition.name}."
) )
port_index[port_key] = port_definition port_index[port_key] = port_definition
port_attributes = {
"name": port_definition.name,
"kind": port_definition.kind,
"domain": port_definition.domain,
"nominalRole": port_definition.nominalRole,
"side": port_definition.side,
}
if port_definition.kind == "physical":
port_attributes["positiveFlowDirection"] = (
port_definition.positiveFlowDirection or "intoComponent"
)
ET.SubElement(component_node, "Port", port_attributes)
parameter_values = {
name: canonical_amesim_parameter_value(project, node, name, value)
for name, value in node.data.parameters.items()
}
try:
component_spec = get_component_model_spec(node.data.modelType)
except ValueError:
component_spec = None
if (
component_spec is not None
and project.amesimParameterEncodingVersion is None
and node.data.modelType == "amesim_mecmas21"
):
parameter_definitions = {
parameter.name: parameter for parameter in component_spec.parameters
}
for name in MECMAS21_LEGACY_BINARY_PARAMETERS:
if name not in parameter_values:
parameter_values[name] = canonical_amesim_parameter_value(
project,
node,
name,
parameter_definitions[name].default,
)
if (
component_spec is not None
and component_spec.display.role == "amesimGasMediumDefinition"
):
for parameter in component_spec.parameters:
if parameter.editor == "amesimGasPropertyModel":
parameter_values.setdefault(parameter.name, parameter.default)
for name, value in parameter_values.items(): for parameter in component_spec.parameters:
ET.SubElement( ET.SubElement(
component_node, component_node,
"Parameter", "Parameter",
{"name": name, "value": str(value)}, {
"name": parameter.name,
"value": str(parameter_values[parameter.name]),
},
) )
for edge in project.edges: for edge in project.edges:
if edge.id in connection_ids:
raise ValueError(f"CONNECTION_ID_DUPLICATE: {edge.id}.")
connection_ids.add(edge.id)
first = require_connection_port( first = require_connection_port(
port_index, port_index,
edge.source, edge.source,
@@ -1070,88 +1128,41 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
connection_node = ET.SubElement( connection_node = ET.SubElement(
connections_node, connections_node,
"Connection", "Connection",
{ {"id": edge.id},
"id": edge.id,
"kind": first.kind,
"domain": first.domain,
},
) )
endpoints = [ endpoints = [(edge.source, first), (edge.target, second)]
(edge.source, first, None),
(edge.target, second, None),
]
if first.kind == "signal": if first.kind == "signal":
if first.nominalRole == "input": if first.nominal_role == "input":
endpoints.reverse() endpoints.reverse()
endpoints = [ for component_id, port in endpoints:
(endpoints[0][0], endpoints[0][1], "source"), ET.SubElement(
(endpoints[1][0], endpoints[1][1], "target"), connection_node,
] "Endpoint",
for component_id, port, role in endpoints: {"component": component_id, "port": port.name},
attributes = {"component": component_id, "port": port.name} )
if role is not None:
attributes["role"] = role
ET.SubElement(connection_node, "Endpoint", attributes)
ET.indent(system, space=" ") ET.indent(system, space=" ")
return ET.tostring(system, encoding="utf-8", xml_declaration=True) xml = ET.tostring(system, encoding="utf-8", xml_declaration=True)
report = validate_system_xml_document(xml)
if not report.valid:
def normalize_port_definition( errors = [
port: ReactFlowPortDefinition | str, f"{issue.code}: {issue.message}"
*, for issue in report.issues
index: int, if issue.severity == "error"
component_type: str, ]
) -> ReactFlowPortDefinition: raise ValueError(
if isinstance(port, ReactFlowPortDefinition): "Generated System XML does not satisfy the v3 contract: "
return port + "; ".join(errors)
registered_legacy_ports: dict[str, dict[str, tuple[str, str]]] = {
"cylinder": {"port_b": ("outlet", "right")},
"tank": {"port_a": ("inlet", "left")},
"pipe": {
"port_a": ("inlet", "left"),
"port_b": ("outlet", "right"),
},
"orifice": {
"port_a": ("inlet", "left"),
"port_b": ("outlet", "right"),
},
"tee": {
"port_in": ("bidirectional", "left"),
"port_out1": ("bidirectional", "right"),
"port_out2": ("bidirectional", "right"),
},
}
registered = registered_legacy_ports.get(component_type, {}).get(port)
if registered is not None:
nominal_role, side = registered
return ReactFlowPortDefinition(
name=port,
nominalRole=nominal_role,
positiveFlowDirection="intoComponent",
side=side,
)
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional"
if "out" in port or port == "port_b":
nominal_role = "outlet"
elif "in" in port or port == "port_a":
nominal_role = "inlet"
return ReactFlowPortDefinition(
name=port,
nominalRole=nominal_role,
positiveFlowDirection="intoComponent",
side="left" if index == 0 else "right",
) )
return xml
def require_connection_port( def require_connection_port(
port_index: dict[tuple[str, str], ReactFlowPortDefinition], port_index: dict[tuple[str, str], "PortDefinition"],
component_id: str, component_id: str,
port_name: str | None, port_name: str | None,
connection_id: str, connection_id: str,
) -> ReactFlowPortDefinition: ) -> "PortDefinition":
if port_name is None or (component_id, port_name) not in port_index: if port_name is None or (component_id, port_name) not in port_index:
raise ValueError( raise ValueError(
f"Connection {connection_id} references missing endpoint " f"Connection {connection_id} references missing endpoint "
@@ -1161,15 +1172,15 @@ def require_connection_port(
def validate_compatible_ports( def validate_compatible_ports(
first: ReactFlowPortDefinition, first: "PortDefinition",
second: ReactFlowPortDefinition, second: "PortDefinition",
connection_id: str, connection_id: str,
) -> None: ) -> None:
if first.kind != second.kind: if first.kind != second.kind:
raise ValueError(f"Connection {connection_id} mixes physical and signal ports.") raise ValueError(f"Connection {connection_id} mixes physical and signal ports.")
if first.domain != second.domain: if first.domain != second.domain:
raise ValueError(f"Connection {connection_id} connects incompatible domains.") raise ValueError(f"Connection {connection_id} connects incompatible domains.")
if first.kind == "signal" and {first.nominalRole, second.nominalRole} != { if first.kind == "signal" and {first.nominal_role, second.nominal_role} != {
"input", "input",
"output", "output",
}: }:
@@ -1182,6 +1193,89 @@ def compile_reactflow_network(
project: ReactFlowProjectPayload, project: ReactFlowProjectPayload,
*, *,
amesim_gas_registry: "AmesimGasRegistry | None" = None, amesim_gas_registry: "AmesimGasRegistry | None" = None,
) -> "SimulationNetwork":
return _compile_solver_network(
_solver_model_from_reactflow(project),
amesim_gas_registry=amesim_gas_registry,
)
def compile_system_xml_network(
document: SystemXmlDocument,
*,
amesim_gas_registry: "AmesimGasRegistry | None" = None,
) -> "SimulationNetwork":
"""Compile validated v3 XML without recreating editor/ReactFlow state."""
model = SolverModelInput(
name=document.name or "untitled",
components=tuple(
SolverComponentInput(
id=component.id,
model_type=component.model_type,
model_version=component.model_version,
parameters={
parameter.name: parameter.value
for parameter in component.parameters
},
)
for component in document.components
),
connections=tuple(
SolverConnectionInput(
id=connection.id,
endpoint_a_component=connection.endpoints[0].component,
endpoint_a_port=connection.endpoints[0].port,
endpoint_b_component=connection.endpoints[1].component,
endpoint_b_port=connection.endpoints[1].port,
)
for connection in document.connections
),
)
return _compile_solver_network(
model,
amesim_gas_registry=amesim_gas_registry,
)
def _solver_model_from_reactflow(
project: ReactFlowProjectPayload,
) -> SolverModelInput:
from app.simulation.registry import get_component_model_spec
components: list[SolverComponentInput] = []
for node in project.nodes:
spec = get_component_model_spec(node.data.modelType)
parameter_values = validate_reactflow_component_contract(node, spec)
components.append(
SolverComponentInput(
id=node.id,
model_type=node.data.modelType,
model_version=node.data.modelVersion,
parameters=parameter_values,
)
)
return SolverModelInput(
name=project.name,
components=tuple(components),
connections=tuple(
SolverConnectionInput(
id=edge.id,
endpoint_a_component=edge.source,
endpoint_a_port=edge.sourceHandle or "",
endpoint_b_component=edge.target,
endpoint_b_port=edge.targetHandle or "",
)
for edge in project.edges
),
)
def _compile_solver_network(
model: SolverModelInput,
*,
amesim_gas_registry: "AmesimGasRegistry | None" = None,
) -> "SimulationNetwork": ) -> "SimulationNetwork":
from app.simulation.components.amesim.gases import ( from app.simulation.components.amesim.gases import (
AMESIM_BUILTIN_AIR_GAS_INDEX, AMESIM_BUILTIN_AIR_GAS_INDEX,
@@ -1198,32 +1292,32 @@ def compile_reactflow_network(
else: else:
amesim_gas_registry = amesim_gas_registry.copy() amesim_gas_registry = amesim_gas_registry.copy()
network = SimulationNetwork(name=project.name) network = SimulationNetwork(name=model.name)
resolved_nodes = [] resolved_nodes = []
specs_by_component_id = {} specs_by_component_id = {}
gas_indices_by_component_id = {} gas_indices_by_component_id = {}
component_ids: set[str] = set()
# Phase 1: normalize every node and consume compile-time medium definitions. # Phase 1: normalize every node and consume compile-time medium definitions.
# A definition node represents one complete property-method instance and is # A definition node represents one complete property-method instance and is
# intentionally not added to the equation network. # intentionally not added to the equation network.
for node in project.nodes: for node in model.components:
spec = get_component_model_spec(node.data.modelType) if node.id in component_ids:
parameter_values = { raise ValueError(f"Duplicate component id: {node.id}.")
parameter.name: canonical_amesim_parameter_value( component_ids.add(node.id)
project, spec = get_component_model_spec(node.model_type)
node, if node.model_version is None or not node.model_version.strip():
parameter.name,
parameter_float(node, parameter.name, parameter.default),
)
for parameter in spec.parameters
}
unknown_parameters = set(node.data.parameters) - set(spec.parameter_by_name)
if unknown_parameters:
raise ValueError( raise ValueError(
f"Component '{node.id}' contains unsupported parameters: " f"COMPONENT_MODEL_VERSION_MISSING: Component '{node.id}' must "
+ ", ".join(sorted(unknown_parameters)) f"declare modelVersion '{spec.model_version}' before it can execute."
+ "."
) )
if node.model_version != spec.model_version:
raise ValueError(
f"COMPONENT_MODEL_VERSION_MISMATCH: Component '{node.id}' declares "
f"modelVersion '{node.model_version}', expected "
f"'{spec.model_version}'."
)
parameter_values = dict(node.parameters)
if issubclass( if issubclass(
spec.component_class, spec.component_class,
AmesimGasMediumDefinitionComponent, AmesimGasMediumDefinitionComponent,
@@ -1237,10 +1331,6 @@ def compile_reactflow_network(
definition_component, definition_component,
AmesimGasMediumDefinitionComponent, AmesimGasMediumDefinitionComponent,
) )
validate_component_port_interface(
node,
definition_component.port_definitions,
)
try: try:
amesim_gas_registry.register( amesim_gas_registry.register(
definition_component.gas_definition() definition_component.gas_definition()
@@ -1263,22 +1353,24 @@ def compile_reactflow_network(
# Phase 2: resolve one gas property model for each pneumatic circuit before # Phase 2: resolve one gas property model for each pneumatic circuit before
# any dynamic component initializes its thermodynamic state. # any dynamic component initializes its thermodynamic state.
pneumatic_connections = [] pneumatic_connections = []
for edge in project.edges: for edge in model.connections:
source_spec = specs_by_component_id.get(edge.source) source_spec = specs_by_component_id.get(edge.endpoint_a_component)
target_spec = specs_by_component_id.get(edge.target) target_spec = specs_by_component_id.get(edge.endpoint_b_component)
if source_spec is None or target_spec is None: if source_spec is None or target_spec is None:
continue continue
source_ports = {port.name: port for port in source_spec.ports} source_ports = {port.name: port for port in source_spec.ports}
target_ports = {port.name: port for port in target_spec.ports} target_ports = {port.name: port for port in target_spec.ports}
source_port = source_ports.get(edge.sourceHandle or "") source_port = source_ports.get(edge.endpoint_a_port)
target_port = target_ports.get(edge.targetHandle or "") target_port = target_ports.get(edge.endpoint_b_port)
if ( if (
source_port is not None source_port is not None
and target_port is not None and target_port is not None
and source_port.domain == "pneumatic" and source_port.domain == "pneumatic"
and target_port.domain == "pneumatic" and target_port.domain == "pneumatic"
): ):
pneumatic_connections.append((edge.source, edge.target)) pneumatic_connections.append(
(edge.endpoint_a_component, edge.endpoint_b_component)
)
media_by_component_id = amesim_gas_registry.resolve_network_media( media_by_component_id = amesim_gas_registry.resolve_network_media(
gas_indices_by_component_id, gas_indices_by_component_id,
@@ -1291,21 +1383,14 @@ def compile_reactflow_network(
media_by_component_id[node.id], media_by_component_id[node.id],
parameter_values, parameter_values,
) )
apply_layout_transform = getattr(component, "apply_layout_transform", None)
if apply_layout_transform is not None:
apply_layout_transform(
rotation=node.data.rotation,
mirrored=node.data.mirrored,
)
validate_component_port_interface(node, component.port_definitions)
network.add_component(component) network.add_component(component)
for edge in project.edges: for edge in model.connections:
network.connect( network.connect(
edge.source, edge.endpoint_a_component,
edge.sourceHandle or "", edge.endpoint_a_port,
edge.target, edge.endpoint_b_component,
edge.targetHandle or "", edge.endpoint_b_port,
connection_id=edge.id, connection_id=edge.id,
) )
return network return network
@@ -1315,14 +1400,7 @@ def validate_component_port_interface(
node: ReactFlowNodePayload, node: ReactFlowNodePayload,
component_ports: tuple["PortDefinition", ...], component_ports: tuple["PortDefinition", ...],
) -> None: ) -> None:
payload_ports = [ payload_ports = node.data.ports
normalize_port_definition(
port,
index=index,
component_type=node.data.componentType,
)
for index, port in enumerate(node.data.ports)
]
expected_by_name = {port.name: port for port in component_ports} expected_by_name = {port.name: port for port in component_ports}
payload_by_name = {port.name: port for port in payload_ports} payload_by_name = {port.name: port for port in payload_ports}
if len(payload_by_name) != len(payload_ports): if len(payload_by_name) != len(payload_ports):
@@ -1377,6 +1455,8 @@ def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, objec
) )
from app.simulation.solvers.solver import SolveIVPConfig from app.simulation.solvers.solver import SolveIVPConfig
validate_reactflow_execution_contract(project)
nodes_by_type: dict[str, list[ReactFlowNodePayload]] = {} nodes_by_type: dict[str, list[ReactFlowNodePayload]] = {}
for node in project.nodes: for node in project.nodes:
nodes_by_type.setdefault(node.data.modelType, []).append(node) nodes_by_type.setdefault(node.data.modelType, []).append(node)
@@ -1451,6 +1531,8 @@ def run_reactflow_test_mql(project: ReactFlowProjectPayload) -> dict[str, object
from app.simulation.examples.test_mql.run import run_test_mql from app.simulation.examples.test_mql.run import run_test_mql
from app.simulation.examples.test_mql.system import TestMqlRunConfig from app.simulation.examples.test_mql.system import TestMqlRunConfig
validate_reactflow_execution_contract(project)
run_config = TestMqlRunConfig( run_config = TestMqlRunConfig(
t_start=project.simulation.t_start, t_start=project.simulation.t_start,
t_stop=project.simulation.t_stop, t_stop=project.simulation.t_stop,
@@ -1510,37 +1592,6 @@ def parameter_float(
raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.") raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.")
def canonical_amesim_parameter_value(
project: ReactFlowProjectPayload,
node: ReactFlowNodePayload,
name: str,
value: Any,
) -> Any:
"""Translate legacy app-local AMESim codes to the canonical catalog codes.
Projects created before ``amesimParameterEncodingVersion`` used 0/1 for
MECMAS21 yes/no parameters. AMESim itself uses 1/2, so preserve the old
project meaning while all newly saved projects and System XML use the
canonical codes.
"""
if (
project.amesimParameterEncodingVersion is not None
or node.data.modelType != "amesim_mecmas21"
or name not in MECMAS21_LEGACY_BINARY_PARAMETERS
):
return value
try:
numeric_value = float(value)
except (TypeError, ValueError):
return value
if numeric_value == 0.0:
return 1.0
if numeric_value == 1.0:
return 2.0
return value
def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type): def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type):
return pipe_config_type( return pipe_config_type(
length=parameter_float(node, "length", 5.0), length=parameter_float(node, "length", 5.0),
+1 -1
View File
@@ -186,7 +186,7 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
`testmodel_tank_temperature.svg` `testmodel_tank_temperature.svg`
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。 11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。 12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。 13. 面向 System XML v3 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
当前没有实现: 当前没有实现:
@@ -81,12 +81,29 @@ class AmesimForc(AlgebraicComponent):
"""AMESim FORC signal-to-force converter.""" """AMESim FORC signal-to-force converter."""
MODEL_TYPE = "amesim_forc" MODEL_TYPE = "amesim_forc"
MODEL_VERSION = "0.1.0" MODEL_VERSION = "0.2.0"
PORTS = ( PORTS = (
PortDefinition.signal("res", nominal_role="input"), PortDefinition.signal("res", nominal_role="input"),
PortDefinition.mechanical_translational("port_2"), PortDefinition.mechanical_translational("port_2"),
) )
PARAMETERS = () PARAMETERS = (
ParameterDefinition(
"direction",
1.0,
label="力方向",
quantity="dimensionless",
unit="",
editor="choice",
options=(
ParameterOption(1.0, "正向"),
ParameterOption(-1.0, "反向"),
),
description=(
"显式控制输入信号相对于机械端口正方向的力符号;"
"图标旋转和镜像不会改变该参数。"
),
),
)
RESULT_VARIABLES = ( RESULT_VARIABLES = (
ResultVariableDefinition("force", "输出力", "force", "N", "signal", 10), ResultVariableDefinition("force", "输出力", "force", "N", "signal", 10),
) )
@@ -102,12 +119,12 @@ class AmesimForc(AlgebraicComponent):
order=20, order=20,
) )
def __init__(self, name: str) -> None: def __init__(self, name: str, *, direction: float = 1.0) -> None:
super().__init__(name=name) super().__init__(name=name)
self.set_parameter_values({}) self.set_parameter_values({"direction": direction})
self.direction = float(direction)
self.res = self.register_declared_port("res") self.res = self.register_declared_port("res")
self.port_2 = self.register_declared_port("port_2") self.port_2 = self.register_declared_port("port_2")
self._orientation_sign = 1.0
@classmethod @classmethod
def create( def create(
@@ -117,20 +134,11 @@ class AmesimForc(AlgebraicComponent):
medium: IdealGasMedium, medium: IdealGasMedium,
parameters: Mapping[str, float], parameters: Mapping[str, float],
) -> "AmesimForc": ) -> "AmesimForc":
return cls(name=name) return cls(name=name, direction=parameters["direction"])
def apply_layout_transform(self, *, rotation: int, mirrored: bool) -> None:
"""Apply the AMESim icon direction to the signed force output."""
normalized_rotation = int(rotation) % 360
if normalized_rotation not in {0, 90, 180, 270}:
raise ValueError("FORC rotation must be a multiple of 90 degrees.")
direction = -1.0 if normalized_rotation in {180, 270} else 1.0
self._orientation_sign = -direction if mirrored else direction
@property @property
def output_force(self) -> float: def output_force(self) -> float:
return float(self.res.signal) return self.direction * float(self.res.signal)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return ( return (
@@ -141,7 +149,7 @@ class AmesimForc(AlgebraicComponent):
relation="constitutive", relation="constitutive",
variables=(f"{self.name}.port_2.f", f"{self.name}.res.signal"), variables=(f"{self.name}.port_2.f", f"{self.name}.res.signal"),
role="flow", role="flow",
value=self.port_2.f + self._orientation_sign * self.output_force, value=self.port_2.f + self.output_force,
), ),
) )
@@ -366,7 +374,7 @@ class AmesimMecmas21(DynamicComponent):
), ),
ParameterDefinition( ParameterDefinition(
"useFriction", "useFriction",
1.0, 2.0,
label="启用摩擦", label="启用摩擦",
quantity="dimensionless", quantity="dimensionless",
unit="", unit="",
@@ -383,7 +391,7 @@ class AmesimMecmas21(DynamicComponent):
), ),
ParameterDefinition( ParameterDefinition(
"stoptype", "stoptype",
4.0, 1.0,
label="限位类型", label="限位类型",
quantity="dimensionless", quantity="dimensionless",
unit="", unit="",
@@ -1031,14 +1039,33 @@ class AmesimLmechn1(AlgebraicComponent):
"""AMESim LMECHN1 first public dynamic linear mechanical node.""" """AMESim LMECHN1 first public dynamic linear mechanical node."""
MODEL_TYPE = "amesim_lmechn1" MODEL_TYPE = "amesim_lmechn1"
MODEL_VERSION = "0.1.0" MODEL_VERSION = "0.2.0"
PORTS = tuple( PORTS = tuple(
PortDefinition.mechanical_translational(f"port_{index}") PortDefinition.mechanical_translational(f"port_{index}")
for index in range(1, 10) for index in range(1, 22)
) )
PARAMETERS = ( PARAMETERS = (
ParameterDefinition("v1", 8.0, label="右侧端口数", quantity="dimensionless", unit="", minimum=1.0, maximum=8.0), ParameterDefinition(
ParameterDefinition("sum", 1.0, label="节点求和模式", quantity="dimensionless", unit="", minimum=0.0), "v1",
2.0,
label="右侧端口数",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=20.0,
description="设置工作区中显示的右侧机械端口数量,最多 20 个。",
),
ParameterDefinition(
"sum",
1.0,
label="节点求和模式",
quantity="dimensionless",
unit="",
editor="choice",
options=(
ParameterOption(1.0, "各端口力代数和为零(标准节点)"),
),
),
) )
RESULT_VARIABLES = ( RESULT_VARIABLES = (
ResultVariableDefinition("tforce", "节点合力", "force", "N", "derived", 10), ResultVariableDefinition("tforce", "节点合力", "force", "N", "derived", 10),
@@ -1049,13 +1076,16 @@ class AmesimLmechn1(AlgebraicComponent):
category_id="mechanical", category_id="mechanical",
symbol="amesim_lmechn1", symbol="amesim_lmechn1",
ports=tuple( ports=tuple(
[PortDisplaySpec(f"port_{index}", "left", order=index * 10) for index in range(1, 9)] [
+ [PortDisplaySpec("port_9", "right", order=90)] PortDisplaySpec(f"port_{index}", "right", order=index * 10)
for index in range(1, 21)
]
+ [PortDisplaySpec("port_21", "left", order=210)]
), ),
order=50, order=50,
) )
def __init__(self, name: str, medium: IdealGasMedium, *, v1: float = 8.0, sum: float = 1.0) -> None: def __init__(self, name: str, medium: IdealGasMedium, *, v1: float = 2.0, sum: float = 1.0) -> None:
super().__init__(name=name) super().__init__(name=name)
self.set_parameter_values({"v1": v1, "sum": sum}) self.set_parameter_values({"v1": v1, "sum": sum})
self.v1 = int(v1) self.v1 = int(v1)
@@ -1078,21 +1108,30 @@ class AmesimLmechn1(AlgebraicComponent):
@property @property
def active_ports(self) -> tuple[str, ...]: def active_ports(self) -> tuple[str, ...]:
return tuple(f"port_{index}" for index in range(1, self.v1 + 1)) + ("port_9",) return tuple(f"port_{index}" for index in range(1, self.v1 + 2))
@property
def reference_port_name(self) -> str:
return f"port_{self.v1 + 1}"
@property
def required_connection_ports(self) -> tuple[str, ...]:
return self.active_ports
@property @property
def total_force(self) -> float: def total_force(self) -> float:
# AMESim's ``tforce`` is the force transmitted by the summed branch # AMESim's ``tforce`` is the force transmitted by the summed branch
# ports (1..v1). Port 9 is the balancing/common port and is excluded # ports (1..v1). The final active port is the balancing/common port and
# from that reported value. # is excluded from that reported value.
return sum(self.get_port(port_name).f for port_name in self.active_ports[:-1]) return sum(self.get_port(port_name).f for port_name in self.active_ports[:-1])
@property @property
def force_balance(self) -> float: def force_balance(self) -> float:
return self.total_force + self.port_9.f return self.total_force + self.get_port(self.reference_port_name).f
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
reference = self.port_9 reference_name = self.reference_port_name
reference = self.get_port(reference_name)
residuals: list[EquationResidual] = [] residuals: list[EquationResidual] = []
for port_name in self.active_ports[:-1]: for port_name in self.active_ports[:-1]:
port = self.get_port(port_name) port = self.get_port(port_name)
@@ -1102,7 +1141,7 @@ class AmesimLmechn1(AlgebraicComponent):
owner="component", owner="component",
owner_id=self.name, owner_id=self.name,
relation="equal", relation="equal",
variables=(f"{self.name}.{port_name}.x", f"{self.name}.port_9.x"), variables=(f"{self.name}.{port_name}.x", f"{self.name}.{reference_name}.x"),
role="effort", role="effort",
value=port.x - reference.x, value=port.x - reference.x,
) )
@@ -1113,7 +1152,7 @@ class AmesimLmechn1(AlgebraicComponent):
owner="component", owner="component",
owner_id=self.name, owner_id=self.name,
relation="equal", relation="equal",
variables=(f"{self.name}.{port_name}.v", f"{self.name}.port_9.v"), variables=(f"{self.name}.{port_name}.v", f"{self.name}.{reference_name}.v"),
role="effort", role="effort",
value=port.v - reference.v, value=port.v - reference.v,
) )
@@ -1129,6 +1168,39 @@ class AmesimLmechn1(AlgebraicComponent):
value=self.force_balance, value=self.force_balance,
) )
) )
for definition in self.PORTS[self.v1 + 1 :]:
port = self.get_port(definition.name)
residuals.extend(
(
EquationResidual(
id=f"{self.name}:{definition.name}_inactive_x",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.{definition.name}.x",),
role="effort",
value=port.x,
),
EquationResidual(
id=f"{self.name}:{definition.name}_inactive_v",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.{definition.name}.v",),
role="effort",
value=port.v,
),
EquationResidual(
id=f"{self.name}:{definition.name}_inactive_force",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.{definition.name}.f",),
role="flow",
value=port.f,
),
)
)
return tuple(residuals) return tuple(residuals)
def component_result_values(self) -> Mapping[str, float]: def component_result_values(self) -> Mapping[str, float]:
@@ -1,12 +1,3 @@
"""Temporary component library used to validate the model authoring contract.""" """Temporary component library used to validate the model authoring contract."""
from app.simulation.components.experimental.library import LIBRARY from app.simulation.components.experimental.library import LIBRARY
# Compatibility aliases for code written before the v1 library manifest.
LIBRARY_ID = LIBRARY.id
LIBRARY_LABEL = LIBRARY.label
LIBRARY_VERSION = LIBRARY.version
LIBRARY_ORDER = LIBRARY.order
LIBRARY_SOURCE_PACKAGE = LIBRARY.source_package
LIBRARY_TEMPORARY = LIBRARY.temporary
+10
View File
@@ -44,6 +44,16 @@ class Component(ABC):
if port.definition is not None if port.definition is not None
) )
@property
def required_connection_ports(self) -> tuple[str, ...]:
"""Physical ports that must have an external connection before simulation."""
return tuple(
definition.name
for definition in self.port_definitions
if definition.kind == "physical"
)
def register_port(self, port: PortState) -> PortState: def register_port(self, port: PortState) -> PortState:
definition = port.definition definition = port.definition
if definition is None: if definition is None:
+108 -16
View File
@@ -47,6 +47,14 @@ class ThermofluidClosureError(RuntimeError):
"""Raised when stream enthalpy and pressure-flow do not reach one fixed point.""" """Raised when stream enthalpy and pressure-flow do not reach one fixed point."""
class SimulationSampleTimeError(ValueError):
"""Stable failure contract for an unsafe or unrepresentable sample grid."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
@dataclass(frozen=True) @dataclass(frozen=True)
class GenericSimulationResult: class GenericSimulationResult:
success: bool success: bool
@@ -106,10 +114,9 @@ def simulation_preparation_issues(
) -> tuple[SimulationPreparationIssue, ...]: ) -> tuple[SimulationPreparationIssue, ...]:
issues: list[SimulationPreparationIssue] = [] issues: list[SimulationPreparationIssue] = []
physical_endpoints = { physical_endpoints = {
Endpoint(component.name, definition.name) Endpoint(component.name, port_name)
for component in network.components.values() for component in network.components.values()
for definition in component.port_definitions for port_name in component.required_connection_ports
if definition.kind == "physical"
} }
connected_endpoints = { connected_endpoints = {
endpoint endpoint
@@ -148,8 +155,18 @@ def simulation_preparation_issues(
) )
) )
adjacency = {name: set() for name in network.components} physical_component_names = {
component.name
for component in network.components.values()
if any(
definition.kind == "physical"
for definition in component.port_definitions
)
}
adjacency = {name: set() for name in physical_component_names}
for connection in network.connections: for connection in network.connections:
if connection.kind != "physical":
continue
first, second = connection.endpoints first, second = connection.endpoints
adjacency[first.component].add(second.component) adjacency[first.component].add(second.component)
adjacency[second.component].add(first.component) adjacency[second.component].add(first.component)
@@ -224,20 +241,95 @@ def simulation_sample_times(
*, *,
max_points: int = 10001, max_points: int = 10001,
) -> list[float]: ) -> list[float]:
if max_points < 2:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_LIMIT_INVALID",
"Simulation sample limit must allow at least two points.",
)
t_start = float(config.t_start)
t_stop = float(config.t_stop)
if not isfinite(t_start) or not isfinite(t_stop):
raise SimulationSampleTimeError(
"SIMULATION_VALUE_NOT_FINITE",
"Simulation start and stop times must be finite.",
)
if step <= 0.0 or not isfinite(step): if step <= 0.0 or not isfinite(step):
raise ValueError("Simulation sample step must be finite and greater than zero.") raise SimulationSampleTimeError(
duration = config.t_stop - config.t_start "SIMULATION_SAMPLE_STEP_INVALID",
"Simulation sample step must be finite and greater than zero.",
)
duration = t_stop - t_start
if not isfinite(duration):
raise SimulationSampleTimeError(
"SIMULATION_TIME_SPAN_NOT_FINITE",
"Simulation time span must be finite.",
)
if duration <= 0.0: if duration <= 0.0:
raise ValueError("Simulation stop time must be greater than start time.") raise SimulationSampleTimeError(
interval_count = int(floor(duration / step + 1e-12)) "SIMULATION_TIME_RANGE_INVALID",
times = [config.t_start + index * step for index in range(interval_count + 1)] "Simulation stop time must be greater than start time.",
if times[-1] < config.t_stop - 1e-12: )
times.append(config.t_stop)
else: # Bound the grid before dividing by a potentially tiny step or allocating
times[-1] = config.t_stop # the result list. This avoids both float-to-int overflow and an OOM-sized
if len(times) > max_points: # ``range``/list when input comes from an external System XML document.
raise ValueError( maximum_interval_count = max_points - 1
f"Simulation requests {len(times)} samples; the limit is {max_points}." if step < duration / maximum_interval_count:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
f"Simulation sample count exceeds the limit of {max_points}; "
"increase sampleStep.",
)
ratio = duration / step
if not isfinite(ratio):
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
f"Simulation sample count exceeds the limit of {max_points}; "
"increase sampleStep.",
)
interval_count = int(floor(ratio))
last_regular_time = t_start + interval_count * step
append_stop = last_regular_time < t_stop
requested_point_count = interval_count + 1 + int(append_stop)
if requested_point_count > max_points:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
f"Simulation requests {requested_point_count} samples; "
f"the limit is {max_points}.",
)
times = [t_start]
for index in range(1, interval_count + 1):
candidate = t_start + index * step
if not isfinite(candidate):
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
"Simulation sampleStep cannot be represented over the requested "
"absolute time range.",
)
if candidate >= t_stop:
candidate = t_stop
if candidate <= times[-1]:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
"Simulation sampleStep is too small to advance floating-point "
"time over the requested absolute time range.",
)
times.append(candidate)
if candidate == t_stop:
break
if times[-1] < t_stop:
times.append(t_stop)
if len(times) < 2 or any(
current >= following
for current, following in zip(times, times[1:])
):
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
"Simulation sample times must contain at least two strictly "
"increasing values.",
) )
return times return times
+14 -2
View File
@@ -91,8 +91,10 @@ class SimulationNetwork:
) -> Connection: ) -> Connection:
endpoint_a = Endpoint(endpoint_a_component, endpoint_a_port) endpoint_a = Endpoint(endpoint_a_component, endpoint_a_port)
endpoint_b = Endpoint(endpoint_b_component, endpoint_b_port) endpoint_b = Endpoint(endpoint_b_component, endpoint_b_port)
if endpoint_a == endpoint_b: if endpoint_a.component == endpoint_b.component:
raise ValueError(f"Cannot connect endpoint {endpoint_a} to itself.") raise ValueError(
f"Cannot connect component {endpoint_a.component} to itself."
)
first_port = self._port_for(endpoint_a) first_port = self._port_for(endpoint_a)
second_port = self._port_for(endpoint_b) second_port = self._port_for(endpoint_b)
@@ -131,6 +133,16 @@ class SimulationNetwork:
+ ", ".join(occupied) + ", ".join(occupied)
+ ". Use a junction component for branching." + ". Use a junction component for branching."
) )
else:
signal_input = (
endpoint_a
if first_definition.nominal_role == "input"
else endpoint_b
)
if signal_input in occupied_endpoints:
raise ValueError(
f"Signal input {signal_input} already has a driver."
)
if first_definition.kind == "physical" and endpoint_b.key < endpoint_a.key: if first_definition.kind == "physical" and endpoint_b.key < endpoint_a.key:
endpoint_a, endpoint_b = endpoint_b, endpoint_a endpoint_a, endpoint_b = endpoint_b, endpoint_a
+282 -668
View File
File diff suppressed because it is too large. Load diff
+14 -6
View File
@@ -2,6 +2,13 @@
本目录是 SystemSimulationApp 协议和开发规范的统一入口。 本目录是 SystemSimulationApp 协议和开发规范的统一入口。
跨 HTTP、组件目录、模型合同和 System XML 的版本边界统一见
[后端接口版本与定义规范 v1](backend-interface-version-spec-v1.md)。
## 更新记录
- [更新日志 2026-08-15](更新日志-2026-08-15.md)
## 模型开发 ## 模型开发
1. [组件模型建模规范 v1](component-model-authoring-spec-v1.md) 1. [组件模型建模规范 v1](component-model-authoring-spec-v1.md)
@@ -20,13 +27,12 @@
## System XML ## System XML
- [System XML v2 协议](system-xml-v2.md) - [System XML v3 协议(当前规范)](system-xml-v3.md)
- [System XML v2 XSD](../schemas/system-simulation-v2.xsd) - [System XML v3 XSD(当前 Schema)](../schemas/system-simulation-v3.xsd)
- [System XML v1 协议(旧版)](system-xml-v1.md)
- [System XML v1 XSD(旧版)](../schemas/system-simulation-v1.xsd)
新增模型时,模型类和组件库清单是后端事实来源;System XML 保存组件实例、参数和 新增模型时,模型类和组件库清单是后端事实来源;System XML v3 只保存求解所需的
连接。XML 解析器不能自行创造模型端口或参数。 组件实例、模型版本、参数、连接和仿真设置。端口契约由注册模型恢复,画布位置、图标
方向等编辑信息只属于工程 JSON。XML 解析器不能自行创造模型端口或参数。
## 当前代码入口 ## 当前代码入口
@@ -41,6 +47,8 @@
| AMESim 第一版公开临时库清单 | [`app/simulation/components/amesim/library.py`](../app/simulation/components/amesim/library.py) | | AMESim 第一版公开临时库清单 | [`app/simulation/components/amesim/library.py`](../app/simulation/components/amesim/library.py) |
| `test_mql` 固定算例入口 | [`app/simulation/examples/test_mql/system.py`](../app/simulation/examples/test_mql/system.py) | | `test_mql` 固定算例入口 | [`app/simulation/examples/test_mql/system.py`](../app/simulation/examples/test_mql/system.py) |
| 元件完整示例 | [`app/simulation/components/example.md`](../app/simulation/components/example.md) | | 元件完整示例 | [`app/simulation/components/example.md`](../app/simulation/components/example.md) |
| System XML v3 解析与语义校验 | [`app/system_xml.py`](../app/system_xml.py) |
| System XML v3 XSD | [`schemas/system-simulation-v3.xsd`](../schemas/system-simulation-v3.xsd) |
## AI 使用原则 ## AI 使用原则
+1 -1
View File
@@ -46,7 +46,7 @@
| `PNGD00` | 1 | 氦气气体定义 | 氦气 Peng-Robinson 首版公开 | `amesim_helium_medium`,`media` | 已映射源模型 `fluidType=12/eosType=6`;密度和压力使用 PR EOS,热量学暂用手册参考点的定比热闭合。完整压力相关残余焓、比热和真实气体临界流仍待状态相关物性接口。 | | `PNGD00` | 1 | 氦气气体定义 | 氦气 Peng-Robinson 首版公开 | `amesim_helium_medium`,`media` | 已映射源模型 `fluidType=12/eosType=6`;密度和压力使用 PR EOS,热量学暂用手册参考点的定比热闭合。完整压力相关残余焓、比热和真实气体临界流仍待状态相关物性接口。 |
| `PNRP17` | 8 | 气动活塞与移动体耦合 | 第一版公开 | `amesim_pnrp17`,`mechanical` | 已接入 1 个气动端口和 4 个一维机械端口,按 `dp/dr/x0/gi` 计算环形有效面积、扫掠容积、容积变化率和表压作用力;已与 PNCH012、双质量块跑通 System XML 联合仿真,完整事件语义和 AMESim baseline 仍待校准。 | | `PNRP17` | 8 | 气动活塞与移动体耦合 | 第一版公开 | `amesim_pnrp17`,`mechanical` | 已接入 1 个气动端口和 4 个一维机械端口,按 `dp/dr/x0/gi` 计算环形有效面积、扫掠容积、容积变化率和表压作用力;已与 PNCH012、双质量块跑通 System XML 联合仿真,完整事件语义和 AMESim baseline 仍待校准。 |
| `MECMAS21` | 10 | 一维平动质量 | 第一版公开 | `amesim_mecmas21`,`mechanical` | 已接入一维机械端口 `x/v/f`、双端质量状态和基本摩擦/限位项,并跑通零力源与信号力源最小 System XML;参数注册已对齐 AMESim 选项码与条件显示,高级静摩擦、Stribeck 公式和倾角重力分量仍未实现。 | | `MECMAS21` | 10 | 一维平动质量 | 第一版公开 | `amesim_mecmas21`,`mechanical` | 已接入一维机械端口 `x/v/f`、双端质量状态和基本摩擦/限位项,并跑通零力源与信号力源最小 System XML;参数注册已对齐 AMESim 选项码与条件显示,高级静摩擦、Stribeck 公式和倾角重力分量仍未实现。 |
| `LMECHN1` | 2 | 动态线性机械节点 | 第一版公开 | `amesim_lmechn1`,`mechanical` | 已接入 9 端一维机械节点、端口位移/速度等值和节点力平衡,并跑通 `FORC -> LMECHN1 -> MECMAS21` 最小 System XML;端口朝向/符号细节留后续 AMESim baseline 对齐。 | | `LMECHN1` | 2 | 动态线性机械节点 | 第一版公开 | `amesim_lmechn1`,`mechanical` | 已接入 1..20 个右侧端口和动态最大编号左侧参考端口;工作区画布、接口编号与节点方程会随端口数同步变化,并兼容迁移旧版固定 `port_9` 工程。已跑通 `FORC -> LMECHN1 -> MECMAS21` 最小 System XML。 |
| `LSTP00A` | 8 | 弹性接触/端止动 | 第一版公开 | `amesim_lstp00a`,`mechanical` | 已接入两端一维机械端口、相对位移/速度接触力和最小 System XML 仿真;当前是连续罚函数基础版,完整 AMESim 事件/非光滑接触语义留后续 baseline 对齐。 | | `LSTP00A` | 8 | 弹性接触/端止动 | 第一版公开 | `amesim_lstp00a`,`mechanical` | 已接入两端一维机械端口、相对位移/速度接触力和最小 System XML 仿真;当前是连续罚函数基础版,完整 AMESim 事件/非光滑接触语义留后续 baseline 对齐。 |
| `F000` | 16 | 零力源 | 第一版公开 | `amesim_f000`,`mechanical` | 已作为一端机械零力边界公开,约束端口力为 0。 | | `F000` | 16 | 零力源 | 第一版公开 | `amesim_f000`,`mechanical` | 已作为一端机械零力边界公开,约束端口力为 0。 |
| `FORC` | 2 | 信号转力 | 第一版公开 | `amesim_forc`,`mechanical` | 已接入信号输入 `res` 到机械端口力源,可由 `STEP0/UD00` 驱动质量组件。 | | `FORC` | 2 | 信号转力 | 第一版公开 | `amesim_forc`,`mechanical` | 已接入信号输入 `res` 到机械端口力源,可由 `STEP0/UD00` 驱动质量组件。 |
+228
View File
@@ -0,0 +1,228 @@
# 后端接口版本与定义规范 v1
本文统一说明 SystemSimulationApp 后端的接口边界、版本编号和事实来源。规范版本为
`1.0.0`。这个编号只表示本文档自身的修订版本,不等同于 HTTP API、System XML、
组件目录或单个模型的版本。
## 1. 当前版本基线
后端没有一个可以替代所有子版本的“总版本号”。调用方必须按所使用的数据合同读取
对应版本:
| 版本轴 | 当前值 | 作用范围 | 机器可读事实来源 |
| --- | --- | --- | --- |
| HTTP API | 未版本化 | `/api/...` 路由、请求和响应 | `app/main.py`、FastAPI OpenAPI |
| 后端应用发布版本 | 未定义 | 整个后端部署产物 | 当前没有包元数据或运行时常量 |
| 组件目录 Schema | `1` | `GET /api/components/catalog` | `schemas/component-catalog-v1.schema.json` |
| System XML Schema | `3` | XML 校验、编译和仿真输入 | `schemas/system-simulation-v3.xsd` |
| 组件库版本 | 各库独立 | 一个组件库的发布边界 | 各库 `library.py` 的 `version` |
| 模型合同版本 | 各模型独立 | 单个 `MODEL_TYPE` 的物理和数据合同 | 模型类的 `MODEL_VERSION` |
| ReactFlow 工程 JSON | `1` | 编辑器存档 | `projectSchemaVersion`、`ReactFlowProjectPayload` 和前端读取代码 |
| 结果文件格式 | `1` | 前端导入、导出的结果快照 | `SimulationResultsView.tsx` |
因此,`schemaVersion=3` 只能说明文件是 System XML v3,不能说明“后端 API 是
v3”;`MODEL_VERSION=0.2.0` 也只描述对应模型合同。
FastAPI 自动生成的 OpenAPI 当前可能显示默认 `info.version=0.1.0`;后端没有显式
声明这个值,因此它不是正式 API 或应用发布版本。
## 2. 事实来源和优先级
接口定义冲突时,按以下优先级处理:
1. 可执行代码和机器可读 Schema;
2. 自动化合同测试;
3. 当前版本规范文档;
4. 示例、调研记录和历史说明。
各类合同的唯一事实来源如下:
| 合同 | 唯一事实来源 |
| --- | --- |
| 公开模型类型和版本 | 模型类的 `MODEL_TYPE`、`MODEL_VERSION` |
| 端口物理合同 | 模型类的 `PORTS` 和 `app/simulation/core/ports.py` |
| 参数、结果及单位 | 模型类的 `PARAMETERS`、`RESULT_VARIABLES` |
| 组件库及分类 | 各库 `library.py` |
| 组件目录 JSON | `build_component_catalog()` 与目录 JSON Schema |
| System XML | v3 XSD、`app/system_xml.py` |
| 网络最终连接检查 | `SimulationNetwork.connect()` |
| HTTP 路由和请求模型 | `app/main.py` |
前端兜底目录、画布布局和示例 XML 不能反向定义后端物理合同。
## 3. 组件接口合同
每个公开组件必须显式声明:
```python
MODEL_TYPE = "example_component"
MODEL_VERSION = "1.0.0"
PORTS = (...)
PARAMETERS = (...)
RESULT_VARIABLES = (...)
DISPLAY = ...
```
并提供统一的 `create()` 入口。各字段含义如下:
- `MODEL_TYPE` 是工程、XML、目录和结果元数据共同使用的稳定机器标识;
- `MODEL_VERSION` 使用 `主版本.次版本.修订版本`;
- `PORTS` 定义端口名称、种类、物理域、名义角色、变量和连接规则;
- `PARAMETERS` 定义 SI 单位、默认值、范围和离散选项;
- `RESULT_VARIABLES` 定义结构化结果元数据;
- `DISPLAY` 只定义前端展示,不得成为物理方程的隐式输入。
物理流变量统一以进入组件为正。物理连接的两个端点无方向;信号方向由注册端口的
`output/input` 合同决定。
## 4. 组件目录接口
`GET /api/components/catalog` 返回组件库、分类和模型合同。响应顶层必须为:
```json
{
"schemaVersion": 1,
"libraries": []
}
```
目录结构由 `schemas/component-catalog-v1.schema.json` 约束。库必须携带
`version`,模型必须携带 `modelVersion`。目录 Schema 的整数版本只描述目录 JSON
结构;库和模型版本仍分别使用三段式版本号。
当前三段版本只接受 `数字.数字.数字`,不接受 prerelease 或 build metadata,不能直接
等同于完整 SemVer 实现。目录 Schema 对对象使用 `additionalProperties=false`,因此
新增字段也必须先评估严格消费者,不能只因字段可选就默认兼容。
目录响应是前端生成组件面板、参数编辑器和端口快照的来源。修改响应结构时必须同时
更新 JSON Schema、前端解析和合同测试。
## 5. System XML v3
System XML v3 是当前唯一支持的 XML 求解输入。根元素固定使用:
```xml
<System schemaVersion="3" unitSystem="SI">
```
每个组件必须包含 `id`、`type`、`modelVersion` 和完整 SI 参数;连接只包含两个
`Endpoint(component, port)`。端口合同由 `type + port` 从注册表恢复,XML 不保存
端口快照和画布布局。
`modelVersion` 必须与当前注册模型完全一致。版本不一致时返回
`COMPONENT_MODEL_VERSION_MISMATCH`,不会静默使用当前模型解释旧输入。完整结构见
`docs/system-xml-v3.md` 和 `schemas/system-simulation-v3.xsd`。
## 6. HTTP API
FastAPI 当前直接注册未版本化的 `/api/...` 路由,没有 `/api/v1` 命名空间,也没有
运行时 `apiVersion`。主要业务接口如下:
| 方法与路径 | 请求合同 | 响应合同 |
| --- | --- | --- |
| `GET /api/components/catalog` | 无 | 组件目录 JSON v1 |
| `GET /api/reactflow/projects` | 无 | 已保存工程摘要列表 |
| `GET /api/reactflow/projects/{id}` | 工程 ID | 严格校验后的工程 JSON v1 |
| `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/system-xml/validate` | 原始 XML v3 | 三层校验报告 |
| `POST /api/system-xml/parse` | 原始 XML v3 | 规范化执行模型 |
| `POST /api/system-xml/compile-model` | 原始 XML v3 | 校验报告、设置和网络 |
| `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 | 任务快照 |
| `POST /api/system-xml/simulations/{id}/cancel` | 取消原因 JSON | 取消受理状态 |
| `POST /api/simulation-results/csv` | 结构化结果 JSON | UTF-8 CSV 附件 |
两个固定算例接口 `simulate-testmodel` 和 `simulate-test-mql` 仍属于测试/基线能力,不能
视为任意拓扑仿真合同。
FastAPI 会生成 `/openapi.json`、`/docs` 和 `/redoc`。Pydantic JSON 请求体能够进入
OpenAPI,但当前多数 JSON 响应仍以 `dict[str, object]` 构造,XML、CSV 和 NDJSON
也使用原始响应类型。因此 OpenAPI 目前不是完整的响应合同;代码、Schema 和合同测试
仍是必要依据。
## 7. 数据命名、单位和错误
- XML 属性和目录 JSON 主要使用 `camelCase`;
- ReactFlow 仿真设置保留现有 `t_start/t_stop/step/max_step` 名称;
- 不允许调用方自行猜测或转换字段命名;
- XML 和求解参数统一使用 SI 基准值;
- 实例 ID 和机器标识必须稳定,显示名称不能代替机器标识。
System XML 校验问题统一包含:
```json
{
"severity": "error",
"layer": "semantic",
"code": "ENDPOINT_PORT_UNKNOWN",
"message": "...",
"path": "...",
"line": 1
}
```
其中 `severity/layer/code/message` 是核心字段,`path/line` 可选。同步 HTTP 错误目前既
可能是 FastAPI 字符串 `detail`,也可能是带 `message/issues/diagnostics` 的结构化
`detail`;流式接口则使用 `event=error` 的 NDJSON 事件。统一错误响应模型尚未实现,
调用方必须同时处理这三种现有形状。
## 8. 版本变更规则
### 8.1 模型和组件库
- 修订版本:修复实现,不改变输入、端口和结果合同;
- 次版本:向后兼容地增加有默认值的参数、结果或能力;
- 主版本:删除或改名端口/参数,或者改变既有物理语义。
修改 `MODEL_TYPE` 视为新模型,不得用原标识承载不兼容合同。
以上版本含义用于分类变更影响;当前 System XML v3 对 `modelVersion` 执行完整字符串
精确匹配。因此即使只是修订或次版本变化,旧 XML 也会被拒绝,不能把“向后兼容”
理解成解析器会自动接受旧版本。
### 8.2 目录和 XML Schema
- 兼容澄清不改变 Schema 版本;
- 新增可选字段前必须验证旧消费者行为;
- 删除字段、改名或改变既有语义必须提高 Schema 版本;
- Schema、解析器、导出器、文档和合同测试必须在同一修改中更新。
### 8.3 HTTP API
当前 HTTP API 未版本化,因此不得在原路径上直接发布破坏性变更。需要破坏现有请求
或响应合同前,应先单独设计 API 版本命名空间、兼容周期和下线规则;该机制不在本文
本次整理范围内。
## 9. 旧版本和迁移边界
当前后端只接受 System XML v3:
- 不按 `schemaVersion` 自动选择 v1/v2 解析器;
- 不提供 v1/v2 到 v3 的自动迁移器;
- 不对不匹配的 `modelVersion` 做自动升级;
- 旧版本值只用于验证“不受支持输入应被拒绝”的边界测试。
ReactFlow 工程 JSON 只接受 `projectSchemaVersion: 1` 的当前结构,每个节点必须保存
目录给出的 `modelVersion`。执行、编译和 XML 导出前会再次核对节点版本;缺失或不匹配
时明确拒绝,不能先补当前默认参数再冒充当前模型。字符串端口、缺失连接 Handle 或
已经删除的兼容标记也不会被猜测、补齐或迁移;以后确有升级需求时再为新的工程版本
单独设计迁移器。
MECMAS21 的 `useFriction`、`strib` 等 AMESim 选项统一使用目录声明的原生编码:
`1` 表示“否/禁用”,`2` 表示“是/启用”。工程 JSON v1、组件目录、System XML v3
和模型构造器不再接受或自动换算旧的 `0/1` 编码,也不再使用
`amesimParameterEncodingVersion` 触发猜测式转换。
## 10. 接口修改完成条件
任何接口合同变更至少应同时完成:
1. 更新唯一事实来源;
2. 根据兼容性决定是否提高对应版本;
3. 更新机器可读 Schema;
4. 更新当前规范和示例;
5. 增加请求、响应、拒绝边界和前后端联调测试;
6. 明确说明未实现的兼容或迁移能力。
+30 -24
View File
@@ -2,7 +2,7 @@
状态:已在 `experimental` 临时组件库实施 状态:已在 `experimental` 临时组件库实施
适用范围:`app/simulation/components`、组件注册中心、System XML 和 React Flow 组件库 适用范围:`app/simulation/components`、组件注册中心、System XML 和 React Flow 组件库
当前试验库:`experimental`(界面名称:临时测试组件库) 当前试验库:`experimental`(仅用于注册契约验证,不在前端组件库中显示)
## 0. 文档定位 ## 0. 文档定位
@@ -150,8 +150,11 @@ MODEL_VERSION = "1.0.0"
- 次版本:向后兼容地新增参数、结果或能力。 - 次版本:向后兼容地新增参数、结果或能力。
- 主版本:端口、参数语义或方程发生不兼容变化。 - 主版本:端口、参数语义或方程发生不兼容变化。
当前 System XML v2 尚未保存模型版本。正式发布组件库前,应在 XML 中增加 当前 System XML v3 要求每个 `Component` 显式保存 `modelVersion`,并与注册模型
`library` 和 `modelVersion`,并提供旧工程迁移规则。 版本完全一致;不一致时拒绝加载,不做静默升级。v3 不另存 `library`,而由全局唯一的
`Component/@type` 定位注册模型。旧模型的自动迁移仍未实现,需要另行提供显式规则。
因此当前“修订/次版本向后兼容”只表示合同设计意图,不表示旧 XML 会被解析器自动
接受;任意模型版本变化都会使旧 XML 的精确版本检查失败。
## 5. 组件库清单 ## 5. 组件库清单
@@ -206,7 +209,7 @@ LIBRARY = ComponentLibrarySpec(
- 启动错误能明确定位到具体库和模型。 - 启动错误能明确定位到具体库和模型。
当前 `experimental/library.py` 已按此格式声明库、分类和五个公开模型; 当前 `experimental/library.py` 已按此格式声明库、分类和五个公开模型;
`experimental/__init__.py` 只保留旧常量的兼容别名。 `experimental/__init__.py` 只公开规范化的 `LIBRARY` 清单。
## 6. 模型类契约 ## 6. 模型类契约
@@ -572,8 +575,8 @@ GET /api/components/catalog
一类目录数据,前端据此生成下拉栏;后续增加算法时由介质模型注册新的选项, 一类目录数据,前端据此生成下拉栏;后续增加算法时由介质模型注册新的选项,
前端不硬编码算法名称。 前端不硬编码算法名称。
- 模型的 `role: "amesimGasMediumDefinition"` 表示该模型是项目级介质定义。 - 模型的 `role: "amesimGasMediumDefinition"` 表示该模型是项目级介质定义。
此类模型允许 `ports: []`,在 System XML v2 中仍按普通零端口 此类模型允许 `ports: []`,在 System XML v3 中仍按普通零端口
`Component` 保存。 `Component` 保存;XML 不写任何 `Port` 快照,只保存模型版本和完整参数。
这些字段在目录对象中均为可选。宽松读取目录的消费者可以把未知编辑器参数 这些字段在目录对象中均为可选。宽松读取目录的消费者可以把未知编辑器参数
退化为普通数值输入;按本仓库 JSON Schema 严格校验的消费者必须与后端成套 退化为普通数值输入;按本仓库 JSON Schema 严格校验的消费者必须与后端成套
@@ -586,8 +589,9 @@ GET /api/components/catalog
Peng-Robinson,并映射到 `fluidType=12/eosType=6`。编译层负责保存这种映射, Peng-Robinson,并映射到 `fluidType=12/eosType=6`。编译层负责保存这种映射,
前端只使用目录选项。 前端只使用目录选项。
当前前端保留内置兜底目录,用于后端未启动时继续打开工程。兜底只是一种开发期 前端不再维护内置兜底目录。后端目录不可用时,组件区保持为空,并在“组件库”
容错机制,不能成为新增模型的正式注册方式;正式环境应明确提示目录加载失败。 标题旁显示红色“加载失败”状态;悬停或聚焦该状态可查看失败范围和详细原因。
`experimental` 试验库即使由成功的目录响应返回,也不会出现在组件区。
### 14.1 前端实际读取步骤 ### 14.1 前端实际读取步骤
@@ -599,12 +603,10 @@ React Flow 启动时:
4. 检查模型 `type` 是否全局重复。 4. 检查模型 `type` 是否全局重复。
5. 将参数数组转换为参数面板定义。 5. 将参数数组转换为参数面板定义。
6. 按库、分类和模型的 `order` 排序。 6. 按库、分类和模型的 `order` 排序。
7. 成功时显示“后端目录”。 7. 过滤仅用于注册验证的 `experimental` 试验库。
8. 请求或格式校验失败时显示“内置兜底”并使用开发期兜底目录。 8. 成功时用绿色状态显示“已加载 X 个组件库”,不追加其他成功说明。
9. 请求或格式校验失败时显示红色“加载失败”,不显示任何兜底组件;悬停状态可
前端兜底目录不保证包含新模型。新增公开模型后,只要 FastAPI 正常提供目录,前端 查看具体库名(目录响应可识别时)或受影响范围、接口地址与错误原因。
就能读取;若要求后端离线时也显示新模型,才需要有意识地同步兜底定义。兜底定义
仍不能成为端口、参数或默认值的权威来源。
### 14.2 修改后如何生效 ### 14.2 修改后如何生效
@@ -625,19 +627,20 @@ System XML 中:
```xml ```xml
<Component <Component
id="cylinder_1" id="cylinder_1"
name="cylinder_1"
type="cylinder" type="cylinder"
componentType="cylinder"> modelVersion="1.0.0">
<Parameter name="volume" value="0.01"/>
</Component>
``` ```
映射规则: 映射规则:
- `id`:工程内唯一的组件实例 ID。 - `id`:工程内唯一的组件实例 ID。
- `name`:用户可修改的组件实例名称。
- `type`:必须匹配唯一的 `MODEL_TYPE`。 - `type`:必须匹配唯一的 `MODEL_TYPE`。
- `componentType`:当前为兼容字段,应与 `type` 相同。 - `modelVersion`:必须与该模型当前 `MODEL_VERSION` 完全一致。
- `<Port name>`:必须存在于模型的 `PORTS`。 - `<Parameter name>`:必须完整且只能来自模型的 `PARAMETERS`,数值使用 SI。
- `<Parameter name>`:必须存在于模型的 `PARAMETERS`。 - XML v3 不保存 `name/componentType/Port` 或画布布局;连接中的
`Endpoint/@port` 必须存在于模型的 `PORTS`。
介质定义组件不通过物理端口连接。编译器先收集目录角色为 介质定义组件不通过物理端口连接。编译器先收集目录角色为
`amesimGasMediumDefinition` 的零端口组件,再解析带 `amesimGasMediumDefinition` 的零端口组件,再解析带
@@ -720,7 +723,9 @@ AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开
| 模型工厂 | 已实现 | 模型类统一 `create()` | | 模型工厂 | 已实现 | 模型类统一 `create()` |
| 模型发现 | 已实现 | 按库清单受控发现 | | 模型发现 | 已实现 | 按库清单受控发现 |
| 启动校验 | 已实现首版 | 覆盖版本、分类、端口、参数、单位和默认实例 | | 启动校验 | 已实现首版 | 覆盖版本、分类、端口、参数、单位和默认实例 |
| XML/工程中的模型版本与迁移 | 未实现 | 正式库发布前补齐 | | System XML 中的模型版本 | 已实现 | v3 显式保存并严格匹配 `modelVersion` |
| 工程 JSON 的整体版本 | 已实现 | 固定为 `projectSchemaVersion: 1`,节点显式锁定 `modelVersion` |
| 自动版本迁移 | 未实现 | 当前明确拒绝不匹配版本,本阶段不实现迁移 |
| 目录 JSON Schema | 已实现 | `schemas/component-catalog-v1.schema.json` | | 目录 JSON Schema | 已实现 | `schemas/component-catalog-v1.schema.json` |
## 19. 推荐实施顺序 ## 19. 推荐实施顺序
@@ -731,7 +736,8 @@ AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开
4. 已完成:公开模型统一实现 `create()`,集中式工厂函数已删除。 4. 已完成:公开模型统一实现 `create()`,集中式工厂函数已删除。
5. 已完成:注册表由库清单构建,并在导入时执行契约和默认实例校验。 5. 已完成:注册表由库清单构建,并在导入时执行契约和默认实例校验。
6. 已完成:已增加组件目录 JSON Schema。 6. 已完成:已增加组件目录 JSON Schema。
7. 待完成:在 System XML 和工程文件中保存模型版本,并设计迁移机制。 7. 已完成:System XML v3 保存并严格校验模型版本;工程 JSON 使用
`projectSchemaVersion: 1`,每个节点保存创建时的 `modelVersion`,当前不实现旧工程迁移。
8. 待完成:规范稳定后新建正式组件库,不再向 `experimental` 增加生产模型。 8. 待完成:规范稳定后新建正式组件库,不再向 `experimental` 增加生产模型。
该顺序可以保证每一步都保持现有前端和 System XML 可用,不需要一次性重写模型、 该顺序可以保证每一步都保持现有前端和 System XML 可用,不需要一次性重写模型、
@@ -745,7 +751,7 @@ AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开
| 新增分类 | 库 `categories`、模型 `DISPLAY.category_id`、测试 | 物理端口和求解器 | | 新增分类 | 库 `categories`、模型 `DISPLAY.category_id`、测试 | 物理端口和求解器 |
| 新增组件库 | 新库包和 `library.py`、启用列表、测试 | 已有库清单 | | 新增组件库 | 新库包和 `library.py`、启用列表、测试 | 已有库清单 |
| 修改参数默认值或范围 | 模型 `PARAMETERS`、测试、必要的版本 | 前端参数硬编码 | | 修改参数默认值或范围 | 模型 `PARAMETERS`、测试、必要的版本 | 前端参数硬编码 |
| 修改端口 | 模型 `PORTS`、`DISPLAY.ports`、XML/网络测试、版本迁移 | 库分类 | | 修改端口 | 模型 `PORTS`、`DISPLAY.ports`、主版本、XML/网络拒绝边界测试 | 库分类 |
| 新增专用图标 | 模型 `DISPLAY.symbol`、前端图标渲染器 | 参数和物理方程 | | 新增专用图标 | 模型 `DISPLAY.symbol`、前端图标渲染器 | 参数和物理方程 |
| 新增物理域 | 端口协议、网络、求解器、XML、前端兼容规则和测试 | 仅修改分类名称 | | 新增物理域 | 端口协议、网络、求解器、XML、前端兼容规则和测试 | 仅修改分类名称 |
| 修改目录响应结构 | 后端序列化、JSON Schema、前端解析、协议版本和测试 | 单个模型方程 | | 修改目录响应结构 | 后端序列化、JSON Schema、前端解析、协议版本和测试 | 单个模型方程 |
@@ -757,7 +763,7 @@ AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开
| 模型完全没有出现在目录响应 | 模型类路径是否加入已启用库的 `models` | | 模型完全没有出现在目录响应 | 模型类路径是否加入已启用库的 `models` |
| FastAPI 无法启动 | 启动错误中的库、模型和字段;通常是契约校验失败 | | FastAPI 无法启动 | 启动错误中的库、模型和字段;通常是契约校验失败 |
| 接口有模型但前端没有 | `schemaVersion`、前端控制台、目录规范化错误 | | 接口有模型但前端没有 | `schemaVersion`、前端控制台、目录规范化错误 |
| 前端显示“内置兜底” | 8000 端口、`/api/components/catalog`、后端是否重启 | | 前端显示红色“加载失败” | 悬停状态查看详情,再检查 8000 端口、`/api/components/catalog`、后端是否重启 |
| 分类错误 | `DISPLAY.category_id` 与库 `categories` | | 分类错误 | `DISPLAY.category_id` 与库 `categories` |
| 端口数量或位置错误 | `PORTS` 与 `DISPLAY.ports` 是否完全一致 | | 端口数量或位置错误 | `PORTS` 与 `DISPLAY.ports` 是否完全一致 |
| 参数面板缺字段 | 模型 `PARAMETERS` 和目录响应,不先改前端 | | 参数面板缺字段 | 模型 `PARAMETERS` 和目录响应,不先改前端 |
+4 -4
View File
@@ -34,11 +34,11 @@
| 改动 | 版本建议 | 兼容性要求 | | 改动 | 版本建议 | 兼容性要求 |
| --- | --- | --- | | --- | --- | --- |
| 修复数值实现但不改变契约 | 修订版本 | 旧 XML 和工程继续可用 | | 修复数值实现但不改变契约 | 修订版本 | 若提高 `modelVersion`,既有 XML 会因精确版本不匹配而被拒绝;需明确是否真的变更合同 |
| 新增有默认值的参数或结果 | 次版本 | 旧工程缺少该字段时必须有迁移或默认值 | | 新增有默认值的参数或结果 | 次版本 | 新 XML 必须写全当前参数;本阶段不提供旧文件自动迁移 |
| 修改界面名称或图标 | 库修订版本 | 不修改机器标识 | | 修改界面名称或图标 | 库修订版本 | 不修改机器标识 |
| 修改方程的物理语义 | 根据影响提高次版本或主版本 | 补充基准和变更说明 | | 修改方程的物理语义 | 根据影响提高次版本或主版本 | 补充基准和变更说明 |
| 删除、改名端口或参数 | 主版本 | 必须设计工程和 XML 迁移 | | 删除、改名端口或参数 | 主版本 | 当前格式直接拒绝旧端口或参数;如以后需要兼容,再单独设计迁移器 |
| 修改 `MODEL_TYPE` | 视为新模型 | 旧类型必须保留迁移映射 | | 修改 `MODEL_TYPE` | 视为新模型 | 旧类型必须保留迁移映射 |
### 2.3 新增内部模型 ### 2.3 新增内部模型
@@ -666,7 +666,7 @@ AI 创建或修改模型时必须遵守:
| --- | --- | --- | | --- | --- | --- |
| FastAPI 启动时报模型缺少声明 | 字段继承自父类或漏写 | 在公开模型类中显式声明 | | FastAPI 启动时报模型缺少声明 | 字段继承自父类或漏写 | 在公开模型类中显式声明 |
| 模型未出现在前端 | 未加入 `library.py` 或后端未重启 | 检查清单并重启 FastAPI | | 模型未出现在前端 | 未加入 `library.py` 或后端未重启 | 检查清单并重启 FastAPI |
| 前端显示“内置兜底” | `/api/components/catalog` 不可用 | 检查 8000 端口和接口响应 | | 前端显示红色“加载失败” | `/api/components/catalog` 不可用或目录合同无效 | 悬停状态查看详情,再检查 8000 端口和接口响应 |
| 显示端口校验失败 | `DISPLAY.ports` 与 `PORTS` 不一致 | 使用相同端口名和完整集合 | | 显示端口校验失败 | `DISPLAY.ports` 与 `PORTS` 不一致 | 使用相同端口名和完整集合 |
| 单位校验失败 | `quantity` 与 SI 单位不匹配 | 使用受控单位表或先扩展规范 | | 单位校验失败 | `quantity` 与 SI 单位不匹配 | 使用受控单位表或先扩展规范 |
| 默认模型无法注册 | 默认参数越界或构造函数未保存参数 | 修复默认值和 `set_parameter_values()` | | 默认模型无法注册 | 默认参数越界或构造函数未保存参数 | 修复默认值和 `set_parameter_values()` |
-72
View File
@@ -1,72 +0,0 @@
# System XML v1 协议
> 此版本仅用于识别旧文件。新项目使用 [System XML v2](system-xml-v2.md),物理连接在 v2 中改为无序端点。
System XML 是 ReactFlow 前端与 `app.simulation` 仿真层之间的稳定交换格式。
## 基本约定
- 根元素固定为 `System`,`schemaVersion` 固定为 `1`。
- `unitSystem` 固定为 `SI`。组件参数必须保存为 SI 基准值,界面显示单位不进入数值换算语义。
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
- `Component.id` 是稳定的仿真实例 ID;`name` 是用户可编辑的显示名称。
- `Component.type` 是仿真后端模型类型;`componentType` 是前端组件类型。
- `Connection.source/target` 只标识图形拓扑端点,不代表仿真中的实际流动方向。正流和倒流由求解器决定。
- v1 文档不携带结果数据;仿真结果通过运行接口返回。
## 完整示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<System name="transfer-system" schemaVersion="1" unitSystem="SI">
<Simulation tStart="0" tStop="2" step="0.1" maxStep="0.005" method="BDF"/>
<Components>
<Component id="cylinder_1" name="cylinder_1" type="cylinder" componentType="cylinder" x="90" y="180">
<Port name="port_b"/>
<Parameter name="volume" value="0.01"/>
<Parameter name="p0" value="35000000"/>
<Parameter name="T0" value="300"/>
</Component>
<Component id="tank_1" name="tank_1" type="tank" componentType="tank" x="420" y="180">
<Port name="port_a"/>
<Parameter name="volume" value="0.1"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="300"/>
</Component>
</Components>
<Connections>
<Connection id="edge-1" source="cylinder_1" sourcePort="port_b" target="tank_1" targetPort="port_a"/>
</Connections>
</System>
```
## Simulation
| 属性 | 类型 | 含义 | 约束 |
|---|---|---|---|
| `tStart` | double | 仿真起始时间,单位 s | 有限数 |
| `tStop` | double | 仿真结束时间,单位 s | 必须大于 `tStart` |
| `step` | double | 结果采样间隔,单位 s | 必须大于 0 |
| `maxStep` | double | 求解器最大积分步长,单位 s | 必须大于 0 |
| `method` | string | 求解器名称,例如 `BDF` | 非空 |
## Components
每个 `Component` 必须包含唯一 `id`、显示名称、模型类型、前端类型和画布坐标。组件下可以包含多个 `Port` 和 `Parameter`。
参数的 `value` 必须是可转换为有限浮点数的 SI 值。具体组件支持的端口、参数和取值范围由后续组件注册表协议约束。
## Connections
每个 `Connection` 必须包含唯一 `id` 和两个完整端点:
- `source` + `sourcePort`
- `target` + `targetPort`
端点中的组件 ID 必须存在,端口名称必须属于对应组件。v1 前端仍使用 source/target 组织连线,但仿真层必须将它们视为连接的两个端点,不得直接解释为固定物理流向。
## 版本兼容
- 解析器遇到缺少 `schemaVersion` 的旧 XML 时,应明确报告“旧协议”,不能静默当作 v1。
- v1 新增可选字段时必须保持旧解析器可忽略;删除字段或改变字段语义时必须升级主版本。
- XSD 只验证文档结构和基础数据类型。跨字段约束、组件端口规则和拓扑可求解性由仿真校验层负责。
-303
View File
@@ -1,303 +0,0 @@
# System XML v2 协议
System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的交换格式。v2 将物理端口与信号端口分开,并移除了物理连接中的方向语义。
## 基本约定
- 根元素 `System` 的 `schemaVersion` 固定为 `2`,`unitSystem` 固定为 `SI`。
- 新导出的介质引用语义使用可选根属性
`mediumReferenceVersion="1"`。缺少该属性的文档按旧版介质索引规则读取。
- AMESim 离散参数版本属性在 XSD 中仅为兼容旧文档而可省略;所有当前导出器
必须写出 `amesimParameterEncodingVersion="1"`,表示参数采用组件目录中的
规范编码。
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
- `Component.id` 是稳定实例 ID,`name` 是用户可编辑名称。
- 参数以 SI 基准值保存;显示单位不改变 XML 中的数值含义。
- 物理端口统一规定 `m_flow > 0` 表示流入组件,`m_flow < 0` 表示流出组件。
- `nominalRole` 只表示设计意图和展示语义,不限制实际流向。
- v2 文档不携带仿真结果;端口实际流向、流量幅值和累计质量由运行结果接口返回。
## 完整示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<System name="transfer-system" schemaVersion="2" unitSystem="SI" mediumReferenceVersion="1" amesimParameterEncodingVersion="1">
<Simulation tStart="0" tStop="2" step="0.1" maxStep="0.005" method="BDF"/>
<Components>
<Component id="cylinder_1" name="cylinder_1" type="cylinder" componentType="cylinder" x="90" y="180" rotation="0" mirrored="false">
<Port name="port_b" kind="physical" domain="pneumatic" nominalRole="outlet" positiveFlowDirection="intoComponent" side="right"/>
<Parameter name="volume" value="0.01"/>
<Parameter name="p0" value="35000000"/>
<Parameter name="T0" value="300"/>
</Component>
<Component id="tank_1" name="tank_1" type="tank" componentType="tank" x="420" y="180" rotation="0" mirrored="false">
<Port name="port_a" kind="physical" domain="pneumatic" nominalRole="inlet" positiveFlowDirection="intoComponent" side="left"/>
<Parameter name="volume" value="0.1"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="300"/>
</Component>
</Components>
<Connections>
<Connection id="edge-1" kind="physical" domain="pneumatic">
<Endpoint component="cylinder_1" port="port_b"/>
<Endpoint component="tank_1" port="port_a"/>
</Connection>
</Connections>
</System>
```
## Port
组件的 `rotation` 只能为 `0/90/180/270`,`mirrored` 表示水平镜像。它们只用于恢复画布布局和端口显示位置,不参与物理方程或流向判断。
| 属性 | 含义 |
|---|---|
| `name` | 组件模型中的稳定端口名 |
| `kind` | `physical` 或 `signal` |
| `domain` | 端口物理域,当前流体组件使用 `pneumatic` |
| `nominalRole` | 物理端口使用 `inlet/outlet/bidirectional`;信号端口使用 `input/output` |
| `positiveFlowDirection` | 物理端口固定为 `intoComponent`;信号端口省略 |
| `side` | 前端图标上的 `left/right` 布局位置,不参与物理求解 |
物理端口的流向由求解结果决定。一个名义出口的 `m_flow > 0` 表示该端口发生实际流入,可在结果层标记为倒流。
三通的三个端口当前保留仿真模型已有名称 `port_in/port_out1/port_out2`,但全部声明为 `bidirectional`,名称不构成方向约束。
## Connection
物理连接包含两个无序 `Endpoint`。第一个端点不代表上游,第二个端点也不代表下游;交换二者顺序不得改变仿真结果。
信号连接也使用两个 `Endpoint`,但必须分别携带 `role="source"` 和 `role="target"`。信号端口只允许 `output` 与 `input` 相连。
连接生成前必须验证:
- 组件和端口存在。
- 两端 `kind` 相同。
- 两端 `domain` 相同。
- 信号连接一端为 `output`,另一端为 `input`。
## v1 迁移
- v1 的字符串端口在加载时按组件注册表迁移成 v2 端口对象。
- v1 的 `source/sourcePort/target/targetPort` 在导出 v2 时转换为两个 `Endpoint`。
- 物理连接不继承 v1 的 source/target 方向。
- v1 文件仍由原 XSD 描述;新生成文件只输出 v2。
XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求解性由模型校验层负责。
### AMESim 介质定义与索引兼容
AMESim 介质定义继续使用普通的零端口 `Component`,不增加新的 XML
层级。其介质索引和物性选择仍以数值 `Parameter` 保存,例如:
```xml
<Component id="air_1" name="Air 1"
type="amesim_ideal_air_medium"
componentType="amesim_ideal_air_medium"
x="40" y="40">
<Parameter name="gi" value="1"/>
<Parameter name="property_model" value="0"/>
</Component>
```
氦气 Peng-Robinson 定义使用相同结构:
```xml
<Component id="helium_1" name="Helium 1"
type="amesim_helium_medium"
componentType="amesim_helium_medium"
x="40" y="140">
<Parameter name="gi" value="2"/>
<Parameter name="property_model" value="0"/>
</Component>
```
这里的 `property_model=0` 是氦气组件内部的 Peng-Robinson 选项编号;编译时
同时保留源 AMESim `fluidType=12/eosType=6` 元数据,不能把两个编号体系混用。
气动组件的 `gi=0` 表示内置的空气理想气体;正整数引用画布中的显式介质
定义。介质定义的 `property_model` 是该介质内部的物性计算模型编号,当前
空气定义中的 `0` 表示理想气体。界面中的 `gi` 下拉栏只显示索引数值,
`property_model` 下拉栏的名称和可选值则来自组件目录。
解析旧文档时,只有同时满足以下条件才执行旧 `gi` 兼容转换:
- 根元素没有 `mediumReferenceVersion`;
- 所有组件类型均可由当前注册表识别;
- 画布中不存在目录角色为 `amesimGasMediumDefinition` 的组件。
在这种可证明没有显式介质定义的旧文档中,缺失的 `gi` 会补为 `0`,旧
`gi=1` 会映射为 `0`,并返回语义层警告。带有
`mediumReferenceVersion="1"` 的新文档不会重解释正整数索引。
在物性计算模型下拉接口加入之前保存的介质定义可能只有 `gi`。无论是否存在
`mediumReferenceVersion`,解析器都会为缺失的 `property_model` 注入组件目录
声明的默认值,并返回 `AMESIM_GAS_PROPERTY_MODEL_DEFAULTED` 警告;重新保存后
该参数会显式写入 XML。
完成兼容转换后,解析器统一按介质引用版本 1 执行以下语义校验:
- 介质定义组件的 `gi` 必须是 `1..99` 的整数,且工程内不得重复。
- 气动组件的介质引用必须是 `0..99` 的整数。
- `gi=0` 始终引用内置空气理想气体;所有正索引必须存在对应介质定义。
- 同一个气动连通分量内只能使用一个 `gi`,不同的独立气动网络可以选择不同
介质。
相应错误码为 `AMESIM_GAS_MEDIUM_INDEX_INVALID`、
`AMESIM_GAS_MEDIUM_INDEX_DUPLICATE`、
`AMESIM_GAS_REFERENCE_INDEX_INVALID`、`AMESIM_GAS_REFERENCE_UNDEFINED` 和
`AMESIM_GAS_REFERENCE_CONFLICT`。XML 解析得到的规范化工程 JSON 总是输出
数值字段 `"mediumReferenceVersion": 1`;因此旧文档一经解析并重新保存,便
不再依赖旧版启发式迁移。
### AMESim 参数编码兼容
早期 ReactFlow 工程和 System XML 没有 `amesimParameterEncodingVersion`,并对
`MECMAS21.useFriction` 与 `MECMAS21.strib` 使用应用内部的 `0/1` 编码。读取这类
旧数据时,`0` 映射为规范 AMESim 编码 `1`,`1` 映射为 `2`。旧 System XML
发生转换时返回 `AMESIM_PARAMETER_ENCODING_MIGRATED` 语义层警告。
版本字段缺失(ReactFlow JSON 中也包括显式 `null`)专用于识别上述旧数据;
它不是当前客户端可省略的默认值。由于旧编码 `1` 与规范编码 `1` 含义相反,
所有按当前组件目录创建或保存工程的客户端都必须写出数值版本字段 `1`,不能
发送无版本的新数据。
带有 `amesimParameterEncodingVersion="1"` 的 XML,以及顶层数值字段
`"amesimParameterEncodingVersion": 1` 的 ReactFlow 工程,均已使用规范编码,
不得再重解释其 `1/2` 值。新导出的 XML 与规范化后的工程数据始终写出版本 1,
因此完成一次读取和保存后不再依赖旧编码迁移。
## 仿真模型编译接口
`POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作:
1. 按 `node.data.modelType` 创建 `app.simulation` 组件实例,并写入 SI 参数。
2. 将前端端口声明与组件注册端口逐项比对。
3. 按画布实际 `edges` 创建无方向物理连接,而不是按组件类型或拖入顺序推断拓扑。
4. 检查端口存在性、物理域兼容性、重复连接和未连接端口。
成功响应中的物理连接只包含两个 `endpoints`,不包含 `source/target`:
```json
{
"success": true,
"name": "transfer-system",
"components": [
{
"id": "cylinder_1",
"type": "cylinder",
"ports": [
{
"name": "port_b",
"kind": "physical",
"domain": "pneumatic",
"nominalRole": "outlet",
"positiveFlowDirection": "intoComponent",
"variables": [
{"name": "p", "role": "effort", "connectionRule": "equal"},
{"name": "m_flow", "role": "flow", "connectionRule": "sumToZero"},
{"name": "h_outflow", "role": "stream", "connectionRule": "streamMix"}
]
}
]
}
],
"connections": [
{
"id": "edge-1",
"kind": "physical",
"domain": "pneumatic",
"endpoints": [
{"component": "cylinder_1", "port": "port_b"},
{"component": "tank_1", "port": "port_a"}
]
}
],
"unconnectedPorts": []
}
```
一个物理端口当前只允许一条连接;需要分支时必须显式放置 `Tee` 等结点组件。这样拓扑不会通过“一个端口连多条线”隐式产生结点方程。
此接口完成模型实例化、端口契约校验、拓扑编译和压力-流量方程结构组装。编译结果中的 `pressureFlowSystem` 包含未知量、方程、数量及 `isSquare` 状态;方阵只表示结构数量平衡,不代表方程一定可解。
当前组件已提供可执行残差:气瓶和贮箱提供状态-压力约束,孔板提供流量守恒和压差-流量本构关系,三通提供等压零结点和流量守恒。XML 注册表中的管段使用准稳态 Darcy 阻性模型,同时提供流量守恒和双向压降关系。连接层根据端口契约生成 `p` 相等及 `m_flow` 代数和为零的残差。
`/api/reactflow/simulate-testmodel` 继续保留固定 TestModel 和动态管段,用于已有基线对比。XML 驱动仿真使用独立的通用半显式求解链路,不调用固定 TestModel 闭合器。
## 第二阶段:XML 解析与校验
第二阶段已经实现从 System XML v2 回到仿真网络的完整入口。解析过程固定分为三层:
| 层级 | `layer` | 负责内容 |
|---|---|---|
| XML | `xml` | 文档大小、XML 语法、禁止 DTD 和实体声明 |
| XSD | `schema` | v2 版本、元素顺序、必填属性、枚举、基础数值类型 |
| 模型语义 | `semantic` | 组件注册、端口契约、参数集合和范围、端点引用、物理域、连接占用及仿真设置 |
校验诊断统一包含:
```json
{
"severity": "error",
"layer": "semantic",
"code": "ENDPOINT_PORT_UNKNOWN",
"message": "Connection edge-1 references unknown port tank_1.port_x.",
"path": "/System/Connections/Connection[1]/Endpoint[2]",
"line": 18
}
```
未连接端口使用 `PORT_UNCONNECTED` 警告,不会阻止解析和网络编译;结构错误、接口不一致、参数错误和非法拓扑会使 `valid=false`。
### API
四个接口均直接接收 `Content-Type: application/xml` 的原始 XML 请求体:
- `POST /api/system-xml/validate`:无论成功与否都返回校验报告,便于编辑器实时显示问题。
- `POST /api/system-xml/parse`:成功时返回规范化工程 JSON;失败时返回 HTTP `422` 和结构化诊断。
- `POST /api/system-xml/compile-model`:成功时返回仿真网络、仿真设置和校验报告;失败时返回 HTTP `422`。
- `POST /api/system-xml/simulate`:完成校验、编译、仿真准备、代数闭合、stream 传播和时间积分;成功时返回组件与端口时间序列,失败时返回 HTTP `422` 和仿真层诊断。
示例:
```powershell
Invoke-RestMethod `
-Method Post `
-Uri http://127.0.0.1:8000/api/system-xml/validate `
-ContentType application/xml `
-InFile .\test\system.xml
```
组件参数和端口定义集中在 `app/simulation/registry.py`。ReactFlow JSON 编译和 XML 语义校验共用该注册表,新增组件时必须先在这里登记参数范围、默认值和端口契约。
## 第三阶段:XML 驱动仿真 MVP
第三阶段当前已经打通:
1. XML 中的组件、参数和无方向物理连接编译成 `app.simulation` 网络。
2. 仿真准备层检查未连接端口、方程数量、无储能代数孤岛和无阻力储能直连。
3. SciPy 非线性最小二乘求解每个时刻的端口压力与质量流量。
4. 根据求解后的实际流向迭代传播 `h_outflow`,并在三通处执行质量流量加权混合。
5. 动态组件自动拼装质量及内能导数,使用 XML 的 `tStart/tStop/step/maxStep/method` 开展积分。
6. 结果包含动态组件的 `m/U/p/T/rho/u/h`,以及全部物理端口的 `p/m_flow/h_outflow` 时间序列。
当前限制:
- 只支持注册表中的气动物理组件,不支持信号端口仿真。
- 所有物理端口在运行前必须完成连接;分支必须显式使用三通。
- 每个独立物理网络必须包含至少一个气瓶或贮箱作为压力和焓的储能锚点。
- 两个储能组件不能通过理想连接或纯三通直接耦合,必须在中间放置孔板或管段。
- XML 管段当前是准稳态阻性元件,`p0/T0` 用于名义密度和初始代数猜测,不包含管内储气动态。
- 当前 stream 混合是适合 MVP 的正则化近似,还不是 Modelica `inStream/actualStream` 的严格复刻。
- 当前是半显式 ODE/代数求解链路,不支持一般高指数 DAE 和事件系统。
运行示例:
```powershell
Invoke-RestMethod `
-Method Post `
-Uri http://127.0.0.1:8000/api/system-xml/simulate `
-ContentType application/xml `
-InFile .\test\system.xml
```
+284
View File
@@ -0,0 +1,284 @@
# System XML v3 协议
System XML v3 是 SystemSimulationApp 当前唯一的 XML 求解输入格式。它只描述可执行模型,不再承担 ReactFlow 画布存档职责。
机器可读结构见 [`schemas/system-simulation-v3.xsd`](../schemas/system-simulation-v3.xsd)。当前校验、解析、编译和仿真接口固定按 v3 处理,不会根据 `schemaVersion` 自动切换到 v1 或 v2。
## 1. 设计边界
v3 遵循一条简单规则:
> XML 保存“求解什么”,工程 JSON 保存“怎样编辑和显示”。
因此 XML 保留:
- 仿真起止时间、结果采样间隔、内部最大步长和积分方法;
- 组件实例 ID、后端模型类型、模型版本和完整 SI 参数;
- 每条连接的两个端点。
XML 不保存:
- 组件显示名称、画布坐标、旋转、镜像;
- 图标、端口显示侧和显示顺序;
- 端口的 `kind/domain/nominalRole/positiveFlowDirection/variables` 快照;
- 参数表达式、显示单位、科学记数法偏好;
- ReactFlow 的选择状态、撤销历史或仿真结果。
这些信息中,编辑器状态留在工程 JSON;端口物理合同由 `Component.type + Endpoint.port` 从后端组件注册表恢复。
## 2. 完整结构示例
下面的例子包含一条信号连接和一条机械连接,展示 v3 的全部结构元素:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<System name="signal-force-demo" schemaVersion="3" unitSystem="SI">
<Simulation
tStart="0"
tStop="1"
sampleStep="0.01"
maxStep="0.001"
method="BDF"/>
<Components>
<Component id="step_1" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="0"/>
<Parameter name="final" value="0"/>
<Parameter name="time" value="0.5"/>
</Component>
<Component id="force_1" type="amesim_forc" modelVersion="0.2.0">
<Parameter name="direction" value="1"/>
</Component>
<Component id="zero_1" type="amesim_f000" modelVersion="0.1.0"/>
</Components>
<Connections>
<Connection id="signal-1">
<Endpoint component="step_1" port="out"/>
<Endpoint component="force_1" port="res"/>
</Connection>
<Connection id="mechanical-1">
<Endpoint component="force_1" port="port_2"/>
<Endpoint component="zero_1" port="port_1"/>
</Connection>
</Connections>
</System>
```
XML 的外形是一棵树,模型本身仍是一张连接图。组件平铺在 `Components` 中,`Connections` 再用 `(component, port)` 地址建立拓扑。
固定骨架为:
```text
System
├─ Simulation
├─ Components
│ └─ Component *
│ └─ Parameter *
└─ Connections
└─ Connection *
├─ Endpoint
└─ Endpoint
```
顶层顺序固定为 `Simulation → Components → Connections`。每条 `Connection` 恰好包含两个 `Endpoint`。
## 3. `System` 根元素
| 属性 | 是否必填 | 规则 |
| --- | --- | --- |
| `schemaVersion` | 是 | 固定为 `3` |
| `unitSystem` | 是 | 固定为 `SI` |
| `name` | 否 | 非空工程名称;省略后解析模型使用 `untitled` |
`schemaVersion="3"` 已经表示唯一的介质引用和 AMESim 离散参数编码语义。根元素不接受额外的版本提示字段,也不会据此触发兼容猜测。
## 4. `Simulation`
| 属性 | 含义 | 主要校验 |
| --- | --- | --- |
| `tStart` | 仿真开始时刻 | 必须是有限数值 |
| `tStop` | 仿真结束时刻 | 必须有限且大于 `tStart` |
| `sampleStep` | 结果相邻采样点的时间间隔 | 必须大于 0,且整个区间最多生成 10001 个采样点 |
| `maxStep` | 自适应积分器单个内部步的上限 | 必须大于 0 |
| `method` | 积分方法 | `RK45/RK23/DOP853/Radau/BDF/LSODA` |
`sampleStep` 和 `maxStep` 不是一回事:
- `sampleStep` 决定结果曲线多久保存一个点;
- `maxStep` 限制求解器内部一次最多前进多久;
- 自适应求解器可以因为误差、事件或试探状态失败而走得比 `maxStep` 更短。
采样点数量会在创建时间数组前计算。若区间长度不可表示为有限数、请求超过 10001
点,或在当前浮点精度下无法得到包含 `tStart/tStop` 的严格递增时间序列,输入会在
仿真前被拒绝,不会把超大或重复的 `t_eval` 交给积分器。
工程 JSON 为兼容现有前端仍把采样字段命名为 `simulation.step`;导出 v3 时必须映射为 `Simulation/@sampleStep`。
## 5. `Component` 与 `Parameter`
### 5.1 `Component`
| 属性 | 是否必填 | 含义 |
| --- | --- | --- |
| `id` | 是 | 当前系统内唯一的实例 ID;连接通过它引用组件 |
| `type` | 是 | 后端组件注册键,例如 `amesim_forc` |
| `modelVersion` | 是 | 该 `type` 的模型合同版本 |
语义校验要求 `modelVersion` 与当前注册表完全一致。版本不匹配时返回 `COMPONENT_MODEL_VERSION_MISMATCH`,不会静默套用新模型默认值或自动改写旧参数。
v3 不保存 `name/componentType/x/y/rotation/mirrored`。其中:
- 显示名称和画布位置只属于工程 JSON;
- `componentType` 在当前系统中与后端 `type` 重复;
- 旋转和镜像只属于画面布局,不再改变求解方程。
### 5.2 `Parameter`
```xml
<Parameter name="direction" value="-1"/>
```
每个参数只保存稳定参数名和已经换算到 SI 基准单位的数值。v3 要求组件显式列出当前模型注册表中的全部参数:
- 参数名重复会报错;
- 缺少注册参数会报 `PARAMETER_REQUIRED_MISSING`;
- 出现未知参数会报 `PARAMETER_UNSUPPORTED`;
- 超范围或不在枚举集合内会报 `PARAMETER_VALUE_INVALID`。
前端和后端导出器会先用注册默认值补齐工程 JSON 中省略的参数,再写入 XML。XML 解析器本身不替缺失参数猜默认值。
### 5.3 AMESim 介质引用
介质仍用普通参数表达,不增加额外 XML 层级:
- `gi=0`:内置理想空气;
- 对普通气动组件,`gi=1..99` 引用同一 XML 中显式介质定义组件的索引;
- 对介质定义组件自身,`gi=1..99` 表示它所定义的索引;
- 介质定义的 `gi` 必须唯一;
- 一个连通气动网络只能使用同一 `gi`;
- `property_model` 也是普通必填参数,由对应介质模型注册表解释。
## 6. 端口与连接
### 6.1 为什么 v3 没有 `Port` 元素
端口不是组件实例的自由数据,而是组件模型合同的一部分。后端根据:
```text
Component.type + Endpoint.port
```
从注册表恢复:
- `kind`:物理或信号;
- `domain`:气动、机械或信号;
- `nominalRole`:输入、输出或物理名义角色;
- `positiveFlowDirection`:物理流变量统一以进入组件为正;
- 端口变量、单位和 `equal/sumToZero/streamMix/directed` 连接规则。
XML 不能通过写一个新端口名来扩展组件,也不能通过修改字符串把气动口变成机械口。
### 6.2 `Connection`
`Connection/@id` 可省略。省略时解析器按文档顺序生成 `connection_1`、`connection_2` 等内部 ID;显式 ID 和生成 ID 都必须唯一。
连接本身不再保存 `kind` 或 `domain`。语义层解析两个端点的注册端口后检查:
- 组件和端口存在;
- 两端同为物理端口或同为信号端口;
- 两端 `domain` 和完整变量合同一致;
- 信号连接恰好连接一个 `output` 和一个 `input`;
- 一个信号输出可以驱动多个输入,但每个信号输入只能有一个驱动;
- 同一物理端口只使用一次;分支必须使用显式 Tee/节点组件;
- 不允许自连接或重复端点对。
### 6.3 两个 `Endpoint` 的顺序
物理连接的两个端点无序,交换顺序不改变方程或实际流向。
信号连接也不写 `role="source"` 或 `role="target"`。发送方和接收方由注册端口的 `output/input` 合同确定,而不是由 XML 中的先后顺序决定。导出器可以为了便于阅读把输出端写在前面,但求解器不能依赖这一顺序。
## 7. `AmesimForc.direction`
`amesim_forc` 从模型版本 `0.2.0` 开始使用显式物理参数:
```xml
<Parameter name="direction" value="1"/>
```
它只允许:
- `+1`:默认方向,方程为 `port_2.f + inputForce = 0`;
- `-1`:反向,方程为 `port_2.f - inputForce = 0`。
组件的图标旋转和镜像不会改变该参数,也不会改变求解结果。用户要反转施力方向时必须修改 `direction`,而不是旋转图标。
### 7.1 MECMAS21 离散选项编码
`amesim_mecmas21` 只使用 AMESim 原生的 `1/2` 编码。以两个布尔选项为例:
| 参数 | `1` | `2` |
| --- | --- | --- |
| `useFriction` | 不启用摩擦 | 启用摩擦 |
| `strib` | 不使用 Stribeck 效应 | 使用 Stribeck 效应 |
工程 JSON v1 和 System XML v3 都直接保存上述值。后端不会把 `0/1` 自动换算成
`1/2`,也不会根据缺失的兼容标记猜测工程含义;不在目录选项集合内的值会被拒绝。
## 8. 工程 JSON 与 System XML 的分工
| 信息 | 工程 JSON | System XML v3 |
| --- | --- | --- |
| 组件实例 ID、模型类型 | 保存 | 保存 |
| 模型版本 | 每个节点显式保存并与目录核对 | 每个组件显式保存 |
| 数值参数 | 保存编辑值及显示信息 | 保存完整 SI 数值 |
| 组件显示名、坐标、旋转、镜像 | 保存 | 不保存 |
| 端口快照、`side/order` | 保存供编辑器使用 | 不保存 |
| ReactFlow `source/target/handle` | 保存 | 转成两个 `Endpoint` |
| 端口物理合同 | 目录快照用于前端检查 | 不重复保存,由注册表恢复 |
| 参数表达式、显示单位 | 保存 | 不保存 |
| 仿真结果 | 不作为模型输入 | 不保存 |
因此 `/api/system-xml/parse` 返回的是规范化执行模型,不是可无损恢复原画布的 ReactFlow 工程文件。需要继续编辑时,应保存和打开工程 JSON;需要校验、交换或求解时,使用 System XML v3。
## 9. 校验与 API
校验固定分三层:
| 层级 | 负责内容 |
| --- | --- |
| XML | 5 MiB 大小限制、语法、安全解析、禁止 DTD/实体和网络访问 |
| XSD | 元素顺序、必填属性、数量、基础数值类型、`schemaVersion=3`、`unitSystem=SI` |
| semantic | 模型及版本、完整参数、端点引用、注册端口兼容性、介质引用和拓扑占用 |
当前相关接口都直接接收 `Content-Type: application/xml` 的原始 v3 XML:
- `POST /api/system-xml/validate`:返回三层校验报告;
- `POST /api/system-xml/parse`:返回规范化执行模型;
- `POST /api/system-xml/compile-model`:返回编译后的网络和仿真设置;
- `POST /api/system-xml/simulate`:同步运行并返回完整结果;
- `POST /api/system-xml/simulate-stream`:通过 NDJSON 返回心跳、进度和最终结果。
`POST /api/reactflow/system-xml` 可将工程 JSON 导出为 v3;当前前端也能在浏览器中直接生成同一结构。
通过 XML/XSD/语义校验只说明输入合同正确。完整仿真前仍会检查动态储能锚点、未连接物理端口、方程结构和不允许的理想储能直连等可求解条件。物理连通岛只由物理组件和物理连接构成;控制信号扇出不会把两个独立气路或机械网络合并成一个物理岛。
## 10. 旧版本处理边界
当前 API 不读取或自动转换 System XML v1/v2,也不会为不匹配的
`Component/@modelVersion` 选择旧模型实现。旧输入会在 XSD 或语义层被明确拒绝。
本阶段不定义转换步骤、迁移注册表或兼容承诺。仓库不再保存旧版 XSD、规范或示例;
需要进入当前系统的模型必须由来源端重新导出为 v3,不能只修改版本号。
## 11. 事实来源
- XSD:`schemas/system-simulation-v3.xsd`
- XML 数据类、解析和三层校验:`app/system_xml.py`
- 后端 JSON→XML 导出及 XML 仿真路由:`app/main.py`
- 前端 XML 生成:`frontend/src/App.tsx` 中的 `buildSystemXml()`
- 组件和端口事实来源:`app/simulation/registry.py`、`app/simulation/core/ports.py`
- 网络最终兼容检查:`app/simulation/systems/network.py`
@@ -0,0 +1,542 @@
# SystemSimulationApp 后端求解逻辑与效率优化调研(通俗版)
> 调研基线:2026-08-12(System XML v3 迁移后),依据当前仓库代码、配置、说明文档与测试。
> 本文所称“主求解路径”是当前前端实际调用的 System XML 流式接口;固定 TestModel 和 Test MQL 接口另行说明。文中没有把静态代码分析冒充 CPU、内存实测。
## 0. 三分钟读懂
### 0.1 求解器到底在做什么
先不管 ODE、RHS、BDF 这些名字。把一次仿真想成制作一段工程动画:
1. **检查装配图。** 气管有没有漏接,控制线方向对不对,模型参数是否齐全。
2. **读取当前“存量”。** 例如气室里有多少气体和能量,质量块现在的位置和速度。
3. **让当前瞬间自洽。** 根据这些存量,把此刻的压力、流量和力反复对账,直到连接规则和组件方程同时满足。
4. **计算变化速度。** 得出“下一小段时间内,质量、能量、位置、速度将怎样变化”。
5. **内部小步前进。** 求解器自己决定每次走多小;变化剧烈时会缩短步长。
6. **按用户指定时刻留快照。** 内部可能算很多小步,但结果文件只在 XML v3 的 `sampleStep` 指定的时刻保存数值。
7. **把进度和结果交给前端。** 运行中发心跳/进度,结束时一次性发送完整曲线数据。
项目里的技术说法“**半显式 ODE + 每次变化率计算前做代数闭合**”,翻译成人话就是:**会积累的量用时间积分向前推;必须在当前瞬间成立的关系,每次都先对账求平衡。**
### 0.2 用仓库里的真实案例贯穿全文
`tests/test_generic_system_xml_simulation.py:86-145` 有一条完整回归气路:
```text
高压气缸 低压储气罐
0.01 m³、500 kPa 0.1 m³、100 kPa
──> 节流孔 ──> 1 m 管路 ──>
```
图中箭头只表示这个初始压差下**预计**的气流方向,不代表物理连线本身有 `source/target` 方向。回归测试检查了:
- 气缸压力下降;
- 储气罐压力上升;
- 总质量和总能量守恒;
- 把工程 JSON 中物理边的 `source/target` 对调,结果不变。
测试只运行 `0~0.01 s`,设置结果采样 `sample_step=0.005 s`、内部上限 `max_step=0.001 s`、`method=BDF`(`tests/test_generic_system_xml_simulation.py:86-145, 254-300, 354-372`)。在 System XML v3 中,前两个字段分别写成 `sampleStep` 和 `maxStep`。它们可以这样理解:
```text
结果快照: 0 s -------- 0.005 s -------- 0.01 s
内部计算: 0 s - 小步 - 小步 - 小步 - ... - 0.01 s
每个内部步最多 0.001 s,也可能更短或被重算
```
- `sampleStep`:相机隔多久保存一张结果快照;
- `max_step`:求解器一次内部前进最多能走多远;
- `BDF`:一种适合系统中“有的变化快、有的变化慢”的自适应算法。本文不需要展开它的公式。
另一个真实案例位于 `tests/test_amesim_pnvo001_signal_xml.py:87-204, 230-246`:高、低压氦气室之间有一个阀,阶跃信号在 `0.04 s` 从 0 跳到 1。求解器会像遇到“定时闹钟”一样,准确停到事件时刻,更新阀命令,再从该时刻继续积分。
### 0.3 常见术语翻译
| 技术词 | 先这样理解 | 本项目里的具体含义 |
| --- | --- | --- |
| 动态状态(state) | 会随时间积累的存量 | 气体质量/内能 `[m,U]`,或机械速度/位置 `[v,x]` |
| 代数量 | 当前瞬间的仪表读数 | 压力、流量、连接力等;由当前状态和约束求出 |
| 代数闭合(closure) | 把所有账对平 | 让组件方程、连接守恒和当前状态同时成立 |
| 变化率计算(RHS) | 算下一刻变化有多快 | 输入当前状态,输出 `dm/dt`、`dU/dt`、加速度等 |
| ODE 积分 | 根据变化率向时间前进 | SciPy 的 BDF、Radau、RK45 等 |
| DAE | 状态和瞬时约束一起交给专用求解器 | 当前主内核不是通用 DAE 求解器 |
| stream 焓 | 气体随流动携带的“能量标签” | `h_outflow` 按实际流向传播/混合,不是两端温度相等 |
| 非线性迭代(`least_squares`) | 直接算不出时反复试值 | 压力流量快速路径失败后的回退方案 |
| Jacobian | “改一个量会影响哪些方程”的灵敏度地图 | 可帮助 BDF/Radau 和非线性求解少做试算 |
| dense output | 两个内部步之间的插值尺 | 用来补采样点和定位机械事件 |
| NDJSON | 一行一个 JSON 消息 | 同一 HTTP 响应中依次发送心跳、进度、结果 |
| worker | 后台办事通道 | 当前脚本是一个 Uvicorn worker;流式任务另开求解线程 |
只想了解系统如何运行,可以读第 0、3、5、6、9、10、13 节;需要改求解器时,再阅读其余技术细节和第 15 节代码索引。
## 1. 结论先行
1. **[已实现] 当前主内核是“半显式 ODE + RHS 内代数闭合”。** 动态组件只把储能状态交给 ODE 积分器;每次计算导数前,系统先传播信号、刷新热力状态、求压力/流量代数网络、传播变容边界、迭代 stream 焓并更新机械加速度。它不是通用 DAE 求解器,也不等价于完整 Modelica `inStream/actualStream` 语义(`README.md:18-20`、`app/simulation/README.md:174-195`)。
2. **[已实现] 当前前端主链路是 System XML 流式仿真。** 浏览器生成 XML,经 `POST /api/system-xml/simulate-stream` 发送;后端以 NDJSON 返回心跳与进度,最后在一个 JSON 行中返回完整结果。不是 WebSocket 或标准 SSE。
3. **[已实现] XML v3 的 `sampleStep` 是输出采样间隔,不是固定积分步长。** 内部的 `max_step`(XML 为 `maxStep`)才是自适应积分步长上限;`BDF/Radau/LSODA/RK45/RK23/DOP853` 均受支持。流式运行因总是提供取消检查,会使用 SciPy 低层求解器逐个已接受步推进。
4. **[已实现] 每次 RHS 的完整闭合至少调用 2 次、存在外部容积传播时最多调用 3 次压力流量求解。** 积分结束后,每个输出采样点又执行一次完整闭合并提取全部公开结果。这是当前最明确的单任务重复工作来源。
5. **[已实现] 代数求解已有因果化快路径。** 压力流量求解器预编译相等组与显式流量计划,种子残差足够小时不调用非线性优化;否则对全局未知向量调用 SciPy `least_squares`,当前未提供解析 Jacobian 或 `jac_sparsity`。
6. **[已实现] 当前启动脚本是一个 Uvicorn worker。** 每个流式任务再创建一个无并发上限的 daemon 线程和无界队列;没有进程池、集中任务队列、CPU/内存配额或持久化作业系统。同步仿真端点还会在 `async def` 中直接执行 CPU 密集代码。
7. **[推断] 优化应分两条线:**
- 单算例速度:减少闭合 pass、代数未知量与残差装配,缓存热物性,改善外层积分尺度/Jacobian,减少后处理重算;
- 服务吞吐与资源稳定性:有界进程 worker、结果分块/按需返回、任务表主动清理和前端结果存储降副本。
## 2. 证据标签与范围
- **[已实现]**:当前可执行代码或测试直接体现。
- **[约定]**:配置、Schema、类型或仓库说明规定,但不一定被每条入口完整执行。
- **[推断]**:从调用结构推导的资源或性能判断,尚无仓库内基准数据。
- **[发现]**:实现间的不一致、诊断缺口或潜在效率风险。
本次覆盖四条后端执行路径:
| 路径 | 是否为当前 UI 主路径 | 数值行为 |
| --- | --- | --- |
| `POST /api/system-xml/simulate-stream` | 是 | XML 校验、拓扑编译、通用系统积分;NDJSON 进度与最终结果 |
| `POST /api/system-xml/simulate` | 否 | 同一通用求解函数;同步返回、无任务登记/流式进度 |
| `POST /api/reactflow/simulate-testmodel` | 否 | 固定 TestModel 专用闭包与产物生成,不按任意 ReactFlow 边拓扑求解 |
| `POST /api/reactflow/simulate-test-mql` | 否 | 当前只返回结构/采样摘要和全零状态数组,不执行 132 状态时域积分 |
## 3. 主求解调用链
```mermaid
flowchart TD
A["前端校验参数并生成 System XML"] --> B["POST simulate-stream"]
B --> C["安全解析 + XSD v3 + 语义校验"]
C --> D["XML 执行模型直接编译 SimulationNetwork"]
D --> E["准备检查与求解器构造"]
E --> F["一致初始代数闭合"]
F --> G["自适应 ODE 逐步积分"]
G --> H["每个 RHS:信号/热力/代数/stream/机械闭合"]
G --> I["事件定位、状态重置、求解器重启"]
G --> J["逐采样点完整后处理闭合"]
J --> K["完整结果作为末条 NDJSON 返回"]
```
### 3.1 前端形成输入
**[已实现]** 前端先检查模型、表达式和仿真设置,再由 `buildSystemXml()` 在浏览器内构造精简 v3 XML。参数表达式会先求值,并按选定显示单位转成 SI 基准数值;求解期间这些组件参数保持静态,不存在前端实时调参或联合仿真输入通道(`frontend/src/App.tsx` 中的参数解析与 `buildSystemXml()`;`app/simulation/core/base.py:65-95`)。
前端生成 `simulationId`,通过 `fetch` 发送 XML,请求头携带该 ID;响应类型要求为 `application/x-ndjson`(`frontend/src/App.tsx` 中的运行入口与 `streamSystemSimulation()`)。
### 3.2 XML 校验、解析和网络编译
主序列见 `app/main.py` 的 `run_system_xml_simulation()`:
1. `validate_system_xml_document()` 执行大小/安全解析、v3 XSD 和语义校验;
2. XML 解析为只含求解信息的规范化执行模型,不重建 ReactFlow 画布;
3. `compile_system_xml_network()` 创建 `SimulationNetwork`;
4. 构造 `GenericFluidSystem` 与 `SolveIVPConfig`;
5. 调用通用 `simulate()`;
6. 汇总验证、模型接口、数值结果和错误诊断。
XML 安全层限制 5 MiB、禁用 DTD/实体和网络访问(`app/system_xml.py` 的安全解析与 `validate_system_xml_document()`)。
XML 编译最终进入 `_compile_solver_network()` 的两阶段装配(`app/main.py`):
- v3 语义层先核对组件 `modelVersion`、完整参数和端点引用;编译层再消费零端口介质定义节点;
- 按气动连通区域解析介质引用;
- 实例化实际组件,并根据注册模型恢复端口类型、角色和变量合同;
- 最后建立网络连接。
**[已实现]** v3 不携带坐标、旋转、镜像或端口显示侧,布局不会进入求解。`AmesimForc` 的物理正反由模型参数 `direction=+1/-1` 决定;旋转/镜像图标不会改变力方程(`app/simulation/components/amesim/mechanical/translational.py`)。
### 3.3 求解前结构检查
创建通用系统前,`generic_simulation_preparation_issues()` 检查(`app/simulation/systems/generic.py:93-201`):
- 所有物理端口已经连接;
- 压力流量系统的未知量数与方程数相等;
- 网络至少存在一个动态储能组件;
- 每个相连物理岛具有动态储能锚点;
- 不允许多个理想储能元件无阻力直接耦合。
这些检查是当前可求解结构的边界,不表示任意声明了 `PortDefinition` 的网络都能被通用求解器处理。
## 4. 数学结构与状态组织
本节的核心区别只有一个:**有些量要“记住过去并随时间积累”,有些量只要在当前瞬间满足约束。**
在高压气缸向低压储罐放气的案例里,气室内的气体质量和能量属于前者;端口压力和通过节流孔的瞬时流量属于后者。求解器先保存前者,再用它们求出后者。下面的“动态状态”“代数未知量”只是这两组量的技术名称。
### 4.1 动态状态
**[已实现]** 动态组件把自身状态拼成全局 ODE 向量:
- 固定/变容气室及部分动态管路通常使用质量与内能 `[m, U]`;导数由质量流、焓流和换热构成。PNCH023 示例见 `app/simulation/components/amesim/storage/chambers.py:111-218`。
- PNL0003 具有两个容积单元,使用四维 `[m1, U1, m2, U2]`(`app/simulation/components/amesim/flow/pipes.py:1043-1078`)。
- MECMAS21 的机械状态是 `[v, x]`,导数是 `[a, v]`(`app/simulation/components/amesim/mechanical/translational.py:569-700`)。
`MechanicalStateReducer` 会按机械连接中的 `x/v` 等值关系将刚性相连的质量归组,每组只保留一套 `[v, x]` 状态,从而避免重复积分同一个运动自由度(`app/simulation/solvers/mechanical.py:225-427`)。
### 4.2 代数未知量与方程
网络从物理端口合同和组件残差构造代数系统(`app/simulation/systems/network.py:159-237`):
- 气动端口的主要代数变量是 `p` 和 `m_flow`;
- 机械端口包含 `x`、`v`、`f`,其中已归属动态状态的坐标会被状态/等值关系约束;
- 连接对 `effort/equal` 生成两端差值,对 `flow/sumToZero` 生成两端和值;
- 组件再提供状态约束、构成关系、质量/力守恒等残差。
stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积也不进入同一通用连接残差,而由专用 resolver 处理。
### 4.3 压力流量因果化与非线性回退
`PressureFlowSolver` 初始化时预编译 effort 等值组、显式流/力赋值计划和未知量布局(`app/simulation/solvers/algebraic.py:86-106, 637-729`)。每次 `solve()`:
1. 从上一解与当前动态状态播种端口未知量;
2. 传播状态拥有的压力、位移和速度;
3. 执行可显式求值的构成关系与守恒关系;
4. 处理单边接触约束;
5. 若缩放后残差不超过 `1e-7`,直接返回且 `evaluations=0`;
6. 否则调用 `scipy.optimize.least_squares`。
非线性回退当前参数为 `x_scale="jac"`、`ftol=xtol=gtol=1e-10`、`max_nfev=500`,没有传入解析 Jacobian 或 `jac_sparsity`(`app/simulation/solvers/algebraic.py:771-968`)。
**[推断]** 即使快速路径经常命中,固定的多次全网扫描、残差重建和热物性刷新仍会发生;一旦回退到有限差分 `least_squares`,成本会随全局未知量数量快速上升。
## 5. 每次导数计算的代数闭合
这一步就是第 0 节所说的“当前瞬间对账”。以高压气缸—节流孔—管路—低压储罐为例,求解器需要同时保证:
- 接头压力相容;
- 从一个组件流出的质量等于进入另一个组件的质量;
- 节流孔和管路自己的压差—流量关系成立;
- 气体携带的能量按实际流向交给下游;
- 若还有机械活塞或控制信号,它们在同一时刻也要一致。
代码目前不是一次对完,而是按固定顺序做若干轮专项检查。因此一个“计算变化率”的请求会调用 **2 次压力/流量闭合**;涉及外部容积传播时会调用 **3 次**。这也是后文首要优化方向。
`GenericFluidSystem._close_current_state()` 的实际顺序见 `app/simulation/systems/generic.py:258-290`:
| 顺序 | 操作 | 目的 |
| ---: | --- | --- |
| 1 | `SignalResolver.solve(time)` | 更新时间信号源并从 output 传播到 input |
| 2 | 刷新动态组件热力端口 | 由当前 `m/U/V` 恢复压力、温度、焓等 |
| 3 | 第一次 `PressureFlowSolver.solve()` | 闭合当前压力、流量、机械端口代数关系 |
| 4 | `PneumaticVolumeResolver.solve()` | 沿气动连接传播外部 `volume/volume_flow` |
| 5 | 若发生容积传播,再刷新热力状态并第二次求压力/流量 | 让变容边界进入气室状态关系 |
| 6 | `StreamResolver.solve()` | 按实际流向迭代传播/混合 `h_outflow` |
| 7 | 无条件再次求压力/流量 | 让依赖 stream/温度的构成关系重新闭合 |
| 8 | 更新机械约束加速度 | 为机械状态导数准备 `a` |
随后 `rhs()` 才收集各动态组件的导数(`app/simulation/systems/generic.py:298-301`)。
因此:
- 无外部容积传播时,每次闭合固定有 **2 次**压力流量求解;
- 有外部容积传播时固定有 **3 次**;
- stream 默认相对容差 `1e-9`、最多 100 次迭代,每轮复制端口焓并扫描组件/端口(`app/simulation/solvers/stream.py:29-119`)。
**[发现]** 这套固定顺序没有按模型实际能力裁剪。例如没有信号、没有外部容积源或 stream 不反向影响构成关系的网络,仍经过对应全网 pass。是否能安全删去某一 pass 必须由依赖关系和回归测试决定,不能只凭某个算例结果不变。
## 6. 初始化、积分参数与推进方式
可以把初始化理解为“先摆好第 0 帧”,把积分理解为“根据每一帧的变化速度继续制作后续帧”。初始化并不会自动修改所有不合理的初始存量;它主要是在给定初始质量、能量、位置和速度后,求出与之匹配的端口压力、流量和力。
### 6.1 初始状态
`consistent_initial_state_vector()` 取得组件构造时形成的初值,应用该状态并执行一次完整代数闭合,然后原样返回状态向量(`app/simulation/systems/generic.py:292-296`)。
**[重要边界]** 这不是通用 DAE 一致初值求解:它不会联合调整微分状态及导数,只求“给定当前动态状态时”的端口代数变量。固定 TestModel 的专用压力投影/初始化逻辑不能外推为通用 XML 求解器能力。
### 6.2 参数来源及真实含义
初学者最需要先分清 XML v3 的 `sampleStep` 和 `maxStep`(进入 Python 后分别是 `sample_step` 和 `max_step`):
- `sampleStep` 只控制**结果多久记一条**;
- `max_step` 才限制**内部一次最多走多远**;
- 自适应求解器可以走得比 `max_step` 更短,也可能先试一步、发现误差过大后退回来重算。
所以,把 `sampleStep` 从 0.1 改成 0.01 通常会让输出曲线更密、结果占用更多内存,但它不等价于命令求解器固定每 0.01 秒算一步。
| 参数 | 当前来源/默认值 | 实际用途 |
| --- | --- | --- |
| `t_start / t_stop` | 前端与 Pydantic 默认 `0 / 2 s` | 积分区间 |
| `sampleStep`(内部 `sample_step`) | 默认 `0.1 s` | 仅生成输出 `t_eval` 采样网格;工程 JSON 仍暂名 `simulation.step` |
| `maxStep`(内部 `max_step`) | 默认 `0.005 s` | 自适应求解器内部已接受步的上限 |
| `method` | 默认 `BDF` | BDF、Radau、LSODA、RK45、RK23、DOP853 |
| `rtol` | 通用 XML 路径硬编码 `1e-5` | 外层 ODE 相对误差;用户不可配置 |
| `atol` | `SolveIVPConfig` 标量默认 `1e-8` | 同时用于不同量纲的全部状态;用户不可配置 |
| `first_step` | 默认 `None` | 交给 SciPy;用户不可配置 |
| 代数残差容差 | `1e-7` | 压力流量快速路径/接受标准 |
| 代数最大评估 | `500` | 单次 `least_squares` 上限 |
| stream 容差/迭代 | `1e-9 / 100` | 焓传播固定点 |
| 采样数上限 | `10001` | 限制输出样本,不限制 RHS 次数或事件数 |
前端/Pydantic 默认值见 `frontend/src/App.tsx` 的仿真默认配置和 `app/main.py:131-137`;通用路径构造 `SolveIVPConfig` 见 `app/main.py:684-701`;采样网格见 `app/simulation/systems/generic.py:204-225`。
**[已实现]** `sampleStep` 生成的采样网格会确保包含 `t_stop`,并在超过 10001 点时拒绝;它不会把 BDF 变成固定步算法。实际 RHS 次数由自适应误差控制、Jacobian 估计、拒绝步、事件重启和 `max_step` 共同决定。
### 6.3 逐步推进分派
`integrate_ode()` 位于 `app/simulation/solvers/solver.py:917-1009`:
- 有取消检查、信号断点或机械状态事件时,使用 SciPy 的低层 BDF/DOP853/LSODA/RK23/RK45/Radau 类逐步推进;
- 无上述需求时,可一次调用常规 `solve_ivp`;
- 当前流式接口总会传 `cancel_check`,所以总走逐步路径;
- 同步 XML 接口只有在没有断点/状态事件时才可能走一次性 `solve_ivp`。
逐步路径在每个已接受步上:
1. 检查取消;
2. 推进一步;
3. 构造 dense output,并插入跨越到的 `t_eval` 样本;
4. 检测状态事件;
5. 报告进度;
6. 必要时重建求解器。
Peng–Robinson 试探状态越界会抛 `RecoverableTrialStateError`;代码回到最后已接受状态,将 `max_step` 减半,最多重试 16 次(`app/simulation/solvers/solver.py:528-914`)。
仓库有固定 RK4 回退,但通用压力流量求解器本身依赖 SciPy;因此它不能被视为一般流体网络在无 SciPy 环境下的完整替代方案。
## 7. 事件、取消与停止
### 7.1 信号离散时刻
STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件左侧的相邻浮点时刻,再在精确事件时间更新信号并重建求解器,连续动态状态保持不变(`app/simulation/solvers/signal.py:70-99`、`app/simulation/components/amesim/signals/sources.py:128-141`)。
### 7.2 机械端挡
机械状态事件在已接受步的 dense state 上检查端挡穿越,最多 60 次二分定位;命中后按塑性/恢复系数重置位置和速度并重启积分器。同一时刻最多允许 64 次链式状态重置(`app/simulation/solvers/mechanical.py:430-627`、`app/simulation/solvers/solver.py:92-166`)。
**[约定]** 这是为信号断点与机械端挡编写的专用事件框架,不是可由任意组件声明残差事件的通用高指数 DAE 框架。
### 7.3 协作取消
流式任务的取消端点只设置共享 `threading.Event`。积分器在已接受步、重试边界等检查点协作停止;若正在执行一次耗时的热物性、stream 或 `least_squares` 调用,取消不能立即抢占(`app/main.py:535-547`、`app/simulation/solvers/solver.py:917-1009`)。
停止后若至少已有两个有效采样点,通用系统可整理并返回部分结果;相关行为由 `tests/test_generic_system_xml_simulation.py:433-533` 覆盖。
## 8. 后处理与现有诊断
### 8.1 结果后处理
积分完成后,`GenericFluidSystem.simulate()` 重置机械约束模式,并对每个 `solution.t`:
1. 重新应用状态;
2. 再执行一次完整 `_close_current_state()`;
3. 提取所有组件级及端口级公开结果变量。
见 `app/simulation/systems/generic.py:397-474`。
**[推断]** 采样密集或结果变量多时,这会形成明显的第二计算阶段;此时内存中还保留积分状态矩阵,CPU 与内存峰值可能重叠。
### 8.2 当前返回的诊断
**[已实现]** 结果包含:
- 状态数、采样数;
- 压力流量 `solveCount`、最大残差、最大单次评估数;
- stream 最大迭代数;
- 停止状态及部分错误上下文。
**[发现]** `_close_current_state()` 中局部变量 `algebraic` 会被后续 pass 覆盖;最大残差/评估统计只采集每次闭合最后一次压力求解,而 `solveCount` 才累计了全部调用。现有响应还没有外层积分 `nfev/njev/nlu`、接受/拒绝步、重启次数、分阶段墙钟时间、热物性调用数、峰值 RSS、队列深度和结果字节数。
因此本文可静态定位重复工作,但不能用现有诊断精确量化每个热点的时间占比。
## 9. 求解时前后端交流
主路径可以压缩为下图:
```text
浏览器 ──一次 POST:完整 System XML──────────────> 后端
浏览器 <──同一长连接:心跳、进度、心跳、进度──── 后端求解线程
浏览器 <──最后一个消息:完整结果 JSON─────────── 后端
浏览器 ──需要时另发取消 POST───────────────────> 后端
```
这里的“流式”主要是**进度消息流式**,不是每算出一段曲线就立刻传一段曲线。最终数值序列仍在末尾一次性返回。
### 9.1 当前协议
| 阶段 | 通信 | 当前行为 |
| --- | --- | --- |
| 提交 | 一个 HTTP POST | 请求体为完整 XML;`X-Simulation-Id` 标识任务 |
| 运行 | 同一响应上的 NDJSON | 进度事件、错误事件、5 秒心跳 |
| 完成 | 同一 NDJSON 流最后一行 | 一次性携带完整 `SimulationResult` |
| 用户取消 | 另一个短 POST | 设置协作取消事件;原流继续等待终态 |
| 流断开/停滞恢复 | GET 状态 | 每 500 ms 轮询,最多 30 秒 |
后端路由见 `app/main.py:589-634, 773-906`,前端解析、取消和恢复见 `frontend/src/App.tsx` 中的 `streamSystemSimulation()` 及相邻任务控制函数。
**[已实现]** 后端每 5 秒无队列事件时直接发送 heartbeat。通用系统按进度至少变化 0.25% 才发送积分进度,通常至多约 400 条积分进度事件(`app/simulation/systems/generic.py:318-343`)。
前端规则:
- 30 秒没有收到任何字节:连接超时;
- 60 秒只收到心跳而没有真实积分进度:判定 stalled 并请求取消;
- 正常运行不是轮询,轮询仅用于异常恢复。
**[发现]** 合法但单个已接受步/闭合超过 60 秒时,前端可能误判停滞。后端结果事件的 `phase` 使用 `completed/stopped/stalled/failed`,前端事件类型却声明 `"complete"`;运行时当前没有按该字段做严格校验,所以契约漂移尚未直接报错(`app/main.py:825-840`、`frontend/src/App.tsx` 的流式事件类型)。
### 9.2 开发和部署连接数
**[已实现]** 开发态 Vite 将 `/api` 代理到 `127.0.0.1:8000`(`frontend/vite.config.ts:4-10`),所以一个流式仿真在开发态占用浏览器→Vite、Vite→FastAPI 两段长连接;若生产部署由 FastAPI/反向代理直接提供 API,则具体连接层数取决于部署。
仓库没有 WebSocket 路由、`EventSource` 或 `text/event-stream`;当前 NDJSON 只是普通 HTTP 分块响应。
## 10. CPU、线程、内存、网络和磁盘占用
先区分两个问题:
- **一个算例跑得快不快**:主要看每次变化率计算做了多少轮闭合、非线性试算和热物性计算;
- **多人同时运行稳不稳**:主要看并发任务是否有上限、是否能使用多个进程、每个结果在内存中保留多少份。
“每个任务开一个线程”不等于“每个任务独占一个 CPU 核”。Python 组件逻辑、SciPy 数值核和底层 BLAS 的实际并行程度取决于运行环境;仓库没有 CPU/RAM 实测数据,所以本节只给代码可证明的结构和数量级。
### 10.1 进程与线程
- `start-all.bat` 分别启动 Vite 与 FastAPI(`start-all.bat:19-22`)。
- `start-backend.bat` 的 Uvicorn 命令没有 `--workers`,当前脚本即单进程单 worker(`start-backend.bat:17-21`)。
- 每个流式仿真创建一个 daemon `threading.Thread` 和一个无界 `queue.Queue`;没有信号量、线程池或排队上限(`app/main.py:773-880`)。
- 全局任务字典只在读写元数据时持锁,不限制同时启动的求解数量。
- `POST /api/system-xml/simulate` 是 `async def`,但直接执行同步 CPU 求解;若调用该端点,会占用当前 Uvicorn 事件循环。
**[推断]** 单个求解主要是串行 Python 全网扫描加 SciPy 数值核,常会持续消耗一个核心;多任务线程不保证线性利用多核,还可能出现 GIL 竞争、SciPy/BLAS 原生线程过度订阅和内存峰值相叠。仓库没有固定 BLAS 线程数,具体 CPU 占用必须在目标部署环境实测。
### 10.2 内存数量级
不计 Python 对象常数项,主峰值可写为:
```text
O(组件 + 连接 + 代数结构)
+ O(采样数 × 动态状态数)
+ O(采样数 × 公开结果变量数)
```
当前采样上限是 10001。放大因素包括:
- 积分状态矩阵与后处理 `series` 在后处理阶段同时存在;
- 最终完整结果保存在全局任务记录中,又被编码为一个大型 NDJSON 行;
- 前端收到结果后执行 `structuredClone`,再 `JSON.stringify` 写入 `sessionStorage`(`frontend/src/App.tsx` 的结果快照与恢复逻辑);
- 图表会把数值数组映射为对象点数组,多个曲线窗口会产生更多前端副本;
- CSV 导出把完整结果再次上传,后端在 `StringIO` 中一次性构造完整 CSV(`frontend/src/SimulationResultsView.tsx:1041`、`app/main.py:299-377`)。
`SIMULATION_TASK_RETENTION_SECONDS=600`,但过期任务只在注册下一个任务时清理;没有新任务时,最后一批终态结果可能一直保留到进程退出(`app/main.py:491-520`)。
### 10.3 队列、网络和磁盘
- 任务队列是无界的,但进度被 0.25% 节流;正常单任务队列通常不大,客户端变慢或终态序列化时仍没有硬上限。
- 最终数值序列不分块,网络、后端 JSON 编码、前端字符串缓冲与 `JSON.parse` 会在完成时形成瞬时峰值。
- 通用 XML 求解本身不写仿真产物,结果主要驻留内存。
- 固定 TestModel 与 public Test MQL runner 会在 `app/data/simulation-runs` 下写时间戳产物;这不是主流式路径的磁盘行为。
## 11. 其他求解入口不能与主路径混同
### 11.1 固定 TestModel
`POST /api/reactflow/simulate-testmodel` 从所选类型/参数中提取固定数量的气瓶、贮箱、管/孔板来构造专用 `TestModelClosure`,不消费用户的任意节点边拓扑;它复用 `integrate_ode`,并写 CSV、SVG 和报告产物(`app/main.py:1363-1447`、`app/simulation/examples/testmodel/run.py`)。
它是回归/演示算例,不是通用 ReactFlow 网络求解器。
### 11.2 Test MQL
`POST /api/reactflow/simulate-test-mql` 当前忽略任意拓扑和主要积分配置;`TestMqlSystem.simulate()` 构造 132 状态的全零 `y`,只返回结构数量随时间的摘要(`app/main.py:1450-1481`、`app/simulation/examples/test_mql/system.py:6302-6318`)。
完整的 112 个气动状态与 20 个机械状态闭包存在于独立诊断/comparison 代码,但没有接入这个公开 API;仓库仍将其描述为校准阶段,不能宣称与 AMESim 全时域等价。
## 12. 当前明确的效率热点
| 热点 | 代码证据 | 影响范围 | 判断 |
| --- | --- | --- | --- |
| 每次闭合固定 2~3 次压力流量求解 | `generic.py:258-290` | 每个 RHS、初始化、每个结果采样点 | [已实现] 重复 pass;真实耗时待测 |
| stream 每轮复制/扫描并重复刷新 | `stream.py:29-119` | 每个闭合,最多 100 轮 | [已实现];网络越大越明显 |
| 非线性回退用全局有限差分 least-squares | `algebraic.py:927-947` | 快速路径失效时 | [已实现];大非线性网络潜在陡增 |
| 外层刚性积分器看不到显式稀疏 Jacobian | `solver.py:528-1009` | BDF/Radau 的每步/Newton | [已实现] |
| 热物性重复反算 | `mediums.py:199-266` 及各动态组件 refresh | 每个 RHS/闭合 pass | [推断] 需调用计数确认 |
| 每采样点完整后处理闭合 | `generic.py:397-474` | 输出点 × 全网 | [已实现] |
| 每个已接受步构造 dense output | `solver.py:528-914` | 流式逐步路径 | [已实现];无跨样本/事件时可能浪费 |
| 无界求解线程与任务结果驻留 | `main.py:491-520, 773-880` | 并发任务 | [已实现] 稳定性风险,不等于单算例变慢 |
| 完整结果单行 JSON 与前端多副本 | `main.py:825-840`、`App.tsx:8872-8958` | 大输出 | [已实现] 内存/网络热点 |
## 13. 优化建议排序
以下按**预期综合收益**排序;同档位优先低风险、低难度项。收益是基于调用频率与复杂度的代码推断,不是基准测试结果。“单算例”指一个模型的墙钟时间,“吞吐”指多任务服务能力。
### 13.1 先看人话版
在改算法前,应先给各阶段计时和计数;这本身不直接加速,但能防止优化错地方。之后可按下面顺序理解主要方案:
| 顺序 | 人话方案 | 为什么可能更快 | 主要风险 |
| ---: | --- | --- | --- |
| 1 | 少做重复“瞬时对账” | 当前每次变化率计算固定做 2~3 次压力/流量闭合,调用频率最高 | 少做一轮可能漏掉真实耦合,必须按组件依赖裁剪 |
| 2 | 先整理方程,再求解 | 合并重复未知量,把关联较弱的方程分组;大模型回退迭代时收益很高 | 连接、接触和跨域活塞会让分组出错 |
| 3 | 给不同状态使用合适的“尺子” | 质量、内能、位置、速度量级差异很大;合理缩放可减少无效内部步 | 容差改变会影响精度和事件时刻 |
| 4 | 相同输入不要重复查热物性 | 同一轮闭合中常以相同状态反算压力、温度等 | 缓存失效不严谨会产生错误结果 |
| 5 | 只计算、保存和传输需要的曲线 | 采样多、变量多时,可同时减少后处理、内存和网络开销 | 会改变默认结果合同,需要保留完整模式 |
| 6 | 给并发任务设固定“办理窗口” | 有界进程 worker 可防止无限建线程,并更好利用多核 | 对单个算例未必更快,跨进程取消和结果传递更复杂 |
下面的完整表把这些方向进一步拆成 12 项,并明确收益、风险和实施难度。
| 排名 | 建议 | 主要收益对象 | 预期收益 | 风险 | 实施难度 |
| ---: | --- | --- | --- | --- | --- |
| 1 | 将 `_close_current_state` 编译为按能力/依赖启用的执行计划:无信号不扫信号、无外部容积源不传播;仅在 stream 或体积确实使构成关系变脏时追加压力求解。保留可收敛的耦合迭代上限。 | 单算例 | 高;覆盖每个 RHS 和每个后处理点 | 中:错误裁剪会破坏耦合一致性 | 中 |
| 2 | 强化代数结构消元:合并 equality group 中重复未知量,按方程关联图分块,预编译残差/尺度;为非线性回退提供解析或稀疏 Jacobian/`jac_sparsity`。跨域活塞应按方程关联而非仅按物理域分块。 | 单算例、大网络 | 很高,尤其 least-squares 回退时 | 高:影响收敛与接触约束 | 高 |
| 3 | 为 BDF/Radau 提供状态缩放、分量级 `atol` 和 Jacobian 稀疏结构;允许有边界地配置 `rtol/atol`,依据物理时标选择 `max_step`,不要简单全局放宽容差。 | 单算例、刚性网络 | 中到高 | 中高:会改变误差轨迹/事件时刻 | 中高 |
| 4 | 在单次闭包内缓存热物性结果,并预计算 dynamic components、signal sources、stream components、端口引用和结果访问器;状态或体积变化时严格失效。 | 单算例 | 中到高,热力网络可能高 | 中:缓存失效错误会污染物理结果 | 中 |
| 5 | 改造结果选择和后处理:允许选择变量、采样/降采样;避免对不需要的变量和时间点执行完整闭合,必要时复用积分期间已接受的闭合快照。 | 单算例、内存 | 长仿真/多变量时高 | 中:结果合同与复用精度 | 中高 |
| 6 | 引入有界作业队列和固定大小的进程 worker;统一让同步端点也进入执行器,并设置最大并发、排队长度和结果尺寸。 | 吞吐、稳定性 | 高;单任务速度通常不变 | 中高:跨进程取消和序列化 | 高 |
| 7 | 进度与结果解耦:NDJSON 只发送进度和 `resultId`,结果按变量/时间块压缩下载或外部存储;前端改用 TypedArray/IndexedDB,图表先降采样。 | 内存、网络、UI | 大结果时高 | 中:需要版本化协议 | 中高 |
| 8 | 主动定时清理任务表,限制任务数/结果字节;成功交付后只保留摘要或引用。将无界进度队列改为“最新进度槽 + 不可丢终态槽”。 | 稳定性 | 中到高 | 低中 | 低中 |
| 9 | 只在当前步跨越下一采样点或需要状态事件检测时构造 dense output;记录并优化事件重启。大量周期 UD00 事件采用惰性调度。 | 单算例、事件密集模型 | 中 | 低到中 | 低到中 |
| 10 | CSV 在浏览器直接生成或按 `resultId` 服务端流式生成,避免全量 series 重新上传与 `StringIO` 全量复制。 | 内存、网络 | 中 | 低 | 低中 |
| 11 | 用共享 Schema/OpenAPI 生成前后端事件类型,修正 `complete/completed`;停滞依据服务端活动计数/已接受步时间戳并允许按模型调节。 | 可靠性、减少误杀重算 | 中 | 低 | 低中 |
| 12 | 长期评估支持稀疏残差/Jacobian 的 DAE 求解器,将外层 ODE 与内层代数 least-squares 统一成状态—代数系统。 | 复杂大模型 | 潜在很高 | 很高:架构与验证成本大 | 很高 |
### 13.2 推荐落地顺序
在改算法前先增加低侵入观测,但不把“加指标”误列为直接加速:
1. 记录每个闭合 pass 的调用数、墙钟时间、代数 `nfev` 与是否命中快路径;
2. 记录热物性调用数/迭代数、stream 迭代数;
3. 导出外层 `nfev/njev/nlu`、接受/拒绝步、事件与重启次数;
4. 记录状态/结果数组字节数、最终 JSON 字节、任务队列深度和进程 RSS;
5. 用小、中、大三类基准网络定位排名 1~5 的真实占比;
6. 先实施第 1、4、8、9 项的可回滚改造,再决定第 2、3、5 项的深度;
7. 服务并发需求明确后并行推进第 6、7 项。
每项算法改动都应继续验证质量/能量守恒、正反流、stream 混合、机械端挡、信号断点、取消部分结果和 AMESim/TestModel 基线。相关测试证据包括 `tests/test_generic_system_xml_simulation.py:244-533`、`tests/test_core_solver.py:19-508`、`tests/test_amesim_mechanical_public_components.py`。
## 14. 已实现、约定与推断的边界汇总
### 已实现
- System XML 校验、拓扑编译和通用半显式 ODE/代数求解主链。
- 气动、机械和信号的专用闭合顺序。
- 压力流量显式因果化快路径与 `least_squares` 回退。
- 自适应积分、输出采样、信号断点、机械端挡、协作取消和部分结果。
- NDJSON 长响应、心跳、取消端点、异常恢复轮询和任务状态表。
- 单 Uvicorn worker、每任务 daemon 线程、完整终态结果驻留与浏览器多副本行为。
### 约定
- 内核定位为半显式 ODE/代数 MVP,而非任意 DAE。
- XML/组件运行参数使用 SI 基准值。
- 采样上限、超时、心跳和任务名义保留时长。
- 组件参数在单次运行中静态;时间变化通过信号源等模型表达。
### 推断及必须实测
- 哪一类闭合 pass、热物性或 Jacobian 估计占主要墙钟时间。
- 单个任务实际占用几个核心、SciPy/BLAS 原生线程数和多任务扩展曲线。
- 典型/最大工程的峰值 RSS、结果 JSON 大小、浏览器内存副本和 sessionStorage 成功率。
- 各优化的实际收益;表中排序应在观测数据出现后更新。
## 15. 关键文件与符号索引
| 主题 | 文件与位置 | 关键符号 |
| --- | --- | --- |
| API 主入口与任务流 | `app/main.py` | `run_system_xml_simulation()`、`simulation_event_stream()` |
| JSON/XML 网络编译 | `app/main.py` | `compile_reactflow_network()`、`compile_system_xml_network()`、`_compile_solver_network()` |
| XML v3 校验/解析 | `app/system_xml.py`、`schemas/system-simulation-v3.xsd` | `SystemXmlDocument`、`validate_system_xml_document()` |
| 通用系统准备与仿真 | `app/simulation/systems/generic.py:93-474` | `GenericFluidSystem`、`_close_current_state()` |
| 压力流量代数闭合 | `app/simulation/solvers/algebraic.py:86-968` | `PressureFlowSolver.solve()` |
| stream 焓 | `app/simulation/solvers/stream.py:29-119` | `StreamResolver.solve()` |
| 标量信号 | `app/simulation/solvers/signal.py:40-109` | `SignalResolver`、`signal_event_times()` |
| 气动外部容积 | `app/simulation/solvers/pneumatic_volume.py:21-93` | `PneumaticVolumeResolver` |
| 机械因果化与事件 | `app/simulation/solvers/mechanical.py:225-627` | `MechanicalStateReducer` |
| ODE 推进 | `app/simulation/solvers/solver.py:37-1009` | `SolveIVPConfig`、`integrate_ode()` |
| 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 |
| 启动方式 | `start-backend.bat:17-21` | Uvicorn 单 worker 命令 |
| 主路径回归测试 | `tests/test_generic_system_xml_simulation.py`、`tests/test_core_solver.py` | 通用仿真、事件、取消 |
+641
View File
@@ -0,0 +1,641 @@
# SystemSimulationApp 接口类型与表示方式总结(通俗版)
> 调研基线:2026-08-12(System XML v3 接口基线)。本文依据当前仓库的代码、Schema、说明文档和测试编写。
> 这里的“接口”主要指组件上的端口(port/connector),不是只指 HTTP API。文末也单独列出了相关 HTTP API。
## 0. 三分钟读懂
先把整个系统想成一张“可以计算的工程图”:
- **组件**像气瓶、管路、阀门、质量块等设备;
- **端口**像设备上的接头或插座;
- **连接**像气管、机械连接杆或控制线;
- **编译**像正式计算前的接线检查:插头是否匹配、有没有漏接、方向是否正确;
- **求解**才是真正计算每个时刻的压力、流量、位移、速度等数值。
当前项目实际只有三类端口:
| 看到的类型 | 可以把它理解成 | 主要传递什么 |
| --- | --- | --- |
| `physical / pneumatic` | 气路接头 | 压力、质量流量、气体携带的能量 |
| `physical / mechanical` | 机械连接点 | 位移、速度、力 |
| `signal / signal` | 控制线 | 一个有方向的数值,例如阀门开度或目标力 |
最容易混淆的四种文件/数据,可以这样记:
| 数据 | 通俗比喻 | 它回答的问题 |
| --- | --- | --- |
| 组件目录 JSON | 产品说明书 | 某种组件天生有哪些端口、每个端口有哪些变量? |
| 工程 JSON | 画布存档 | 这张图上放了哪些组件、摆在哪里、怎样连? |
| System XML v3 | 交给后端的精简求解清单 | 只带组件、模型版本、SI 参数、连接和仿真设置,不负责保存画布 |
| 编译结果 JSON | 接线检查报告 | 后端恢复完整模型后,最终认出了哪些端口、连接和方程结构? |
贯穿全文的两个例子:
```text
案例 A:气路
储气容器/气室 A ── 节流孔或管路 ── 气室 B
气动端口 气动端口
案例 B:控制
阶跃信号源 step_1.out ──控制线──> 阀 valve_1.res
│
控制气路通断/开度
也可以是:step_1.out ──控制线──> 力源 force_1.res ──机械端口── 质量块
```
这两个示意图不是凭空编造的:仓库里已有对应的回归案例。`tests/test_generic_system_xml_simulation.py:86-145` 搭建了 `cylinder(500 kPa) → orifice → pipe → tank(100 kPa)` 气路;`tests/test_amesim_pnvo001_signal_xml.py:37-84` 搭建了 `step_1.out → valve_1.res`,同时让气缸、阀和气罐通过物理端口相连。
先记住六点就能继续阅读:
1. **物理端口必须同类相连。** 气动只能接气动,机械只能接机械,不能把“气管”插到“机械接头”上。
2. **信号线有方向。** 必须连接一个注册为 `output` 的端口和一个注册为 `input` 的端口;XML v3 不再另写 `source/target` 角色。
3. **物理线没有 source/target 的物理含义。** 画布虽然要写 `source/target`,后端会把它当作无方向的两个端点。
4. **流变量统一以“进入当前组件”为正。** 因此同一条气路两端的质量流量数值互为相反数。
5. **工程 JSON 和 XML 不重复保存完整变量表。** 后端依靠组件的 `modelType/type` 去注册表找回完整定义。
6. **`side/rotation/mirrored` 都只负责画面。** 力源是否反向由显式参数 `direction=+1/-1` 决定;转动或镜像图标不再改变方程。
只想看懂工程图,可以读第 0、2、3、5、6 节;需要开发或排查兼容问题时,再读第 1、4、7~11 节。
## 1. 本文中的标签和“谁说了算”
为了避免把“已经能运行”和“文档希望如此”混为一谈,本文使用四种标签:
- **[已实现]**:当前代码或测试直接体现的行为。
- **[约定]**:Schema、类型声明或说明文档规定的合同,但不一定所有入口都完整实现。
- **[推断]**:根据多处代码可以合理得到的判断,仓库没有直接承诺或实测数据。
- **[发现]**:代码层之间不一致、容易误解或存在兼容风险的地方。
如果不同层的说法不一致,优先相信更靠上的事实来源:
| 优先级 | 事实来源 | 关键文件/符号 | 通俗解释 |
| --- | --- | --- | --- |
| 1 | 端口核心定义 | `app/simulation/core/ports.py:7-207`:`PortVariableDefinition`、`PortDefinition`、`PortState` | 定义“插头标准”和运行时数值 |
| 2 | 具体组件模型类 | `MODEL_TYPE`、`PORTS`、`DISPLAY` | 声明某个产品实际装了哪些插头 |
| 3 | 模型注册器 | `app/simulation/registry.py:41-170, 416-490, 825-980` | 启动时核对产品声明,并生成目录 |
| 4 | 网络连接层 | `app/simulation/systems/network.py:83-150`:`SimulationNetwork.connect()` | 真正接线时做最终兼容检查 |
| 5 | JSON/XML | `ReactFlowPortDefinition`、System XML v3 XSD | JSON 搬运画布和端口显示快照;XML 只搬运可执行模型,端口合同由注册表恢复 |
**[约定]** `docs/README.md:28-29` 和组件建模规范都说明:组件模型类及受控库清单是后端事实来源。XML 或前端不能凭空创造一个模型没有声明的端口。
## 2. 常见术语翻译表
第一次阅读时,可以先把英文术语替换成右侧的日常说法。
| 术语 | 通俗说法 | 在本项目中的具体意思 |
| --- | --- | --- |
| component | 设备/元件 | 气室、节流孔、阀、质量块、信号源等 |
| port / connector | 接头/插座 | 组件可以与外界连接的位置 |
| interface contract | 接口说明书 | 端口名称、类型、变量、单位和连接规则的完整定义 |
| `kind` | 大类 | `physical` 物理连接,或 `signal` 控制信号 |
| `domain` | 专业类别 | 当前为 `pneumatic` 气动、`mechanical` 机械、`signal` 信号 |
| `nominalRole` | 名义用途 | 物理端口的入口/出口提示,或信号端口的输入/输出方向 |
| `effort` | 两端要相同的“势” | 气动压力 `p`;机械位移 `x`、速度 `v` |
| `flow` | 连接处要守恒的“流” | 气动质量流量 `m_flow`;机械力 `f` |
| `stream` | 随介质流动携带的性质 | 当前是气体流出比焓 `h_outflow` |
| `equal` | 两端相等 | 例如连接后 `p_A = p_B` |
| `sumToZero` | 两端相加为零 | 例如 `m_flow_A + m_flow_B = 0` |
| `streamMix` | 按实际流向传播/混合 | 不能简单令两端 `h_outflow` 相等 |
| `directed` | 按指定方向传值 | 例如阶跃源输出写入阀的信号输入 |
| registry / catalog | 型号登记表/产品目录 | 后端支持哪些模型,以及每种模型的完整定义 |
| compile | 接线检查和模型装配 | 根据 `modelType` 实例化组件并检查所有连接 |
| resolver | 专项计算器 | 分别处理信号、气体焓、移动容积等传播问题 |
| Schema / XSD | 格式规则 | 检查 JSON/XML 的字段和结构是否合规 |
| SI | 国际单位制 | Pa、kg/s、m、N 等;提交给求解器的值使用 SI 基准值 |
## 3. 用案例理解 physical 和 signal
### 3.1 案例 A:气室经节流孔连接
假设储气容器 A 的压力高于气室 B:
```text
tank_1.port_a ── orifice_1.port_a [节流孔] orifice_1.port_b ── chamber_1.port_1
```
仓库中的真实回归测试使用了一条更完整的链路:`cylinder(500 kPa) → orifice → pipe → tank(100 kPa)`(`tests/test_generic_system_xml_simulation.py:86-145`)。500 kPa 与 100 kPa 提供明显压差,便于检查压力和质量流量是否按预期推进。下面仍用 A、B 表示任意一对相连端口,规则与该测试相同。
这些都是 `physical / pneumatic` 端口。连接后,求解器关心三件主要事情:
1. 接头处的压力要相容;
2. 从一个组件流出的质量,必须流入另一个组件;
3. 气体携带的能量要按实际流向传递,发生汇合时还要混合。
这也是 `p`、`m_flow`、`h_outflow` 三个变量的来历:
| 变量 | 单位 | 人话解释 | 接线后的处理方式 |
| --- | --- | --- | --- |
| `p` | Pa | 接头处的绝对压力 | 两端相等:`p_A - p_B = 0` |
| `m_flow` | kg/s | 每秒有多少质量的气体流过 | 两端守恒:`m_flow_A + m_flow_B = 0` |
| `h_outflow` | J/kg | 如果气体从该组件流出,每公斤带走多少能量 | 按实际流向传播/混合,不直接令两端相等 |
| `volume` | m³ | 相邻移动机构提供的外部容积 | 内部辅助量,结果默认不展示 |
| `volume_flow` | m³/s | 上述外部容积每秒变化多少 | 内部辅助量,结果默认不展示 |
为什么两端的 `m_flow` 一正一负?项目统一规定“**进入当前组件为正**”。如果 0.01 kg/s 从 A 流进 B,那么从 A 的视角它在流出,约为 `-0.01`;从 B 的视角它在流入,约为 `+0.01`。这不是矛盾,只是观察对象不同。
`inlet`、`outlet`、`bidirectional` 是设计上的名义角色,不是止回阀。即使一个端口名义上叫 `outlet`,求解过程中仍可能出现反向流动。`PortState.actual_direction()` 使用约 `1e-12` 的死区判断 `in/out/stagnant`(`app/simulation/core/ports.py:218-231`)。
**[已实现]** `volume` 和 `volume_flow` 虽然使用 `signal/directed` 的变量规则,但它们仍装在气动物理端口里,不是画布上另一根信号线。`PneumaticVolumeResolver` 会沿现有气路传播它们(`app/simulation/solvers/pneumatic_volume.py:21-93`)。
### 3.2 案例 B:阶跃信号控制阀或力源
控制线与气管不同,它只把一个数值从发送方交给接收方:
```text
amesim_step0.out ───────────────> amesim_pnvo001.res
信号输出 output(发送方) 阀的信号输入 input(接收方)
```
当阶跃源在某个时刻从 0 跳到 1,`SignalResolver.solve()` 先更新信号源,再把输出值写到阀的 `res` 输入(`app/simulation/solvers/signal.py:40-68`)。阶跃发生的时刻还能作为积分断点,避免数值积分跨过突变点而不知情。
`tests/test_amesim_pnvo001_signal_xml.py:37-84` 正好演示了这个分工:`step_1.out → valve_1.res` 只传阀的控制命令,而 `cylinder → valve → tank` 的另外几条连接才传递气体的压力、质量流量和焓。控制线不会“变成气管”,阀组件负责在内部用命令改变气路行为。
同样的信号也能驱动力源:
```text
amesim_step0.out ──> amesim_forc.res [力源] amesim_forc.port_2 ── 机械网络
```
这里 `res` 是信号输入,`port_2` 是机械端口。信号与机械并没有直接相连,而是由 `amesim_forc` 组件内部方程把输入数值转换成力。
### 3.3 机械端口:把连接点当成同一个运动点
`physical / mechanical` 是一维平动机械连接,主要变量为:
| 变量 | 单位 | 人话解释 | 接线后的规则 |
| --- | --- | --- | --- |
| `x` | m | 连接点的位置 | 两端位移相等 |
| `v` | m/s | 连接点的速度 | 两端速度相等 |
| `f` | N | 组件在连接点承受的力 | 两端力相加为零 |
机械端口当前都标为 `bidirectional`。`MechanicalStateReducer` 会把刚性连接的一组惯性元件整理为一个共享的 `[v, x]` 状态坐标,避免同一运动被重复积分(`app/simulation/solvers/mechanical.py:225-248, 354-427`)。
### 3.4 跨域组件不是“不同插头直接相连”
当前有三种典型跨域组件:
- `amesim_forc`:信号输入 + 机械端口;
- `amesim_pnvo001`:信号输入 + 两个气动端口;
- `amesim_pnrp17`:一个气动端口 + 四个机械端口。
不同域之间的转换发生在组件内部方程中。网络层仍禁止把气动端口直接接到机械端口或信号端口。
## 4. 端口定义究竟包含什么
可以把 `PortDefinition` 看成端口铭牌。稳定定义在 `app/simulation/core/ports.py:38-47`:
| 字段 | 例子 | 通俗含义 |
| --- | --- | --- |
| `name` | `port_a`、`res` | 组件内部唯一的端口编号 |
| `kind` | `physical` / `signal` | 是物理接头还是控制线插座 |
| `domain` | `pneumatic` / `mechanical` / `signal` | 具体属于哪个专业类别 |
| `nominal_role` | `inlet`、`output` 等 | 名义用途;只有信号的 input/output 决定传播方向 |
| `positive_flow_direction` | `intoComponent` | 流和力的正号统一指向组件内部 |
| `variables` | `p`、`m_flow` 等 | 端口真正携带的变量、单位和连接规则 |
`side` 和 `order` 来自显示定义 `ComponentDisplaySpec.ports`,不是物理合同:
- `side`:端口图标画在节点左、右、上还是下;
- `order`:多个端口的显示顺序。
它们由 `ComponentModelSpec.as_catalog_dict()` 合并进目录响应(`app/simulation/registry.py:76-110`)。求解器不读取 `side`。
运行时的 `PortState` 是一只通用“数值盒子”,同时预留气动、机械和信号字段(`app/simulation/core/ports.py:194-207`)。不能因为盒子里有某个字段,就认定所有端口都支持该变量;真正要看的是 `PortDefinition.variables`。
## 5. JSON 和 XML 分别保存什么
本节继续用“储气容器连接节流孔”说明同一件事如何经过四层表示。
### 5.1 组件目录 JSON:产品说明书
`GET /api/components/catalog` 返回后端支持的全部型号(`app/main.py:283-287`、`app/simulation/registry.py:1002-1027`)。它受 `schemas/component-catalog-v1.schema.json` 约束,信息最完整。
下面是为了讲解而加了注释的 **JSONC**,不是可直接提交的严格 JSON:
```jsonc
{
"name": "port_a", // 端口编号
"kind": "physical", // 物理接头,不是控制线
"domain": "pneumatic", // 气动类别
"nominalRole": "bidirectional", // 设计上允许双向使用
"positiveFlowDirection": "intoComponent", // 正流量指向组件内部
"variables": [ // 完整变量表只在目录/编译结果中出现
{
"name": "p", // 压力
"role": "effort", // 连接后两端相等
"connectionRule": "equal",
"unit": "Pa",
"resultVisible": true
},
{
"name": "m_flow", // 质量流量
"role": "flow", // 连接后两端相加为零
"connectionRule": "sumToZero",
"unit": "kg/s",
"resultVisible": true
},
{
"name": "h_outflow", // 流出气体的比焓
"role": "stream", // 按流向传播/混合
"connectionRule": "streamMix",
"unit": "J/kg",
"resultVisible": true
}
],
"side": "left", // 只影响画面位置
"order": 10 // 只影响显示顺序
}
```
**[已实现]** 当前目录加载结果为 **27 个模型、61 个已声明端口**:38 个气动端口、19 个机械端口、4 个信号端口。两个介质定义模型没有端口,因此不计入 61 个端口。
### 5.2 ReactFlow 工程 JSON:画布存档
工程 JSON 保存“这次用了哪一个型号、端口快照和连线”,但不重复保存完整变量表。请求模型在 `app/main.py:86-155`;前端定义与生成逻辑见 `frontend/src/App.tsx` 中的 `PortDefinition`、`ReactFlowProjectPayload`、`buildProjectPayload()`。
下面仍是带说明的 JSONC:
为避免示例过长,这里只截取“储气容器接到节流孔”的局部画布;节流孔另一端尚未接出,所以它是**字段讲解片段**,不是可直接运行的完整工程。可运行的完整链路见 `tests/test_generic_system_xml_simulation.py:86-145`。
```jsonc
{
"projectSchemaVersion": 1, // 当前工程 JSON 的唯一格式版本
"name": "tank-orifice-demo",
"nodes": [
{
"id": "tank_1", // 本张图中的实例 ID
"type": "simulationComponent",
"position": {"x": 120, "y": 80}, // 画布位置
"data": {
"label": "储气容器 A",
"componentType": "tank",
"modelType": "tank", // 后端靠它回查完整模型定义
"modelVersion": "1.0.0", // 锁定保存时使用的模型合同
"ports": [
{
"name": "port_a",
"kind": "physical",
"domain": "pneumatic",
"nominalRole": "bidirectional",
"positiveFlowDirection": "intoComponent",
"side": "right"
}
],
"parameters": {} // 当前组件实例的参数值
}
},
{
"id": "orifice_1",
"type": "simulationComponent",
"position": {"x": 360, "y": 80},
"data": {
"label": "节流孔",
"componentType": "orifice",
"modelType": "orifice",
"modelVersion": "1.0.0",
"ports": [
{"name": "port_a", "kind": "physical", "domain": "pneumatic",
"nominalRole": "bidirectional", "positiveFlowDirection": "intoComponent", "side": "left"},
{"name": "port_b", "kind": "physical", "domain": "pneumatic",
"nominalRole": "bidirectional", "positiveFlowDirection": "intoComponent", "side": "right"}
],
"parameters": {}
}
}
],
"edges": [
{
"id": "edge-1",
"source": "tank_1", // ReactFlow 画线需要 source/target
"sourceHandle": "port_a",
"target": "orifice_1",
"targetHandle": "port_a", // 对物理线而言,不代表流动方向
"data": {"isContactEdge": false}
}
],
"simulation": {
"t_start": 0,
"t_stop": 2,
"step": 0.1,
"max_step": 0.005,
"method": "BDF"
}
}
```
后端会按 `modelType` 创建真实模型,并先要求节点 `modelVersion` 与注册版本完全一致,
再检查这里的端口名、类型、域、名义角色和正号是否与注册定义一致。
**[已实现] 前端工程和后端求解模型有意保持分工。**
- 目录 JSON 有完整 `variables`,前端只读取完成画布连接和即时提示所需的端口级字段;后端编译仍是物理合同的最终检查点。
- 工程 JSON 使用必填 `projectSchemaVersion: 1`,每个节点保存 `modelVersion`,端口必须是结构化对象,连接必须写明两端 Handle。结构版本不受支持时直接拒绝;节点模型版本缺失或不匹配时不得编译、导出 XML 或仿真。
- UI 继续通过浏览器 `localStorage` 和本地 JSON 文件保存工程。后端同时提供严格按工程 JSON v1 校验的工程列表、保存和读取接口;仿真主路径仍只向后端提交精简的 System XML v3。
### 5.3 System XML v3:交给后端的精简求解清单
XML v3 和工程 JSON 不再追求“保存同一份完整工程”。两者分工很明确:工程 JSON 保存怎样编辑和显示,XML v3 保存后端求解什么。当前结构由 `schemas/system-simulation-v3.xsd` 定义;完整规范见 `docs/system-xml-v3.md`。
#### 5.3.1 生成出来的 XML 是什么结构
```text
System 整个可执行模型
├─ Simulation 恰好 1 个:仿真时间和算法
├─ Components 组件清单
│ └─ Component * 0~多个组件
│ └─ Parameter * 组件的完整 SI 参数
└─ Connections 接线清单
└─ Connection * 0~多条连接
├─ Endpoint 每条连接恰好两个端点
└─ Endpoint
```
XML 外形是一棵树,模型仍是一张连接图。组件平铺在 `<Components>` 中,`<Connections>` 再通过“组件 `id` + 注册端口名”把它们接起来。顶层顺序固定为 `Simulation → Components → Connections`。
与 v2 相比,v3 主动删掉了 `Port` 快照和画布字段。各字段来源如下:
| XML v3 内容 | 来源 | 通俗解释 |
| --- | --- | --- |
| `System/@name` | `project.name` | 可选的模型名称 |
| `schemaVersion/unitSystem` | 生成器固定写入 | 当前协议固定为 v3,参数使用 SI |
| `Simulation` | 求值后的仿真设置 | 起止时间、结果采样间隔、内部最大步长和算法 |
| `Component/@id` | `node.id` | 连接实际引用的稳定实例编号 |
| `Component/@type` | `modelType` | 用哪个后端模型类创建实例 |
| `Component/@modelVersion` | 组件目录/注册表 | 锁定本文件采用的模型合同版本 |
| `Parameter` | 参数表达式求值并换算后的值 | 写出该模型的全部注册参数,只留最终 SI 数值 |
| `Endpoint` | ReactFlow 边两端的 handle | 用“组件 ID + 端口名”重新接线 |
实际生成过程可以概括为:
1. 收集当前节点、连线和仿真设置;
2. 把工程 JSON 的 `simulation.step` 映射成 XML 的 `sampleStep`;
3. 根据组件目录写入 `modelVersion`,并用注册默认值补齐全部参数;
4. 把参数表达式求值、换算为 SI,只写 `Component/Parameter`;
5. 每条边只写两个 `Endpoint`,不复制端口类型或方向角色。
XML v3 不保存显示名称、`symbol`、坐标、`side`、旋转、镜像、参数显示单位、科学计数法偏好、撤销历史、当前选择和仿真结果。因此它适合校验、交换和求解,但不能无损还原前端画布;要继续编辑,应保存工程 JSON。
#### 5.3.2 一个最小 XML 片段
下面用“阶跃信号 → 力源 → 零力端”同时展示信号和机械连接:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<System name="signal-force-demo" schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="2"
sampleStep="0.1" maxStep="0.005" method="BDF"/>
<Components>
<Component id="step_1" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="0"/>
<Parameter name="final" value="20"/>
<Parameter name="time" value="0.04"/>
</Component>
<Component id="force_1" type="amesim_forc" modelVersion="0.2.0">
<!-- +1 为默认施力方向,-1 为反向 -->
<Parameter name="direction" value="1"/>
</Component>
<Component id="zero_1" type="amesim_f000" modelVersion="0.1.0"/>
</Components>
<Connections>
<Connection id="signal-1">
<Endpoint component="step_1" port="out"/>
<Endpoint component="force_1" port="res"/>
</Connection>
<Connection id="mechanical-1">
<Endpoint component="force_1" port="port_2"/>
<Endpoint component="zero_1" port="port_1"/>
</Connection>
</Connections>
</System>
```
这里没有写 `kind/domain/role`,也没有 `<Port>`。后端看到 `type="amesim_step0"` 后,会从注册表知道 `out` 是信号输出;看到 `amesim_forc.res` 后,会知道它是信号输入;同理还能恢复两个机械端口的变量合同。也就是说,端口名是“查说明书的索引”,不是由 XML 自己重新定义接口。
XML v3 的连接规则是:
- 物理和信号 `Connection` 都只写两个 `Endpoint`;
- 两端的 `kind/domain/nominalRole/variables` 都从当前注册模型恢复;
- 信号连接必须恰好包含一个注册输出端和一个注册输入端,但端点先后顺序不决定方向;一个输出可以扇出到多个输入,每个输入只能有一个驱动;
- 物理端点无序,流向由求解结果和统一正号约定决定;
- 参数必须完整、有限、使用 SI,并通过当前模型的范围或枚举校验。
**[已实现] 布局与物理方向已经分开。** `side/rotation/mirrored` 只保留在工程 JSON 中,XML v3 不包含它们。`amesim_forc` 从模型版本 `0.2.0` 起用显式 `direction=+1/-1` 控制力的正反:相同输入 20 N 时,`direction=+1` 要求机械端口平衡值为 `f=-20 N`,`direction=-1` 时为 `f=+20 N`。旋转或镜像图标不改变求解结果(`app/simulation/components/amesim/mechanical/translational.py`、`tests/test_amesim_mechanical_public_components.py`)。
### 5.4 编译结果 JSON:接线检查报告
`POST /api/reactflow/compile-model` 和 `POST /api/system-xml/compile-model` 最终调用 `SimulationNetwork.as_interface_dict()`(`app/simulation/systems/network.py:280-314`)。
它不是新的工程存档,而是告诉调用者:“后端实际装配出了什么”。下例也是带注释的结构示意:
```jsonc
{
"name": "tank-orifice-demo",
"components": [
{
"id": "tank_1",
"type": "tank",
"ports": [
{
"name": "port_a",
"kind": "physical",
"domain": "pneumatic",
"variables": [
// 工程 JSON 和 XML 都没保存的完整变量定义,在这里由注册表恢复
{"name": "p", "connectionRule": "equal", "unit": "Pa"},
{"name": "m_flow", "connectionRule": "sumToZero", "unit": "kg/s"},
{"name": "h_outflow", "connectionRule": "streamMix", "unit": "J/kg"}
]
}
]
}
],
"connections": [
{
"id": "edge-1",
"kind": "physical",
"domain": "pneumatic",
"endpoints": [
{"component": "tank_1", "port": "port_a"},
{"component": "orifice_1", "port": "port_a"}
] // 物理连接只有两个端点,不再保留 source/target 含义
}
],
"unconnectedPorts": [] // 若有漏接,会在这里或诊断中体现
}
```
### 5.5 四层字段对照
先说结论:v3 XML 只携带连接实际用到的端口名,端口大类和完整变量表都由后端注册表恢复。这样不会同时维护“模型类中的端口”和“XML 中的端口副本”。
| 模型含义 | 目录 JSON | 工程 JSON | System XML v3 | 编译结果 JSON |
| --- | --- | --- | --- | --- |
| 端口编号 | `name` | `name` | 只在 `Endpoint/@port` 出现 | `name` |
| 物理/信号 | `kind` | `kind` | 不保存,由 `type + port` 恢复 | `kind` |
| 气动/机械/信号 | `domain` | `domain` | 不保存,由注册表恢复 | `domain` |
| 名义角色 | `nominalRole` | `nominalRole` | 不保存,由注册表恢复 | `nominalRole` |
| 正号约定 | `positiveFlowDirection` | 同名字段 | 不保存,由注册表恢复 | 同名字段 |
| 完整变量和规则 | `variables[]` | 不保存 | 不保存 | `variables[]`,由注册表恢复 |
| 显示侧 | `side` | `side` | 不保存 | 不是求解合同 |
| 画布连线 | 不适用 | `source/.../targetHandle` | 两个 `Endpoint` | 规范化后的连接端点 |
| 模型合同版本 | `modelVersion` | 节点显式保存并与目录核对 | `Component/@modelVersion` 必填 | 按当前注册模型编译 |
## 6. 接线时后端具体检查什么
可以把 `SimulationNetwork.connect()` 当作最后一道“防止插错线”的检查(`app/simulation/systems/network.py:83-150`)。它会依次确认:
1. 组件和端口确实存在,且不是自己接自己;
2. 两端 `kind` 一样;
3. 两端 `domain` 一样;
4. 完整变量表一致;
5. 信号线是一端 input、一端 output;
6. 一个物理端口最多接一条线;需要分支时必须放入 `tee`、`amesim_pn3node2`、`amesim_p4node2` 等分支组件;
7. 连接 ID 和端点组合没有重复。
对于案例 A 的一条气动连接,网络直接形成:
```text
p_A - p_B = 0 # 接头两侧压力一致
m_flow_A + m_flow_B = 0 # 流出一侧的质量等于流入另一侧的质量
```
不会形成 `h_outflow_A = h_outflow_B`。焓要在压力、流量确定后,由 `StreamResolver` 根据真实流向传播或混合。物理连接端点顺序不影响结果,相关测试包括 `tests/test_component_interfaces.py:51-60`、`tests/test_system_xml_protocol.py:146-176` 和 `tests/test_generic_system_xml_simulation.py:354-372`。
XML v3 还会先经过:
- 安全解析和 5 MiB 大小限制(`app/system_xml.py:21, 243-280`);
- XSD 格式检查;
- 组件类型、`modelVersion`、参数完整性和值域检查;
- 端点引用检查,并从注册表恢复端口类型、角色、变量和正号约定;
- 端点占用、信号输入/输出配对、物理域和变量合同检查。
XML 语义检查会把未连接端口记为 warning;真正进入通用求解前,未连接的**物理端口**会成为 `PORT_UNCONNECTED` error(`app/simulation/systems/generic.py:93-125`)。未连接信号端口不会阻止求解。
## 7. 当前容易踩坑的跨层差异
这些问题不妨碍理解主流程,但开发或制作 XML 时必须注意。
### 7.1 信号多接的规则前后端不一致
**[发现]** 后端网络和 XML 只限制物理端口单连接,没有禁止多个信号源同时连接到一个 input。`SignalResolver.solve()` 会按连接顺序依次写入,因此最后一个值覆盖前面的值。
前端 `canConnectPorts()` 却对所有端口实行一对一:既阻止多个源写同一输入,也阻止一个输出连到多个输入(`frontend/src/App.tsx`)。所以:
- 在浏览器里通常画不出这种多接;
- 直接调用 XML/API 却可能构造出来;
- 最后覆盖不是稳定的“求和”或“仲裁”规则,不应依赖。
后续最好统一为“每个信号输入只能有一个驱动”,或者显式增加求和、选择、总线组件。
### 7.2 XML 不写端口类型,不等于后端不知道类型
**[已实现]** v3 有意删除 `Port`、连接的 `kind/domain` 和端点 `role`。这不是允许调用者“随便省略”,而是把端口合同收回到唯一事实来源:后端注册模型。语义检查会用 `Component/@type + Endpoint/@port` 查出 `kind/domain/nominalRole/variables`;端口不存在或两端不兼容仍会报错。手写 v3 时不要把 v2 的这些字段加回来,XSD 会拒绝它们。
### 7.3 JSON 和 XML 对缺省参数的处理不同
**[发现]** ReactFlow JSON 编译和 JSON→XML 导出会用注册默认值补齐缺失参数;System XML v3 语义检查会对缺少的注册参数报告 `PARAMETER_REQUIRED_MISSING`。因此“工程 JSON 可以省略默认参数”不等于“手写 XML 也可以省略”。正规导出器会替你写全,人工制作 v3 时必须列全。
### 7.4 当前 API 只接受 v3
仓库只保留当前的 v3 协议和 XSD。v1/v2 不属于受支持输入,旧版本号只出现在拒绝边界测试和“旧格式不受支持”的说明中。
**[已实现]** 当前 `validate_system_xml_document()` 固定加载 `schemas/system-simulation-v3.xsd`,不会按 `schemaVersion` 自动切换或迁移 v1/v2;新文件必须使用 v3。
### 7.5 XML v3 会锁定模型版本
**[已实现]** `Component/@modelVersion` 是 v3 必填属性,语义检查要求它与当前注册模型版本完全一致。例如 `amesim_forc` 当前是 `0.2.0`,旧版本号不会被静默当成新方程求解。工程 JSON v1 的每个节点也保存创建时的 `modelVersion`,编译和导出 XML 前必须先与当前目录核对。这种设计采取“发现不一致就拒绝”的策略,不表示系统已经提供自动模型迁移器。
### 7.6 不从旧格式推断当前行为
当前格式只看 `docs/system-xml-v3.md` 和 `schemas/system-simulation-v3.xsd`。旧格式中的 `Port`、布局字段、端点 `role`、`Simulation/@step` 和“旋转改变力方向”都不能继续套用到 v3。
## 8. 当前模型覆盖范围
不需要记住所有型号,只需知道它们仍归入前面三种插头标准。
| 用途 | 当前模型 |
| --- | --- |
| 实验气动 | `cylinder`、`tank`、`pipe`、`orifice`、`tee` |
| AMESim 气动 | `amesim_pnpl01`、`amesim_pnrp17`、`amesim_pnch023`、`amesim_pnch012`、`amesim_pnor001`、`amesim_pnvo001_fixed`、`amesim_pnvo001`、`amesim_pnl00r`、`amesim_pnl0001`、`amesim_pnl0002`、`amesim_pnl0003`、`amesim_pn3node2`、`amesim_p4node2` |
| AMESim 机械 | `amesim_f000`、`amesim_forc`、`amesim_mecmas21`、`amesim_lstp00a`、`amesim_lmechn1`、`amesim_pnrp17` |
| AMESim 信号 | `amesim_step0`、`amesim_ud00`、`amesim_forc`、`amesim_pnvo001` |
| 零端口介质定义 | `amesim_ideal_air_medium`、`amesim_helium_medium` |
两个介质模型虽然出现在组件目录中,却没有连接端口。它们在编译第一阶段登记 `gi=1..99` 的介质定义,之后不进入方程网络(`app/main.py:1206-1253`)。它们是配置节点,不是第四类接口。
## 9. 与接口表示相关的 HTTP API
这里的 API 可以理解为围绕上述四层数据提供的“入口按钮”。
| API | 人话解释 | 当前前端是否直接使用 |
| --- | --- | --- |
| `GET /api/components/catalog` | 获取后端产品说明书 | 是 |
| `GET /api/reactflow/projects` | 列出后端保存的工程 JSON v1 | 当前 UI 主路径不用 |
| `GET /api/reactflow/projects/{id}` | 读取并校验一个后端工程 | 当前 UI 主路径不用 |
| `POST /api/reactflow/projects/{id}` | 保存一个工程并保留单位/科学计数显示信息 | 当前 UI 主路径不用 |
| `POST /api/reactflow/system-xml` | 把工程 JSON 转成精简 XML v3 | UI 当前也能在浏览器内生成同一结构 |
| `POST /api/reactflow/compile-model` | 检查工程 JSON 并返回装配结果 | 可用于诊断 |
| `POST /api/system-xml/validate` | 只检查 XML 格式和语义 | 可用于诊断 |
| `POST /api/system-xml/parse` | 把 XML 变成规范化执行模型;不还原坐标、旋转等画布信息 | 可用于检查求解输入,不是无损工程导入 |
| `POST /api/system-xml/compile-model` | 检查 XML 并装配网络 | 求解前使用 |
| `POST /api/system-xml/simulate-stream` | 提交 XML 并持续接收进度/最终结果 | 当前 UI 的通用求解入口 |
| `POST /api/system-xml/simulate` | 同步返回完整结果 | 后端提供,UI 主路径不用 |
后端还提供 CSV 导出 API。当前 UI 的工程保存和读取主要发生在浏览器与用户选择的本地文件中,后端工程接口作为同一严格合同的可选持久化入口。
## 10. 已实现、约定和推断:最后再分一次边界
### [已实现]
- 当前实际有气动、机械、标量信号三类端口,模型加载快照为 27 个模型、61 个端口。
- 物理端口以进入组件为正,物理连接端点无序;信号方向由注册端口的 output/input 决定,XML 端点不写 source/target 角色。
- 注册器、JSON/XML 编译器、XML 语义层和网络层会分层检查接口。
- 气动压力/质量流量约束、焓传播、外部容积传播、机械连接约束和标量信号传播已有可执行代码。
- 工程 JSON 可导出 System XML v3;XML v3 可解析为执行模型并编译为带完整端口合同的网络。
- `amesim_forc` 用 `direction=+1/-1` 决定力方向;旋转/镜像只影响显示。
- MECMAS21 工程、目录、XML 和模型统一使用 AMESim 原生 `1/2` 选项编码,不再猜测或转换旧 `0/1` 值。
### [约定]
- 组件模型类的 `PORTS` 是权威端口合同;前端目录只是读取视图。
- XML 和执行参数使用 SI 基准值。
- 物理端口的 `nominalRole` 不限制实际流向。
- 新文件使用 System XML v3;每个组件显式写当前 `modelVersion`,仿真采样字段写 `sampleStep`。
### [推断/需要另行实测]
- 类型字段允许出现其他 `domain` 字符串,但新增液压、电气等域还需要变量定义、组件方程和专项求解器;只改字符串不能工作。
- 27 个模型和 61 个端口是本次加载快照。受控库清单改变后,数量也会改变。
- v1/v2 XML 明确不受支持;旧工程 JSON 也不会由当前前端自动猜测或迁移。
## 11. 关键文件与符号索引
读到具体疑问时,可从这里回到代码。前面的章节已经给出人话解释,本表用于精确定位。
| 主题 | 文件与位置 | 关键符号 |
| --- | --- | --- |
| 端口类型、变量、正号 | `app/simulation/core/ports.py:7-231` | `PortVariableDefinition`、`PortDefinition`、`PortState` |
| 组件事实来源 | `app/simulation/core/base.py:21-63` | `Component.PORTS`、`register_declared_port()` |
| 端口显示信息 | `app/simulation/core/catalog.py:27-71` | `PortDisplaySpec`、`ComponentDisplaySpec` |
| 注册发现与校验 | `app/simulation/registry.py:41-170, 416-490, 825-1027` | `ComponentModelSpec`、`_validate_port()`、`build_component_catalog()` |
| 网络接线与方程 | `app/simulation/systems/network.py:24-314` | `Connection`、`SimulationNetwork.connect()`、`connection_equation_residuals()` |
| 工程 JSON 与编译 | `app/main.py:86-155, 1181-1344` | `ReactFlowPortDefinition`、`compile_reactflow_network()` |
| JSON 转 XML | `app/main.py:947-1097` | `build_reactflow_system_xml()` |
| XML 解析和语义检查 | `app/system_xml.py` | `SystemXmlComponent`、`SystemXmlEndpoint`、`SystemXmlDocument`、`validate_system_xml_document()` |
| XML v3 当前格式 | `schemas/system-simulation-v3.xsd`、`docs/system-xml-v3.md` | `sampleStep`、`modelVersion`、`Parameter`、`Endpoint` |
| 目录 JSON 格式 | `schemas/component-catalog-v1.schema.json:52-160` | `$defs.portVariable`、`$defs.port` |
| 前端端口和工程类型 | `frontend/src/App.tsx` | `PortDefinition`、`ReactFlowProjectPayload` |
| 前端生成 XML | `frontend/src/App.tsx` | `buildSystemXml()`、`projectConnectionMetadata()` |
| 接口核心测试 | `tests/test_component_interfaces.py` | 变量规则、端点中立、单连接限制 |
| XML 协议测试 | `tests/test_system_xml_protocol.py`、`tests/test_system_xml_parser.py` | v3 表示和语义诊断 |
## 12. 一句话复盘
SystemSimulationApp 用三种端口把组件组成网络:气动和机械端口负责“守恒与相容”,信号端口负责“有方向地传一个数值”;目录 JSON 定义型号,工程 JSON 保存画布,System XML v3 保存精简求解清单,编译结果则证明后端最终理解并装配出了什么。
+376
View File
@@ -0,0 +1,376 @@
# 更新日志 2026-08-15
## 记录范围
本文记录 `model-development` 分支截至 2026-08-15 的当前未提交工作区改动。范围包括前端建模交互、组件图标与参数驱动布局、后端模型合同、求解与采样安全、工程 JSON、System XML v3、文档、Schema 和自动化测试。
## 重点摘要
- 建模区完成框选纠正、待放置式粘贴、纵向滚轮平移、双向滚动条、接触边去交互化和端口显示重构。
- 仿真控制台改为停靠在图形建模区底部,可折叠、调高并在折叠状态显示摘要。
- 组件库固定使用图标模式,移除前端兜底测试库,增加明确的加载成功/失败状态。
- LMECHN1 改为 1~20 个动态右侧端口,默认 2 个,并同步更新图标、编号、连接、迁移和求解合同。
- FORC 的力方向改为显式参数,不再由图标旋转或镜像改变物理符号。
- MECMAS21 默认改为启用摩擦并使用理想限位,图标根据摩擦和限位参数显示四种外观。
- System XML 升级为 v3;ReactFlow 工程 JSON 固定为 `projectSchemaVersion: 1`,组件实例增加严格的 `modelVersion` 合同。
- 增加采样点数量、浮点时间精度、物理岛、端口基数和模型版本等安全检查。
## 前端建模与交互
### 选择与框选
- 框选过程改为按组件实际图标包络同步纠正 React Flow 内部选择状态,而不是依赖透明节点外框。
- 框选区域缩回图标包络之外时立即取消误选,消除“没有框到却仍被选中”和一帧选中闪烁。
- 框选结束、取消、自动平移和视口变化后会复核并清理选择矩形状态。
- 批量选择结束后只保留各组件自身的虚线选中框,不保留整体选择矩形。
- 从组件库拖入的新组件会成为唯一选中项,后续复制、旋转、镜像等操作不会继续作用于旧选择。
### 复制与待放置粘贴
- `Ctrl+V` 不再立即把副本写入工程,而是进入跟随鼠标的待放置预览状态。
- 待放置状态支持:
- 左键确认位置;
- 中键单击旋转;
- `Esc` 取消;
- 普通滚轮平移、`Ctrl+滚轮` 缩放、中键拖动画布和拖动滚动条。
- 待放置期间会锁定撤销、重做、再次粘贴、属性修改、组件库拖入、工程加载和视图切换等会改变模型的操作。
- 多组件副本会连同内部连线一起预览和落地。
- 节点和边 ID 在生成副本时会避让当前工程中的既有 ID;修复复制已连接的同类型组件时副本复用旧 ID、覆盖预览并最终移动原组件的问题。
- 提交待放置内容前再次执行重复 ID 防御检查。
### 端口、连线与吸附
- 自动接触吸附采用双下限:低倍缩放至少保留 `10px` 屏幕判定范围,高倍缩放至少保留 `10` 个画布单位;解决放大后半径被换算得过小、并与 `18` 单位网格叠加后端口难以对接的问题,同时避免缩小时退化成约 `1px` 的命中范围。
- 一次拖动可匹配同一落点内的全部兼容端口,并按距离和端口合同进行确定性配对。
- 已连接端口统一隐藏、禁止指针事件且不可再次发起连接;前端信号端口不再提供扇出入口。
- 物理端口可见层改为真实 SVG 圆形;信号端口使用 SVG 圆角矩形,避免非整数缩放时 CSS 圆角端口显示成不同椭圆。
- 可见端口图形以 `6×6` 为基准并随画布同比缩放;物理圆形横纵缩放保持一致。
- 可见图形与 Handle 命中层解耦,外层继续保留约 `15×15px` 的操作热区,缩小画面后仍便于连接。
- `isContactEdge` 成为工程边的显式字段;完全重合的接触边不绘制路径、不提供按钮、焦点、点击或框选入口。
- 删除接触边端点的透明圆形保护层,避免保护层覆盖 PNCH012 等小型多端口元件的主体选择区域。
- 普通可见连线仍保留中段选择和删除能力;点击节点、画布或普通边会清理残留的端口连接状态。
- 加载工程时以当前目录端口合同为准,删除、重复占用、类型不兼容或当前参数下未启用的端口连接会被逐条丢弃并报告原因。
### 画布导航
- 普通鼠标滚轮改为纵向平移建模画布。
- `Ctrl+滚轮` 保留缩放;中键拖动画布平移行为保持不变。
- 建模区新增底部水平滚动条和右侧垂直滚动条,滚动条、React Flow 视口和键盘滚动操作双向同步。
- 滚动条使用窄轨道,在保证可点击的同时尽量减少对建模区域的占用。
- 移除建模区左下角 React Flow 放大、缩小、适配和锁定按钮。
- 隐藏建模区和结果系统图右下角的 React Flow 水印。
### 仿真控制台
- 新增 `DockedSimulationConsole`,控制台只占用中央图形建模区底部,不再横跨模型库和参数栏。
- 支持鼠标拖拽和键盘调整高度,双击分隔条恢复默认高度;高度写入本地存储。
- 折叠后只保留约 `40px` 标题栏和最后一条日志/进度摘要。
- 折叠状态收到新日志或仿真进度时只更新摘要,不再自动展开完整控制台。
- 保留仿真进度、停止、清空、XML 日志和普通日志功能。
- 控制台不再记录拖入组件、手动连接、自动吸附、拖开接触连接、全选、网格显示和适配画布等低价值建模噪声;继续保留仿真、检查、XML、复制粘贴、撤销重做、旋转镜像、删除、保存加载及重要迁移警告。
- 控制台文本选择使用浏览器原生复制,不再触发画布组件复制快捷键。
- 旧浮动控制台代码暂由功能开关保留,便于对基础版本进行回退审阅。
### 组件库
- 组件库固定使用图标卡片模式,删除图标/列表切换按钮、列表元信息、本地显示模式设置和相关残留代码。
- 图标卡片保持固定尺寸,保留名称截断、悬停完整名称和放大预览。
- 字体层级调整为:组件库标题 `16px`、库分组 `14px`、类别分组 `12px`。
- 成功状态只显示“已加载 X 个组件库”和绿色状态标记。
- 加载失败时不再显示前端临时测试兜底库,组件区域保持为空并显示红色状态。
- 失败状态支持悬停或键盘聚焦查看详细原因;能识别目录中具体失败的组件库时直接显示其名称。
- `experimental` 临时测试库无论目录加载成功还是失败都不会出现在组件库中。
- 从组件库拖拽时使用工作区 `viewBox`、实际节点尺寸和当前缩放生成待拖入图像,避免落地前后图标形状或大小跳变。
### 参数、工程与结果页
- 只有一个固定选项的 `choice` 参数不再显示,避免出现没有实际选择价值的参数行。
- LMECHN1 右侧端口数执行 `1..20` 整数校验;超过上限时显示“右侧端口数量不能超过20”。
- LMECHN1 缩减端口数前会检查即将隐藏的端口是否仍连接;参考端口编号变化时迁移已有参考连接。
- 参数表、模型检查和 XML 导出共用模型合同与参数验证。
- XML 数值输出使用可往返的双精度文本,减少格式化造成的物理输入精度损失。
- 缺失或不匹配 `modelVersion` 的工程仍可加载检查,但组件显示合同警告,且不能通过模型检查、生成 XML 或运行仿真。
- 仿真结果快照同步要求工程 Schema、节点模型版本、端口结构和 `isContactEdge` 合同;结果系统图复用新的端口图形。
- 结果页容器尺寸变化时重新约束上下窗格布局,避免控制台或工作区尺寸变化造成裁切。
## 组件图标与模型合同
### 图标布局基础设施
- 组件图标布局可根据参数动态返回 `viewBox`、工作区画布、节点尺寸和端口锚点。
- 图标渲染器新增 `palette/canvas` 场景,使组件库固定缩略图与工作区动态图标能够分别定义。
- 删除旧 `legacyPortAnchors` 映射,端口布局、连接、吸附和 XML 导出统一使用组件目录及当前注册图标合同。
### LMECHN1 动态线性机械节点
- 模型版本由 `0.1.0` 升级到 `0.2.0`。
- 默认右侧端口数由 8 改为 2,允许范围扩展为 1~20。
- 目录注册 `port_1`~`port_21`;当 `v1=N` 时,显示 `N` 个右侧端口,并把 `port_{N+1}` 作为左侧参考端口。
- 工作区使用 `custom` 动态画布:端口数量增加时纵向扩展,改变数量时尽量保持元件中心不动。
- 默认 2 个右侧端口时节点约为 `148×96px`;20 个右侧端口时高度约为 `534px`。
- 主框线宽为 `2.5px`;引线和小连接框线宽为 `1.5px`;连接框约为 `6×5`,引线与连接框保持同轴。
- 组件库固定显示 5 个右侧引脚和 1 个左侧参考引脚,占组件库画布 `90%`,不受工作区参数影响。
- 端口编号随数量更新;20 个右侧端口时保持单列,旋转后保持单行且不重叠。
- “节点求和模式”后端仍保留唯一标准模式合同,前端隐藏该无效配置行。
- 旧 `0.1.0` 工程中的固定参考端口 `port_9` 可按 `v1` 迁移为动态参考端口,并将 `sum` 规范化为标准值。
- 求解层只要求活动端口 `port_1..port_{N+1}` 连接;活动端口位移、速度相等,力代数和为零。
- 未启用的预留端口增加 `x/v/f=0` 约束,避免其参与当前节点计算或造成方程欠定。
### PNRP17 图标
- 按参考图重绘上部壳体、上下左侧填充壁面、下部基座、活塞套、活塞面、水平活塞杆和接口引线。
- 左侧壳体补充参考图中的实心壁面。
- 上下两组左右箭头复用同一几何,右箭头由镜像生成,确保大小和形状完全一致。
- 箭头尖端分别触碰左侧壁面和活塞壁面。
- 两侧箭杆各等量延长 `1.8` 个逻辑单位,中间仍保留约 `2.4` 个逻辑单位的空隙,与参考图比例一致。
- 主轮廓线宽调整为 `2.1px`,箭头等细节调整为 `1.9px`。
- 使用约 `1.21` 的纵向比例修正整体外观;保持 `standard` 尺寸档位和约 `132×112px` 节点尺寸。
- 端口排列调整为:左侧 `port_3/port_2`、右侧 `port_4/port_5`、底部 `port_1`;锚点随最终几何同步更新。
- 忽略参考截图中的绿色接口提示像素,并保证引线不会被端口图标完全遮挡。
### PN3NODE2 与 P4NODE2 图标
- 两个节点均调整为 `small` 工作区画布,图形最长边占方形 viewBox 的 `40%`。
- 按参考图改为直线汇流结构:上下、右侧及端口外露引线使用 `1.5px` 细线,中心汇流支路使用 `4px` 粗线。
- 粗汇流支路显式绑定 `port_2`,固定显示在左侧;PN3NODE2 的其余端口位于上、下方,P4NODE2 的其余端口位于上、右、下方。
- 中心使用与粗线比例一致的实心洋红圆点,颜色统一为 `#8b134f`。
- 删除 SVG 内重复绘制的端口小圆,端口图形由统一的 React Flow Handle 负责;外露细引线在 Handle 覆盖后仍保持可见。
### PNL00R 与 PNL0001~PNL0003 图标
- 四个管路元件统一使用 `standard` 工作区画布,节点尺寸统一为约 `132×112px`。
- 图形以画布中心等比缩放,最长边占 64×64 方形 viewBox 的 `60%`。
- 左右接口锚点随图形同步收拢,引线在端口图标覆盖后仍保留可见长度。
- 组件库继续按 `standard` 档位的独立缩略图规则显示,不受工作区 60% 占比影响。
### MECMAS21 图标与默认状态
- `useFriction` 默认值由 1 改为 2,即默认启用摩擦。
- `stoptype` 默认值由 4 改为 1,即默认使用理想限位。
- 新拖入元件和组件库缩略图默认显示“有摩擦、有末端约束”外观。
- 根据摩擦与限位参数显示四种图标:有摩擦有约束、有摩擦无限位、无摩擦有约束、无摩擦无限位。
- 参数切换只改变摩擦、导轨和限位区域,核心滑块、节点尺寸和选择包络保持一致。
- 滑块主轮廓继续使用 `2.5px` 最终线宽。
### LSTP00A 图标
- 按参考图重绘左右接口框、三条竖向导轨、上部弹性曲线和下部机械连接结构。
- 使用 `standard` 画布和约 65% 的工作区占比;组件库按 standard 档位显示 90%。
- 图标颜色为 `#00af00`,工作区线宽统一为 `3px`。
- 左右引线延伸到接口 Handle 内侧后仍保持可见,节点包络基本为正方形。
### FORC 力源
- 模型版本由 `0.1.0` 升级为 `0.2.0`。
- 新增显式 `direction` 参数:`1` 为正向,`-1` 为反向。
- 删除图标旋转或镜像改变求解力符号的布局耦合;旋转和镜像只影响显示。
- 旧版 FORC 工程在前端定向迁移时补充正向参数和当前模型版本。
## 后端求解与安全性
### 组件与网络合同
- 为组件增加 `required_connection_ports`:默认要求全部物理端口连接,动态组件可只声明当前活动端口。
- 禁止同一组件的任意两个端口互相连接。
- 每个信号输入只允许一个驱动源。
- 后端和 System XML 仍支持一个信号输出连接多个输入;当前前端 UI 采用更严格的一端口一连接策略,不提供信号扇出。
- 物理岛检查只遍历物理组件与物理连接,信号连线不再错误合并独立物理岛。
- 可识别通过同一信号源控制、但自身缺少动态储能锚点的物理孤岛。
### 仿真采样安全
- 新增稳定的采样时间错误类型与错误码合同。
- 采样网格在分配数组前检查起止时间、跨度、步长和派生数量是否有限且可表示。
- 采样步长必须大于零;最多允许 `10001` 个采样点。
- 极小步长、整数转换溢出和浮点精度不足以推进绝对时间的情况会被提前拒绝。
- 采样序列保证同时包含起止时间、至少两个点且严格递增。
- System XML 语义校验会提前调用同一采样检查,不安全输入不会进入积分器。
## 工程 JSON v1
- ReactFlow 工程顶层增加必填 `projectSchemaVersion: 1`。
- 每个节点保存 `modelVersion`;执行前必须与组件目录当前模型版本完全一致。
- `componentType`、`modelType` 与注册模型类型必须一致。
- 端口必须保存为结构化对象,不再从旧字符串端口猜测合同。
- 工程边必须保存布尔值 `data.isContactEdge`。
- 参数显示单位、科学计数法原始文本和未来展示元数据继续参与工程持久化。
- 后端严格校验工程 JSON v1;损坏数据和不支持的版本返回 422。
- 删除并拒绝旧兼容标记:
- `mediumReferenceVersion`;
- `amesimParameterEncodingVersion`;
- `presentationLayoutVersion`。
## System XML v3
- System XML v3 成为当前唯一支持的求解协议,并新增 `schemas/system-simulation-v3.xsd` 与 `docs/system-xml-v3.md`。
- 删除 v1/v2 XSD 和协议文档,不再自动识别或迁移旧 XML。
- 根节点固定 `schemaVersion="3"` 和 `unitSystem="SI"`。
- `Simulation/@step` 更名为 `sampleStep`。
- 组件只保存 `id`、`type`、必填 `modelVersion` 和完整 SI 参数。
- 连接只保存可选 `id` 及两个 `(component, port)` 端点。
- 坐标、旋转、镜像、显示名、端口快照、连接 `kind/domain` 和端点 `role` 等编辑器数据不再进入 XML。
- 端口种类、物理域、信号方向和变量合同统一从组件注册表恢复。
- v3 语义校验覆盖模型版本、参数完整性与范围、重复 ID/端点、组件自连、端口存在性、端口域、信号方向、信号输入多驱动和物理端口重复连接。
- `/api/system-xml/parse` 返回编辑器无关的执行 `model`,不再返回 ReactFlow 工程。
- XML 可直接编译为公共 `SolverModelInput`;ReactFlow JSON 与 XML 共用网络编译入口。
- JSON→XML 导出会补齐注册默认参数、写出当前完整参数、剔除编辑器字段,并在返回前再次执行 XSD 与语义校验。
## 兼容性与破坏性变更
- System XML v1/v2 文件必须由来源端重新导出 v3,不能只修改版本号。
- 旧 XML 的 `step` 必须改为 `sampleStep`,并为每个组件补齐当前 `modelVersion` 和完整注册参数。
- `/api/system-xml/parse` 的响应从 ReactFlow `project` 改为执行 `model`,调用方必须适配。
- 严格模型版本检查会拒绝未迁移的 FORC `0.1.0` 和 LMECHN1 `0.1.0` 执行文件。
- MECMAS21 不再把旧 `0/1` 编码静默转换成 AMESim 原生 `1/2`;旧工程需要明确迁移。
- 介质引用不再猜测旧格式:缺失介质定义时不会把 `gi=1` 自动映射为内置空气,XML 缺失 `gi` 或 `property_model` 时不会自动补值。
- 缺少 `projectSchemaVersion`、使用字符串端口或携带已删除兼容标记的旧工程不符合工程 JSON v1。
- 删除 Pydantic v1 序列化兼容分支,后端当前明确依赖 Pydantic v2。
- `experimental` 组件包删除旧 `LIBRARY_ID/LABEL/VERSION/...` 兼容别名,只保留规范化 `LIBRARY` 清单。
## 文档与 Schema
### 新增
- `docs/backend-interface-version-spec-v1.md`:统一说明 HTTP、组件目录、工程 JSON、组件库、模型和 System XML 的版本边界与事实优先级。
- `docs/system-xml-v3.md`:System XML v3 当前协议。
- `schemas/system-simulation-v3.xsd`:System XML v3 Schema。
- `docs/后端求解逻辑与效率优化调研.md`:当前半显式 ODE、代数闭合、采样、流式任务与性能优化方向。
- `docs/接口类型与表示方式总结.md`:组件目录、工程 JSON、System XML 和编译结果之间的字段边界。
### 更新
- 根 `README.md`、`app/simulation/README.md` 和 `docs/README.md` 同步当前接口、协议入口和求解能力说明;文档索引新增本更新日志入口。
- `docs/component-library-spec-v1.md` 更新组件库发现、启动校验、临时库显示和前端读取规则。
- `docs/component-model-authoring-spec-v1.md` 更新模型版本、动态端口和合同测试要求。
- `docs/amesim-component-migration-matrix.md` 更新 AMESim 机械组件迁移状态。
### 删除
- `docs/system-xml-v1.md`
- `docs/system-xml-v2.md`
- `schemas/system-simulation-v1.xsd`
- `schemas/system-simulation-v2.xsd`
- `tests/test_reactflow_project_medium_reference.py`,其覆盖被工程 JSON v1、介质合同和 System XML v3 测试替代。
## 测试覆盖
### 新增或扩展的后端测试
- 工程 JSON v1 的版本、节点模型版本、端口结构、旧兼容标记、持久化元数据和损坏数据拒绝。
- System XML v3 的最小模型、编辑器字段拒绝、模型版本、参数、组件自连、端口合同、信号方向和连接基数。
- 仿真采样点上限、严格递增、两端点、极小步长、非有限时间和浮点不可推进时间。
- 物理岛检查在信号扇出场景下的正确性。
- FORC 正反方向、非法值及旋转不改变物理方向。
- MECMAS21 新默认值和旧编码拒绝。
- LMECHN1 默认 2 个、最大 20 个右侧端口、动态参考端口、未启用端口零约束和方程组方阵性。
- LMECHN1 的 2 端口与 8 端口完整仿真,均验证 `10 N / 2 kg = 5 m/s²`。
- 氦气、介质、气室、管路、阀、孔板、节点、PNRP17、UD00 等现有 XML 测试统一迁移到工程 JSON v1 与 System XML v3。
### 新增或扩展的前端 E2E
- 实际图标包络框选、框选回撤和虚线选择框。
- 待放置式粘贴、鼠标跟随、中键旋转、视口操作、ID 避让和控制台原生复制。
- 底部停靠控制台的范围、高度、折叠摘要和持久化。
- 组件库成功/失败状态、临时库过滤、固定图标模式、字号、折叠、悬停预览和工作区拖拽预览。
- 动态端口数量、编号、旋转布局、参考端口吸附、旧工程迁移及上限错误。
- 接触边无交互入口、已连接端口隐藏、端口圆形和随视口缩放。
- 高倍缩放吸附双下限、PNRP17 等长箭杆以及 PN3NODE2/P4NODE2 的 40% 直线汇流图标。
- PNL00R、PNL0001、PNL0002、PNL0003 的 standard 画布、60% 工作区占比与接口锚点。
- 工程版本、模型版本、MECMAS21 编码拒绝、System XML v3 导出与双精度参数文本。
- 结果页适配、端口图形、水印隐藏和控制台作用范围。
### 当前已复核的定向检查
- LMECHN1 方程、默认值、整数/上限与 2/8 端口仿真:6 个测试通过。
- 建模区端口连接、端口随画布缩放及结果页端口:3 个 E2E 通过。
- 本轮 PNRP17、PN3NODE2/P4NODE2、高倍缩放并开启网格的吸附、既有 10px 边界和控制台降噪:5 个定向 E2E 通过。
- PNL00R、PNL0001、PNL0002、PNL0003 的 standard 画布与 60% 工作区占比:1 个定向 E2E 通过。
- 合并远端求解优化后,完整后端测试共 `589` 项通过。
- 前端全量 E2E 首轮 `91/95` 通过;其余 4 项陈旧断言修正后定向重跑全部通过,当前 95 项覆盖均已验证。
- TypeScript 类型检查通过。
- Vite 生产构建通过;仍存在已有的单个大于 500 kB chunk 警告。
- `git diff --check` 在写入本日志前通过,仅报告既有 LF/CRLF 转换提示。
## 当前变更文件范围
### 前端源码
- `frontend/src/App.tsx`
- `frontend/src/ComponentSymbol.tsx`
- `frontend/src/ContactAwareEdge.tsx`
- `frontend/src/DockedSimulationConsole.tsx`(新增)
- `frontend/src/SimulationResultsView.tsx`
- `frontend/src/componentSymbols/mechanical.tsx`
- `frontend/src/componentSymbols/pneumatic.tsx`
- `frontend/src/componentSymbols/types.ts`
- `frontend/src/styles.css`
### 后端源码
- `app/main.py`
- `app/system_xml.py`
- `app/simulation/components/amesim/mechanical/translational.py`
- `app/simulation/components/experimental/__init__.py`
- `app/simulation/core/base.py`
- `app/simulation/systems/generic.py`
- `app/simulation/systems/network.py`
### 文档与 Schema
- `README.md`
- `app/simulation/README.md`
- `docs/README.md`
- `docs/amesim-component-migration-matrix.md`
- `docs/backend-interface-version-spec-v1.md`(新增)
- `docs/component-library-spec-v1.md`
- `docs/component-model-authoring-spec-v1.md`
- `docs/system-xml-v1.md`(删除)
- `docs/system-xml-v2.md`(删除)
- `docs/system-xml-v3.md`(新增)
- `docs/后端求解逻辑与效率优化调研.md`(新增)
- `docs/接口类型与表示方式总结.md`(新增)
- `docs/更新日志-2026-08-15.md`(新增)
- `schemas/system-simulation-v1.xsd`(删除)
- `schemas/system-simulation-v2.xsd`(删除)
- `schemas/system-simulation-v3.xsd`(新增)
### 前端测试
- `frontend/tests/e2e/amesim-medium.spec.ts`
- `frontend/tests/e2e/component-symbols.spec.ts`
- `frontend/tests/e2e/fit-view.spec.ts`
- `frontend/tests/e2e/fixtures.ts`
- `frontend/tests/e2e/modeling-actions.spec.ts`
- `frontend/tests/e2e/palette-display.spec.ts`
- `frontend/tests/e2e/parameter-table.spec.ts`
### 后端测试
- `tests/test_amesim_gas_registry.py`
- `tests/test_amesim_helium_medium.py`
- `tests/test_amesim_helium_step_long_run.py`
- `tests/test_amesim_mechanical_public_components.py`
- `tests/test_amesim_mechanical_xml.py`
- `tests/test_amesim_pnch012_xml.py`
- `tests/test_amesim_pnch023_xml.py`
- `tests/test_amesim_pneumatic_node_xml.py`
- `tests/test_amesim_pnl0001_xml.py`
- `tests/test_amesim_pnl0002_pnl0003_xml.py`
- `tests/test_amesim_pnl00r_xml.py`
- `tests/test_amesim_pnor001_xml.py`
- `tests/test_amesim_pnpl01_xml.py`
- `tests/test_amesim_pnrp17_xml.py`
- `tests/test_amesim_pnvo001_fixed_xml.py`
- `tests/test_amesim_pnvo001_signal_xml.py`
- `tests/test_amesim_signal_components.py`
- `tests/test_amesim_ud00_xml.py`
- `tests/test_component_catalog.py`
- `tests/test_generic_system_xml_simulation.py`
- `tests/test_medium_reference_contract.py`
- `tests/test_reactflow_project_medium_reference.py`(删除)
- `tests/test_reactflow_project_schema.py`(新增)
- `tests/test_simulation_safety.py`(新增)
- `tests/test_system_xml_parser.py`
- `tests/test_system_xml_protocol.py`
- `tests/test_system_xml_v3.py`(新增)
- `tests/test_test_mql_example_runner.py`
+1919 -1201
View File
File diff suppressed because it is too large. Load diff
+82 -72
View File
@@ -10,7 +10,11 @@ import {
AmesimForcSymbol, AmesimForcSymbol,
FORC_PORT_ANCHORS, FORC_PORT_ANCHORS,
AmesimLmechn1Symbol, AmesimLmechn1Symbol,
lmechn1RightPortCount,
lmechn1SymbolMetrics,
LSTP00A_PORT_ANCHORS,
MECMAS21_PORT_ANCHORS, MECMAS21_PORT_ANCHORS,
PNRP17_PORT_ANCHORS,
AmesimPnrp17Symbol, AmesimPnrp17Symbol,
AmesimLstp00aSymbol, AmesimLstp00aSymbol,
AmesimMecmas21Symbol, AmesimMecmas21Symbol,
@@ -29,6 +33,9 @@ import {
AmesimPnor001Symbol, AmesimPnor001Symbol,
AmesimPnvo001FixedSymbol, AmesimPnvo001FixedSymbol,
AmesimPnvo001SignalSymbol, AmesimPnvo001SignalSymbol,
P4NODE2_PORT_ANCHORS,
PIPE_NETWORK_PORT_ANCHORS,
PN3NODE2_PORT_ANCHORS,
PNVO001_PORT_ANCHORS, PNVO001_PORT_ANCHORS,
} from "./componentSymbols/pneumatic"; } from "./componentSymbols/pneumatic";
import { import {
@@ -126,18 +133,6 @@ function sameViewBox(
); );
} }
const LEGACY_HORIZONTAL_PORT_ANCHORS: ComponentSymbolPortAnchors = {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
};
const LEGACY_PNVO001_SIGNAL_PORT_ANCHORS: ComponentSymbolPortAnchors = {
left: [
{ x: 26, y: 46 },
{ x: 2, y: 24 },
],
right: [{ x: 62, y: 24 }],
};
type CustomSymbolLayout = { type CustomSymbolLayout = {
viewBox?: ComponentSymbolViewBox; viewBox?: ComponentSymbolViewBox;
symbolSize: ComponentSymbolSize; symbolSize: ComponentSymbolSize;
@@ -186,6 +181,30 @@ function makePortlessSymbolLayout(
}; };
} }
function lmechn1CanvasLayout(
parameters?: ComponentSymbolParameters,
): ComponentSymbolLayout {
const metrics = lmechn1SymbolMetrics(parameters);
const symbolHeight = metrics.viewBoxHeight * 2;
return makeSymbolLayout(
"custom",
{
left: [{ x: 2, y: metrics.viewBoxHeight / 2 }],
right: metrics.rightPortRows.map((y) => ({ x: 62, y })),
},
{
viewBox: {
x: 0,
y: 0,
width: 64,
height: metrics.viewBoxHeight,
},
symbolSize: { width: 128, height: symbolHeight },
nodeSize: { width: 148, height: symbolHeight },
},
);
}
function CylinderSymbol() { function CylinderSymbol() {
return ( return (
<SymbolSvg> <SymbolSvg>
@@ -377,7 +396,6 @@ const symbolRegistry = {
render: AmesimPnpl01Symbol, render: AmesimPnpl01Symbol,
layout: { layout: {
...makeSymbolLayout("compact", { left: [PNPL01_PORT_ANCHOR] }), ...makeSymbolLayout("compact", { left: [PNPL01_PORT_ANCHOR] }),
legacyPortAnchors: { left: [{ x: 2, y: 24 }] },
}, },
}, },
amesim_pnch023: { amesim_pnch023: {
@@ -388,10 +406,6 @@ const symbolRegistry = {
left: [PNCH023_PORT_ANCHORS.left], left: [PNCH023_PORT_ANCHORS.left],
right: [PNCH023_PORT_ANCHORS.right], right: [PNCH023_PORT_ANCHORS.right],
}), }),
legacyPortAnchors: {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
},
}, },
}, },
amesim_pnch012: { amesim_pnch012: {
@@ -409,16 +423,6 @@ const symbolRegistry = {
PNCH012_PORT_ANCHORS.bottom, PNCH012_PORT_ANCHORS.bottom,
], ],
}), }),
legacyPortAnchors: {
left: [
{ x: 2, y: 16 },
{ x: 2, y: 32 },
],
right: [
{ x: 62, y: 16 },
{ x: 62, y: 32 },
],
},
}, },
}, },
amesim_pnor001: { amesim_pnor001: {
@@ -429,7 +433,6 @@ const symbolRegistry = {
left: [PNVO001_PORT_ANCHORS.physicalLeft], left: [PNVO001_PORT_ANCHORS.physicalLeft],
right: [PNVO001_PORT_ANCHORS.physicalRight], right: [PNVO001_PORT_ANCHORS.physicalRight],
}), }),
legacyPortAnchors: LEGACY_HORIZONTAL_PORT_ANCHORS,
}, },
}, },
amesim_pnvo001_fixed: { amesim_pnvo001_fixed: {
@@ -440,7 +443,6 @@ const symbolRegistry = {
left: [PNVO001_PORT_ANCHORS.physicalLeft], left: [PNVO001_PORT_ANCHORS.physicalLeft],
right: [PNVO001_PORT_ANCHORS.physicalRight], right: [PNVO001_PORT_ANCHORS.physicalRight],
}), }),
legacyPortAnchors: LEGACY_HORIZONTAL_PORT_ANCHORS,
}, },
}, },
amesim_pnvo001: { amesim_pnvo001: {
@@ -454,49 +456,50 @@ const symbolRegistry = {
], ],
right: [PNVO001_PORT_ANCHORS.physicalRight], right: [PNVO001_PORT_ANCHORS.physicalRight],
}), }),
legacyPortAnchors: LEGACY_PNVO001_SIGNAL_PORT_ANCHORS,
}, },
}, },
amesim_pnl00r: { amesim_pnl00r: {
presentation: "framed", presentation: "framed",
render: AmesimPnl00rSymbol, render: AmesimPnl00rSymbol,
layout: makeSymbolLayout("compact", { layout: makeSymbolLayout("standard", {
left: [{ x: 2, y: 24 }], left: [PIPE_NETWORK_PORT_ANCHORS.left],
right: [{ x: 62, y: 24 }], right: [PIPE_NETWORK_PORT_ANCHORS.right],
}), }),
}, },
amesim_pnl0001: { amesim_pnl0001: {
presentation: "framed", presentation: "framed",
render: AmesimPnl0001Symbol, render: AmesimPnl0001Symbol,
layout: makeSymbolLayout("small", { layout: makeSymbolLayout("standard", {
left: [{ x: 2, y: 24 }], left: [PIPE_NETWORK_PORT_ANCHORS.left],
right: [{ x: 62, y: 24 }], right: [PIPE_NETWORK_PORT_ANCHORS.right],
}), }),
}, },
amesim_pnl0002: { amesim_pnl0002: {
presentation: "framed", presentation: "framed",
render: AmesimPnl0002Symbol, render: AmesimPnl0002Symbol,
layout: makeSymbolLayout("small", { layout: makeSymbolLayout("standard", {
left: [{ x: 2, y: 24 }], left: [PIPE_NETWORK_PORT_ANCHORS.left],
right: [{ x: 62, y: 24 }], right: [PIPE_NETWORK_PORT_ANCHORS.right],
}), }),
}, },
amesim_pnl0003: { amesim_pnl0003: {
presentation: "framed", presentation: "framed",
render: AmesimPnl0003Symbol, render: AmesimPnl0003Symbol,
layout: makeSymbolLayout("small", { layout: makeSymbolLayout("standard", {
left: [{ x: 2, y: 24 }], left: [PIPE_NETWORK_PORT_ANCHORS.left],
right: [{ x: 62, y: 24 }], right: [PIPE_NETWORK_PORT_ANCHORS.right],
}), }),
}, },
amesim_pn3node2: { amesim_pn3node2: {
presentation: "framed", presentation: "framed",
render: AmesimPn3Node2Symbol, render: AmesimPn3Node2Symbol,
layout: makeSymbolLayout("compact", { layout: makeSymbolLayout("small", {
left: [{ x: 3, y: 24 }], // 目录中 port_1 属于 left 组,但参考图把它显示在顶部;
// port_2 是粗线汇流支路,固定显示在左侧。
left: [PN3NODE2_PORT_ANCHORS.port1],
right: [ right: [
{ x: 61, y: 12 }, PN3NODE2_PORT_ANCHORS.port2,
{ x: 61, y: 36 }, PN3NODE2_PORT_ANCHORS.port3,
], ],
}), }),
}, },
@@ -504,11 +507,11 @@ const symbolRegistry = {
presentation: "framed", presentation: "framed",
render: AmesimP4Node2Symbol, render: AmesimP4Node2Symbol,
layout: makeSymbolLayout("small", { layout: makeSymbolLayout("small", {
left: [{ x: 3, y: 24 }], left: [P4NODE2_PORT_ANCHORS.port1],
right: [ right: [
{ x: 61, y: 8 }, P4NODE2_PORT_ANCHORS.port2,
{ x: 61, y: 24 }, P4NODE2_PORT_ANCHORS.port3,
{ x: 61, y: 40 }, P4NODE2_PORT_ANCHORS.port4,
], ],
}), }),
}, },
@@ -517,7 +520,6 @@ const symbolRegistry = {
render: AmesimStep0Symbol, render: AmesimStep0Symbol,
layout: { layout: {
...makeSymbolLayout("standard", { right: [SIGNAL_OUTPUT_PORT_ANCHOR] }), ...makeSymbolLayout("standard", { right: [SIGNAL_OUTPUT_PORT_ANCHOR] }),
legacyPortAnchors: { right: [{ x: 60, y: 24 }] },
}, },
}, },
amesim_ud00: { amesim_ud00: {
@@ -525,7 +527,6 @@ const symbolRegistry = {
render: AmesimUd00Symbol, render: AmesimUd00Symbol,
layout: { layout: {
...makeSymbolLayout("standard", { right: [SIGNAL_OUTPUT_PORT_ANCHOR] }), ...makeSymbolLayout("standard", { right: [SIGNAL_OUTPUT_PORT_ANCHOR] }),
legacyPortAnchors: { right: [{ x: 60, y: 24 }] },
}, },
}, },
amesim_f000: { amesim_f000: {
@@ -533,7 +534,6 @@ const symbolRegistry = {
render: AmesimF000Symbol, render: AmesimF000Symbol,
layout: { layout: {
...makeSymbolLayout("compact", { right: [F000_PORT_ANCHOR] }), ...makeSymbolLayout("compact", { right: [F000_PORT_ANCHOR] }),
legacyPortAnchors: { right: [{ x: 62, y: 24 }] },
}, },
}, },
amesim_forc: { amesim_forc: {
@@ -544,7 +544,6 @@ const symbolRegistry = {
left: [FORC_PORT_ANCHORS.left], left: [FORC_PORT_ANCHORS.left],
right: [FORC_PORT_ANCHORS.right], right: [FORC_PORT_ANCHORS.right],
}), }),
legacyPortAnchors: LEGACY_HORIZONTAL_PORT_ANCHORS,
}, },
}, },
amesim_mecmas21: { amesim_mecmas21: {
@@ -555,33 +554,36 @@ const symbolRegistry = {
left: [MECMAS21_PORT_ANCHORS.left], left: [MECMAS21_PORT_ANCHORS.left],
right: [MECMAS21_PORT_ANCHORS.right], right: [MECMAS21_PORT_ANCHORS.right],
}), }),
legacyPortAnchors: LEGACY_HORIZONTAL_PORT_ANCHORS,
}, },
variantKey: (parameters) => variantKey: (parameters) =>
`${parameters?.useFriction ?? ""}:${parameters?.stoptype ?? ""}`, `${parameters?.useFriction ?? 2}:${parameters?.stoptype ?? 1}`,
}, },
amesim_lstp00a: { amesim_lstp00a: {
presentation: "framed", presentation: "framed",
render: AmesimLstp00aSymbol, render: AmesimLstp00aSymbol,
layout: makeSymbolLayout("standard", { layout: {
left: [{ x: 2, y: 24 }], ...makeSymbolLayout("standard", {
right: [{ x: 62, y: 24 }], left: [LSTP00A_PORT_ANCHORS.left],
right: [LSTP00A_PORT_ANCHORS.right],
}), }),
}, },
},
amesim_lmechn1: { amesim_lmechn1: {
presentation: "framed", presentation: "framed",
render: AmesimLmechn1Symbol, render: AmesimLmechn1Symbol,
layout: makeSymbolLayout( layout: makeSymbolLayout(
"custom", "custom",
{ {
left: [7, 12, 17, 22, 27, 32, 37, 42].map((y) => ({ x: 5, y })), left: [{ x: 2, y: 24 }],
right: [{ x: 59, y: 24 }], right: [{ x: 62, y: 24 }],
}, },
{ {
symbolSize: { width: 128, height: 128 }, symbolSize: { width: 128, height: 128 },
nodeSize: { width: 148, height: 128 }, nodeSize: { width: 148, height: 128 },
}, },
), ),
layoutForParameters: lmechn1CanvasLayout,
variantKey: (parameters) => String(lmechn1RightPortCount(parameters)),
}, },
amesim_pnrp17: { amesim_pnrp17: {
presentation: "framed", presentation: "framed",
@@ -589,13 +591,13 @@ const symbolRegistry = {
// PNRP17 的当前 112×112 画布作为 standard 档位基准。 // PNRP17 的当前 112×112 画布作为 standard 档位基准。
layout: makeSymbolLayout("standard", { layout: makeSymbolLayout("standard", {
left: [ left: [
{ x: 25.5, y: 48 }, PNRP17_PORT_ANCHORS.bottom,
{ x: 2, y: 4.4 }, PNRP17_PORT_ANCHORS.upperLeft,
{ x: 2, y: 24.1 }, PNRP17_PORT_ANCHORS.middleLeft,
], ],
right: [ right: [
{ x: 62, y: 4.4 }, PNRP17_PORT_ANCHORS.upperRight,
{ x: 62, y: 24.1 }, PNRP17_PORT_ANCHORS.middleRight,
], ],
}), }),
}, },
@@ -635,10 +637,15 @@ export function componentSymbolPresentation(
: "framed"; : "framed";
} }
export function componentSymbolLayout(symbol: string): ComponentSymbolLayout { export function componentSymbolLayout(
return isRegisteredComponentSymbol(symbol) symbol: string,
? symbolRegistry[symbol].layout parameters?: ComponentSymbolParameters,
: fallbackComponentSymbolLayout; ): ComponentSymbolLayout {
if (!isRegisteredComponentSymbol(symbol)) {
return fallbackComponentSymbolLayout;
}
const definition: SymbolDefinition = symbolRegistry[symbol];
return definition.layoutForParameters?.(parameters) ?? definition.layout;
} }
export function componentSymbolPortAnchors( export function componentSymbolPortAnchors(
@@ -669,7 +676,10 @@ export function ComponentSymbol({
: "generic"; : "generic";
const definition: SymbolDefinition = symbolRegistry[resolvedSymbol]; const definition: SymbolDefinition = symbolRegistry[resolvedSymbol];
const SymbolRenderer = definition.render; const SymbolRenderer = definition.render;
const { layout } = definition; const layout =
surface === "canvas"
? definition.layoutForParameters?.(parameters) ?? definition.layout
: definition.layout;
const rootRef = useRef<HTMLSpanElement>(null); const rootRef = useRef<HTMLSpanElement>(null);
const [paletteViewBox, setPaletteViewBox] = useState<ComponentSymbolViewBox>( const [paletteViewBox, setPaletteViewBox] = useState<ComponentSymbolViewBox>(
layout.viewBox, layout.viewBox,
@@ -737,7 +747,7 @@ export function ComponentSymbol({
ref={rootRef} ref={rootRef}
> >
<SymbolViewBoxProvider viewBox={renderedViewBox}> <SymbolViewBoxProvider viewBox={renderedViewBox}>
<SymbolRenderer parameters={parameters} /> <SymbolRenderer parameters={parameters} surface={surface} />
</SymbolViewBoxProvider> </SymbolViewBoxProvider>
</span> </span>
); );
+4 -28
View File
@@ -6,11 +6,9 @@ import {
} from "@xyflow/react"; } from "@xyflow/react";
export const CONTACT_AWARE_EDGE_TYPE = "contact-aware"; export const CONTACT_AWARE_EDGE_TYPE = "contact-aware";
// React Flow 在 Loose 模式下会把重叠 Handle 的边端点分别放在圆点两侧,
// 因此两个端口中心完全重合时,edge props 仍会相差约一个 Handle 直径。
export const CONTACT_EDGE_SCREEN_TOLERANCE = 12;
export function ContactAwareEdge({ export function ContactAwareEdge({
data,
id, id,
interactionWidth, interactionWidth,
markerEnd, markerEnd,
@@ -23,31 +21,9 @@ export function ContactAwareEdge({
targetX, targetX,
targetY, targetY,
}: EdgeProps) { }: EdgeProps) {
const distance = Math.hypot(targetX - sourceX, targetY - sourceY); const isContactEdge = data?.isContactEdge === true;
if (distance <= CONTACT_EDGE_SCREEN_TOLERANCE) { if (isContactEdge) {
const centerX = (sourceX + targetX) / 2; return null;
const centerY = (sourceY + targetY) / 2;
return (
<>
<circle
aria-label="接触连接"
className="contact-edge-hit"
cx={centerX}
cy={centerY}
data-contact-edge-id={id}
r={12}
role="button"
tabIndex={0}
/>
<circle
aria-hidden="true"
className="contact-edge-marker"
cx={centerX}
cy={centerY}
r={3.5}
/>
</>
);
} }
const [edgePath] = getSmoothStepPath({ const [edgePath] = getSmoothStepPath({
+377
View File
@@ -0,0 +1,377 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import {
ChevronDown,
ChevronUp,
Square,
SquareTerminal,
Trash2,
} from "lucide-react";
type DockedConsoleMode = "minimized" | "normal" | "maximized";
type DockedConsoleLevel = "info" | "success" | "warning" | "error";
type DockedConsoleEntry = {
id: number;
timestamp: string;
level: DockedConsoleLevel;
message: string;
format: "message" | "xml";
};
type DockedConsoleProgress = {
state: "idle" | "running" | "success" | "warning" | "error";
percent: number;
message: string;
startTime: number;
simulatedTime: number;
totalTime: number;
};
type DockedSimulationConsoleProps = {
entries: DockedConsoleEntry[];
mode: DockedConsoleMode;
progress: DockedConsoleProgress;
onClear: () => void;
onModeChange: (mode: DockedConsoleMode) => void;
onStop: () => void;
stopPending: boolean;
};
type ResizeSession = {
pointerId: number;
startHeight: number;
startY: number;
};
const CONSOLE_DOCK_HEIGHT_KEY =
"system-simulation-flow:modeling-console-dock-height";
const DEFAULT_CONSOLE_DOCK_HEIGHT = 180;
const MIN_CONSOLE_DOCK_HEIGHT = 120;
const MAX_CONSOLE_DOCK_HEIGHT = 420;
const MIN_MODELING_CANVAS_HEIGHT = 240;
const COLLAPSED_CONSOLE_DOCK_HEIGHT = 40;
const consoleLevelLabels: Record<DockedConsoleLevel, string> = {
info: "信息",
success: "完成",
warning: "警告",
error: "错误",
};
function simulationProgressClockText(progress: DockedConsoleProgress) {
const simulatedTime = Number.isFinite(progress.simulatedTime)
? progress.simulatedTime
: progress.startTime;
const totalTime = Number.isFinite(progress.totalTime)
? progress.totalTime
: progress.startTime;
return `${simulatedTime.toFixed(2)} s / ${totalTime.toFixed(2)} s`;
}
function maximumConsoleDockHeight(hostHeight = window.innerHeight) {
return Math.max(
MIN_CONSOLE_DOCK_HEIGHT,
Math.min(
MAX_CONSOLE_DOCK_HEIGHT,
Math.round(hostHeight * 0.48),
Math.max(MIN_CONSOLE_DOCK_HEIGHT, hostHeight - MIN_MODELING_CANVAS_HEIGHT),
),
);
}
function clampConsoleDockHeight(height: number, hostHeight = window.innerHeight) {
return Math.min(
maximumConsoleDockHeight(hostHeight),
Math.max(MIN_CONSOLE_DOCK_HEIGHT, Math.round(height)),
);
}
function loadConsoleDockHeight() {
try {
const stored = Number(localStorage.getItem(CONSOLE_DOCK_HEIGHT_KEY));
return Number.isFinite(stored) && stored > 0
? clampConsoleDockHeight(stored)
: DEFAULT_CONSOLE_DOCK_HEIGHT;
} catch {
return DEFAULT_CONSOLE_DOCK_HEIGHT;
}
}
function storeConsoleDockHeight(height: number) {
try {
localStorage.setItem(CONSOLE_DOCK_HEIGHT_KEY, String(height));
} catch {
// 本地存储不可用时仍可在当前页面调整高度。
}
}
function collapsedSummary(
entries: DockedConsoleEntry[],
progress: DockedConsoleProgress,
) {
if (progress.state === "running") {
return `${progress.message} · ${simulationProgressClockText(progress)} · ${progress.percent}%`;
}
const latestEntry = entries.at(-1);
if (!latestEntry) {
return progress.message;
}
return latestEntry.format === "xml"
? "系统 XML 已生成"
: latestEntry.message.replace(/\s+/g, " ").trim();
}
export function DockedSimulationConsole({
entries,
mode,
progress,
onClear,
onModeChange,
onStop,
stopPending,
}: DockedSimulationConsoleProps) {
const dockRef = useRef<HTMLElement>(null);
const logRef = useRef<HTMLDivElement>(null);
const resizeSessionRef = useRef<ResizeSession | null>(null);
const [height, setHeight] = useState(loadConsoleDockHeight);
const [hostHeight, setHostHeight] = useState(() => window.innerHeight);
const [resizing, setResizing] = useState(false);
const collapsed = mode === "minimized";
const canStop = progress.state === "running" && !stopPending;
const summary = useMemo(
() => collapsedSummary(entries, progress),
[entries, progress],
);
useEffect(() => {
if (!collapsed && logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [collapsed, entries]);
useEffect(() => {
if (!collapsed) {
storeConsoleDockHeight(height);
}
}, [collapsed, height]);
useEffect(() => {
const host = dockRef.current?.parentElement;
const updateHostHeight = () => {
const nextHostHeight = host?.clientHeight ?? window.innerHeight;
setHostHeight(nextHostHeight);
setHeight((current) => clampConsoleDockHeight(current, nextHostHeight));
};
updateHostHeight();
const observer =
host && typeof ResizeObserver !== "undefined"
? new ResizeObserver(updateHostHeight)
: null;
if (host) {
observer?.observe(host);
}
window.addEventListener("resize", updateHostHeight);
return () => {
observer?.disconnect();
window.removeEventListener("resize", updateHostHeight);
};
}, []);
const beginResize = (event: ReactPointerEvent<HTMLDivElement>) => {
if (collapsed || event.button !== 0) {
return;
}
event.preventDefault();
resizeSessionRef.current = {
pointerId: event.pointerId,
startHeight: height,
startY: event.clientY,
};
event.currentTarget.setPointerCapture(event.pointerId);
setResizing(true);
};
const resize = (event: ReactPointerEvent<HTMLDivElement>) => {
const session = resizeSessionRef.current;
if (!session || session.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
setHeight(
clampConsoleDockHeight(
session.startHeight + session.startY - event.clientY,
hostHeight,
),
);
};
const finishResize = (event: ReactPointerEvent<HTMLDivElement>) => {
const session = resizeSessionRef.current;
if (!session || session.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
resizeSessionRef.current = null;
setResizing(false);
};
const consoleStyle = {
"--simulation-console-dock-height": `${
collapsed ? COLLAPSED_CONSOLE_DOCK_HEIGHT : height
}px`,
} as CSSProperties;
return (
<aside
aria-label="仿真控制台"
className={`simulation-console-dock ${collapsed ? "minimized" : "normal"} ${
resizing ? "resizing" : ""
}`}
data-editor-shortcuts-ignore="true"
ref={dockRef}
style={consoleStyle}
>
{!collapsed ? (
<div
aria-label="调整仿真控制台高度"
aria-orientation="horizontal"
aria-valuemax={maximumConsoleDockHeight(hostHeight)}
aria-valuemin={MIN_CONSOLE_DOCK_HEIGHT}
aria-valuenow={height}
className="simulation-console-dock-resizer"
onDoubleClick={() => setHeight(DEFAULT_CONSOLE_DOCK_HEIGHT)}
onKeyDown={(event) => {
const nextHeight =
event.key === "ArrowUp"
? height + 20
: event.key === "ArrowDown"
? height - 20
: event.key === "Home"
? MIN_CONSOLE_DOCK_HEIGHT
: event.key === "End"
? maximumConsoleDockHeight(hostHeight)
: null;
if (nextHeight === null) {
return;
}
event.preventDefault();
setHeight(clampConsoleDockHeight(nextHeight, hostHeight));
}}
onLostPointerCapture={finishResize}
onPointerCancel={finishResize}
onPointerDown={beginResize}
onPointerMove={resize}
onPointerUp={finishResize}
role="separator"
tabIndex={0}
title="拖动调整控制台高度,双击恢复默认高度"
/>
) : null}
<header className="simulation-console-dock-header">
<div className="simulation-console-dock-title">
<SquareTerminal aria-hidden="true" size={17} />
<strong>仿真控制台</strong>
<span className="simulation-console-dock-summary" title={summary}>
{summary}
</span>
</div>
<div className="simulation-console-dock-actions">
<button
aria-label={stopPending ? "正在停止仿真" : "停止仿真"}
className={canStop ? "active" : ""}
disabled={!canStop}
onClick={onStop}
title={canStop ? "停止当前仿真" : "当前没有可停止的仿真"}
type="button"
>
<Square aria-hidden="true" fill="currentColor" size={13} strokeWidth={1.8} />
</button>
{!collapsed ? (
<button
aria-label="清空控制台"
disabled={entries.length === 0}
onClick={onClear}
title="清空控制台"
type="button"
>
<Trash2 aria-hidden="true" size={16} />
</button>
) : null}
<button
aria-label={collapsed ? "展开仿真控制台" : "收起仿真控制台"}
onClick={() => onModeChange(collapsed ? "normal" : "minimized")}
title={collapsed ? "展开仿真控制台" : "收起仿真控制台"}
type="button"
>
{collapsed ? (
<ChevronUp aria-hidden="true" size={17} />
) : (
<ChevronDown aria-hidden="true" size={17} />
)}
</button>
</div>
</header>
{!collapsed ? (
<>
<div className={`simulation-console-dock-progress ${progress.state}`}>
<div className="simulation-console-dock-progress-label">
<span>{progress.message}</span>
<span>{simulationProgressClockText(progress)}</span>
<strong>{progress.percent}%</strong>
</div>
<div
aria-label={`仿真进度 ${progress.percent}%,${simulationProgressClockText(progress)}`}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progress.percent}
className="simulation-console-dock-progress-track"
role="progressbar"
>
<span style={{ width: `${progress.percent}%` }} />
</div>
</div>
<div
aria-label="控制台日志"
className="simulation-console-dock-log"
ref={logRef}
role="region"
>
{entries.length === 0 ? (
<div className="simulation-console-dock-empty">暂无仿真消息</div>
) : (
entries.map((entry) => (
<div
className={`simulation-console-dock-entry ${entry.level} ${entry.format}`}
data-entry-id={entry.id}
key={entry.id}
>
<time>{entry.timestamp}</time>
<span className="simulation-console-dock-entry-level">
{consoleLevelLabels[entry.level]}
</span>
{entry.format === "xml" ? (
<pre
aria-label="生成的系统 XML"
className="simulation-console-dock-entry-xml"
>
{entry.message}
</pre>
) : (
<span className="simulation-console-dock-entry-message">
{entry.message}
</span>
)}
</div>
))
)}
</div>
</>
) : null}
</aside>
);
}
+41 -9
View File
@@ -77,8 +77,11 @@ type ResultProjectEdge = {
id: string; id: string;
source: string; source: string;
target: string; target: string;
sourceHandle: string | null; sourceHandle: string;
targetHandle: string | null; targetHandle: string;
data: {
isContactEdge: boolean;
};
}; };
type ResultSimulationConfig = { type ResultSimulationConfig = {
@@ -131,8 +134,8 @@ export type SimulationResultsSnapshot = {
id: string; id: string;
createdAt: string; createdAt: string;
project: { project: {
projectSchemaVersion: 1;
name: string; name: string;
presentationLayoutVersion?: 1 | null;
nodes: ResultProjectNode[]; nodes: ResultProjectNode[];
edges: ResultProjectEdge[]; edges: ResultProjectEdge[];
simulation: ResultSimulationConfig; simulation: ResultSimulationConfig;
@@ -384,7 +387,18 @@ export function SimulationResultsView({
}; };
clampLayoutToWorkbench(); clampLayoutToWorkbench();
window.addEventListener("resize", clampLayoutToWorkbench); window.addEventListener("resize", clampLayoutToWorkbench);
return () => window.removeEventListener("resize", clampLayoutToWorkbench); const workbench = workbenchRef.current;
const observer =
workbench && typeof ResizeObserver !== "undefined"
? new ResizeObserver(clampLayoutToWorkbench)
: null;
if (workbench) {
observer?.observe(workbench);
}
return () => {
window.removeEventListener("resize", clampLayoutToWorkbench);
observer?.disconnect();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -1138,6 +1152,7 @@ export function SimulationResultsView({
nodes={readOnlyNodes} nodes={readOnlyNodes}
nodesConnectable={false} nodesConnectable={false}
nodesDraggable={false} nodesDraggable={false}
proOptions={{ hideAttribution: true }}
nodesFocusable nodesFocusable
onNodeClick={(_, node) => setSelectedNodeId(node.id)} onNodeClick={(_, node) => setSelectedNodeId(node.id)}
onPaneClick={() => setSelectedNodeId(null)} onPaneClick={() => setSelectedNodeId(null)}
@@ -3910,10 +3925,8 @@ function isSimulationResultsSnapshotValue(
const result = value.result; const result = value.result;
if ( if (
!isRecord(project) || !isRecord(project) ||
project.projectSchemaVersion !== 1 ||
typeof project.name !== "string" || typeof project.name !== "string" ||
(project.presentationLayoutVersion !== undefined &&
project.presentationLayoutVersion !== null &&
project.presentationLayoutVersion !== 1) ||
!Array.isArray(project.nodes) || !Array.isArray(project.nodes) ||
!project.nodes.every(isResultProjectNode) || !project.nodes.every(isResultProjectNode) ||
!Array.isArray(project.edges) || !Array.isArray(project.edges) ||
@@ -3954,6 +3967,7 @@ function isResultProjectNode(value: unknown): value is ResultProjectNode {
typeof data.componentType === "string" && typeof data.componentType === "string" &&
typeof data.modelType === "string" && typeof data.modelType === "string" &&
Array.isArray(data.ports) && Array.isArray(data.ports) &&
data.ports.every(isResultPortDefinition) &&
isRecord(data.parameters) && isRecord(data.parameters) &&
(data.parameterUnits === undefined || isRecord(data.parameterUnits)) && (data.parameterUnits === undefined || isRecord(data.parameterUnits)) &&
[0, 90, 180, 270].includes(Number(data.rotation)) && [0, 90, 180, 270].includes(Number(data.rotation)) &&
@@ -3961,14 +3975,32 @@ function isResultProjectNode(value: unknown): value is ResultProjectNode {
); );
} }
function isResultPortDefinition(value: unknown): value is ResultPortDefinition {
return (
isRecord(value) &&
typeof value.name === "string" &&
value.name.length > 0 &&
typeof value.kind === "string" &&
typeof value.domain === "string" &&
typeof value.nominalRole === "string" &&
(value.positiveFlowDirection === undefined ||
typeof value.positiveFlowDirection === "string") &&
(value.side === "left" || value.side === "right")
);
}
function isResultProjectEdge(value: unknown): value is ResultProjectEdge { function isResultProjectEdge(value: unknown): value is ResultProjectEdge {
return ( return (
isRecord(value) && isRecord(value) &&
typeof value.id === "string" && typeof value.id === "string" &&
typeof value.source === "string" && typeof value.source === "string" &&
typeof value.target === "string" && typeof value.target === "string" &&
(value.sourceHandle === null || typeof value.sourceHandle === "string") && typeof value.sourceHandle === "string" &&
(value.targetHandle === null || typeof value.targetHandle === "string") value.sourceHandle.length > 0 &&
typeof value.targetHandle === "string" &&
value.targetHandle.length > 0 &&
isRecord(value.data) &&
typeof value.data.isContactEdge === "boolean"
); );
} }
+281 -72
View File
@@ -63,12 +63,44 @@ export const MECMAS21_PORT_ANCHORS = {
y: 24, y: 24,
}, },
} as const; } as const;
const LSTP00A_STROKE = "#00af00";
const LSTP00A_STROKE_WIDTH = 3;
const LSTP00A_CONTENT_SCALE = (64 * 0.65) / 60;
const LSTP00A_CONTENT_CENTER = { x: 32, y: 24 } as const;
const LSTP00A_CONTENT_TRANSFORM = `translate(${LSTP00A_CONTENT_CENTER.x} ${LSTP00A_CONTENT_CENTER.y}) scale(${LSTP00A_CONTENT_SCALE}) translate(${-LSTP00A_CONTENT_CENTER.x} ${-LSTP00A_CONTENT_CENTER.y})`;
export const LSTP00A_PORT_ANCHORS = {
left: {
x:
LSTP00A_CONTENT_CENTER.x +
(2 - LSTP00A_CONTENT_CENTER.x) * LSTP00A_CONTENT_SCALE,
y: LSTP00A_CONTENT_CENTER.y,
},
right: {
x:
LSTP00A_CONTENT_CENTER.x +
(62 - LSTP00A_CONTENT_CENTER.x) * LSTP00A_CONTENT_SCALE,
y: LSTP00A_CONTENT_CENTER.y,
},
} as const;
const PNRP17_STROKE = "#8b134f"; const PNRP17_STROKE = "#8b134f";
const PNRP17_FILL = "#cf9db6"; const PNRP17_FILL = "#cf9db6";
const PNRP17_LIGHT_FILL = "#f5ebf0"; const PNRP17_LIGHT_FILL = "#f5ebf0";
// PNRP17 使用最终像素线宽;几何缩放不再改变参考图中的粗细层级。 // PNRP17 使用最终像素线宽;几何缩放不再改变参考图中的粗细层级。
const PNRP17_PRIMARY_STROKE_WIDTH = 2; const PNRP17_PRIMARY_STROKE_WIDTH = 2.1;
const PNRP17_DETAIL_STROKE_WIDTH = 1.5; const PNRP17_DETAIL_STROKE_WIDTH = 1.9;
const PNRP17_VERTICAL_CENTER = 24.5;
const PNRP17_VERTICAL_SCALE = 1.21;
const PNRP17_CONTENT_TRANSFORM = `translate(0 ${PNRP17_VERTICAL_CENTER}) scale(1 ${PNRP17_VERTICAL_SCALE}) translate(0 ${-PNRP17_VERTICAL_CENTER})`;
const pnrp17ScaledY = (y: number) =>
PNRP17_VERTICAL_CENTER +
(y - PNRP17_VERTICAL_CENTER) * PNRP17_VERTICAL_SCALE;
export const PNRP17_PORT_ANCHORS = {
bottom: { x: 25.5, y: pnrp17ScaledY(48) },
upperLeft: { x: 2, y: pnrp17ScaledY(4.4) },
middleLeft: { x: 2, y: pnrp17ScaledY(24.1) },
upperRight: { x: 62, y: pnrp17ScaledY(4.4) },
middleRight: { x: 62, y: pnrp17ScaledY(24.1) },
} as const;
const mechanicalStroke = { const mechanicalStroke = {
fill: "none", fill: "none",
@@ -200,8 +232,8 @@ export function AmesimForcSymbol() {
export function AmesimMecmas21Symbol({ export function AmesimMecmas21Symbol({
parameters, parameters,
}: ComponentSymbolRendererProps) { }: ComponentSymbolRendererProps) {
const frictionEnabled = Number(parameters?.useFriction) === 2; const frictionEnabled = Number(parameters?.useFriction ?? 2) === 2;
const stopType = Number(parameters?.stoptype); const stopType = Number(parameters?.stoptype ?? 1);
const limitsEnabled = stopType === 1 || stopType === 2 || stopType === 3; const limitsEnabled = stopType === 1 || stopType === 2 || stopType === 3;
const showGuides = frictionEnabled || limitsEnabled; const showGuides = frictionEnabled || limitsEnabled;
const variant = frictionEnabled const variant = frictionEnabled
@@ -342,98 +374,270 @@ export function AmesimMecmas21Symbol({
export function AmesimLstp00aSymbol() { export function AmesimLstp00aSymbol() {
return ( return (
<SymbolSvg className="component-symbol-amesim-lstp00a" domain="mechanical"> <SymbolSvg className="component-symbol-amesim-lstp00a" domain="mechanical">
<path d="M2 24h8M54 24h8" {...mechanicalStroke} /> <g
<g transform="rotate(90 32 24)"> data-lstp00a-part="symbol"
<path d="M32 2v7M32 39v7" {...mechanicalStroke} /> fill="#ffffff"
<path d="M27 9V5h10v4M27 39v4h10v-4" {...mechanicalStroke} /> stroke={LSTP00A_STROKE}
<rect strokeLinecap="butt"
fill={SYMBOL_FILL} strokeLinejoin="miter"
height="3" strokeWidth={LSTP00A_STROKE_WIDTH}
stroke={SYMBOL_STROKE} transform={LSTP00A_CONTENT_TRANSFORM}
strokeWidth="1.6" >
width="46" <path
x="9" d="M2 24H7.5"
y="10" data-lstp00a-part="left-extension"
/> fill="none"
<rect
fill={SYMBOL_FILL}
height="3"
stroke={SYMBOL_STROKE}
strokeWidth="1.6"
width="46"
x="9"
y="31"
/>
<rect
fill={SYMBOL_FILL}
height="3"
stroke={SYMBOL_STROKE}
strokeWidth="1.6"
width="46"
x="9"
y="39"
/> />
<path <path
d="M14 13v4c0 2 12 2 12 4s-12 2-12 4 12 2 12 6" d="M56.5 24H62"
data-lstp00a-part="right-extension"
fill="none"
/>
<path
data-lstp00a-part="left-socket"
d="M13.75 19.75H6.25V28.25H13.75"
fill="none"
/>
<path
data-lstp00a-part="right-socket"
d="M49.75 19.75H57.25V28.25H49.75"
fill="none"
/>
<path
d="M28.75 17.2C31.2 16 32.6 0 35 0c1.4 0 2.2 17.2 3.7 17.2 2.8 0 3.8-16 7.3-16"
data-lstp00a-part="spring"
fill="none" fill="none"
stroke={SYMBOL_ACCENT}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2"
/> />
<path d="M36 18v-5M36 18h14v6H36v7M40 22h6" {...mechanicalStroke} /> <path
d="M28.75 39H36M41 39H46M36 31V47M33.75 27H41V50.5H33.75"
data-lstp00a-part="lower-link"
fill="none"
/>
<rect
data-lstp00a-part="left-rail"
height="53"
width="3.75"
x="13.75"
y="-2.5"
/>
<rect
data-lstp00a-part="middle-rail"
height="53"
width="3.75"
x="25"
y="-2.5"
/>
<rect
data-lstp00a-part="right-rail"
height="53"
width="3.75"
x="46"
y="-2.5"
/>
</g> </g>
</SymbolSvg> </SymbolSvg>
); );
} }
export function AmesimLmechn1Symbol() { export const LMECHN1_DEFAULT_RIGHT_PORT_COUNT = 2;
const leftPortRows = [7, 12, 17, 22, 27, 32, 37, 42]; export const LMECHN1_MAX_RIGHT_PORT_COUNT = 20;
const LMECHN1_PALETTE_RIGHT_PORT_COUNT = 5;
const LMECHN1_PORT_ROW_SPACING = 13;
const LMECHN1_BODY_LEFT = 18;
const LMECHN1_BODY_RIGHT = 46;
const LMECHN1_SOCKET_WIDTH = 6;
const LMECHN1_SOCKET_HALF_HEIGHT = 2.5;
const LMECHN1_LEFT_SOCKET_OUTER_X = LMECHN1_BODY_LEFT - LMECHN1_SOCKET_WIDTH;
const LMECHN1_RIGHT_SOCKET_OUTER_X = LMECHN1_BODY_RIGHT + LMECHN1_SOCKET_WIDTH;
export function lmechn1RightPortCount(
parameters?: ComponentSymbolRendererProps["parameters"],
) {
const candidate = Number(parameters?.v1);
if (!Number.isInteger(candidate)) {
return LMECHN1_DEFAULT_RIGHT_PORT_COUNT;
}
return Math.min(
LMECHN1_MAX_RIGHT_PORT_COUNT,
Math.max(1, candidate),
);
}
export function lmechn1SymbolMetrics(
parameters?: ComponentSymbolRendererProps["parameters"],
) {
const rightPortCount = lmechn1RightPortCount(parameters);
const requestedBodyHeight = Math.max(
28,
12 + (rightPortCount - 1) * LMECHN1_PORT_ROW_SPACING,
);
const viewBoxHeight = Math.max(48, requestedBodyHeight + 8);
const bodyHeight = Math.min(requestedBodyHeight, viewBoxHeight - 8);
const bodyTop = (viewBoxHeight - bodyHeight) / 2;
const rightPortSpan = (rightPortCount - 1) * LMECHN1_PORT_ROW_SPACING;
const firstPortY = (viewBoxHeight - rightPortSpan) / 2;
const rightPortRows = Array.from(
{ length: rightPortCount },
(_, index) => firstPortY + index * LMECHN1_PORT_ROW_SPACING,
);
return {
bodyHeight,
bodyTop,
rightPortCount,
rightPortRows,
viewBoxHeight,
} as const;
}
export function AmesimLmechn1Symbol({
parameters,
surface,
}: ComponentSymbolRendererProps) {
// 组件库始终展示一个固定、易辨识的标准图;工作区才按实际端口数展开。
const metrics = lmechn1SymbolMetrics(
surface === "palette"
? { ...parameters, v1: LMECHN1_PALETTE_RIGHT_PORT_COUNT }
: parameters,
);
const centerY = metrics.viewBoxHeight / 2;
return ( return (
<SymbolSvg className="component-symbol-amesim-lmechn1" domain="mechanical"> <SymbolSvg className="component-symbol-amesim-lmechn1" domain="mechanical">
<rect <rect
data-lmechn1-part="body"
fill={SYMBOL_FILL} fill={SYMBOL_FILL}
height="44" height={metrics.bodyHeight}
rx="1" rx="1"
stroke={SYMBOL_STROKE} stroke={SYMBOL_STROKE}
strokeWidth="1.8" strokeWidth="2.5"
width="12" width={LMECHN1_BODY_RIGHT - LMECHN1_BODY_LEFT}
x="27" x={LMECHN1_BODY_LEFT}
y="2" y={metrics.bodyTop}
/> />
{leftPortRows.map((y) => ( {metrics.rightPortRows.map((y, index) => (
<g data-lmechn1-port={`port_${index + 1}`} key={index}>
<path <path
d={`M5 ${y}h22`} d={`M${LMECHN1_RIGHT_SOCKET_OUTER_X} ${y}H62`}
key={y} data-lmechn1-part="right-port-line"
fill="none"
stroke={SYMBOL_STROKE} stroke={SYMBOL_STROKE}
strokeLinecap="round" strokeLinecap="butt"
strokeWidth="1.4" strokeWidth="1.5"
/> />
<path
d={`M${LMECHN1_BODY_RIGHT} ${y - LMECHN1_SOCKET_HALF_HEIGHT}H${LMECHN1_RIGHT_SOCKET_OUTER_X}V${y + LMECHN1_SOCKET_HALF_HEIGHT}H${LMECHN1_BODY_RIGHT}`}
data-lmechn1-part="right-port-socket"
fill="none"
stroke={SYMBOL_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth="1.5"
/>
</g>
))} ))}
<path d="M39 24h20" stroke={SYMBOL_STROKE} strokeWidth="1.8" /> <g data-lmechn1-part="reference-port">
<circle cx="33" cy="24" fill={SYMBOL_ACCENT} r="2.2" /> <path
d={`M2 ${centerY}H${LMECHN1_LEFT_SOCKET_OUTER_X}`}
data-lmechn1-part="reference-port-line"
fill="none"
stroke={SYMBOL_STROKE}
strokeLinecap="butt"
strokeWidth="1.5"
/>
<path
d={`M${LMECHN1_BODY_LEFT} ${centerY - LMECHN1_SOCKET_HALF_HEIGHT}H${LMECHN1_LEFT_SOCKET_OUTER_X}V${centerY + LMECHN1_SOCKET_HALF_HEIGHT}H${LMECHN1_BODY_LEFT}`}
data-lmechn1-part="reference-port-socket"
fill="none"
stroke={SYMBOL_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth="1.5"
/>
</g>
<text
data-lmechn1-part="label"
fill={SYMBOL_STROKE}
fontFamily="Arial, sans-serif"
fontSize="6.2"
fontWeight="600"
stroke="none"
textAnchor="middle"
x="32"
y={centerY - 6.2}
>
<tspan x="32">linear</tspan>
<tspan dy="6.5" x="32">node</tspan>
<tspan dy="6.5" x="32">(displ.)</tspan>
</text>
</SymbolSvg> </SymbolSvg>
); );
} }
function Pnrp17ArrowPair({ centerY }: { centerY: number }) {
return (
<g
data-pnrp17-part="chamber-arrows"
fill="none"
stroke={PNRP17_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth={PNRP17_DETAIL_STROKE_WIDTH}
transform={`translate(0 ${centerY})`}
>
<g data-pnrp17-arrow="left" transform="translate(18.2 0)">
<path d="M9.1 0H3.9" />
<path d="M3.9 -2.5 0 0 3.9 2.5Z" fill="#ffffff" />
</g>
<g
data-pnrp17-arrow="right"
transform="translate(38.8 0) scale(-1 1)"
>
<path d="M9.1 0H3.9" />
<path d="M3.9 -2.5 0 0 3.9 2.5Z" fill="#ffffff" />
</g>
</g>
);
}
export function AmesimPnrp17Symbol() { export function AmesimPnrp17Symbol() {
return ( return (
<SymbolSvg className="component-symbol-amesim-pnrp17" domain="mechanical"> <SymbolSvg className="component-symbol-amesim-pnrp17" domain="mechanical">
<g data-pnrp17-part="symbol" transform={PNRP17_CONTENT_TRANSFORM}>
<path <path
d="M13.4 1.1H51.4V3h4.1v2.9h-4.1v1.9H18.2v13.8h-10V7h5.2V5.9H9.3V3h4.1Z" data-pnrp17-part="upper-shell"
d="M13.4 1.1H51.4V3h4.1v2.9h-4.1v1.9H18.2V21.1H3.3V7.3H13.4V5.9H9.3V3h4.1Z"
fill={PNRP17_FILL} fill={PNRP17_FILL}
stroke={PNRP17_STROKE} stroke={PNRP17_STROKE}
strokeLinejoin="miter" strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH} strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/> />
<rect
data-pnrp17-part="upper-left-wall"
fill={PNRP17_STROKE}
height="13.8"
width="2.9"
x="15.3"
y="7.3"
/>
<path <path
d="M8.2 26.7h12.7v13.8h2v6.7H8.2Z" data-pnrp17-part="lower-left-shell"
d="M3.3 27H18.2V40.7H20V46.6H3.3Z"
fill={PNRP17_FILL} fill={PNRP17_FILL}
stroke={PNRP17_STROKE} stroke={PNRP17_STROKE}
strokeLinejoin="miter" strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH} strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/> />
<rect
data-pnrp17-part="lower-left-wall"
fill={PNRP17_STROKE}
height="13.7"
width="2.9"
x="15.3"
y="27"
/>
<path <path
data-pnrp17-part="lower-base"
d="M28 40.5h28.5v6.7H28Z" d="M28 40.5h28.5v6.7H28Z"
fill={PNRP17_FILL} fill={PNRP17_FILL}
stroke={PNRP17_STROKE} stroke={PNRP17_STROKE}
@@ -441,60 +645,65 @@ export function AmesimPnrp17Symbol() {
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH} strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/> />
<rect <rect
data-pnrp17-part="piston-sleeve"
fill={PNRP17_LIGHT_FILL} fill={PNRP17_LIGHT_FILL}
height="34" height="34"
stroke={PNRP17_STROKE} stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH} strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
width="7.7" width="7.7"
x="40.8" x="40.8"
y="7.2" y="7.2"
/> />
<rect fill={PNRP17_STROKE} height="34" width="1.8" x="38.8" y="7.2" />
<rect <rect
data-pnrp17-part="piston-face"
fill={PNRP17_STROKE}
height="34"
width="1.8"
x="38.8"
y="7.2"
/>
<rect
data-pnrp17-part="horizontal-rod-left"
fill={PNRP17_LIGHT_FILL} fill={PNRP17_LIGHT_FILL}
height="2.8" height="2.8"
stroke={PNRP17_STROKE} stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH} strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
width="31.5" width="31.5"
x="9.3" x="9.3"
y="22.7" y="22.7"
/> />
<rect <rect
data-pnrp17-part="horizontal-rod-right"
fill={PNRP17_LIGHT_FILL} fill={PNRP17_LIGHT_FILL}
height="2.8" height="2.8"
stroke={PNRP17_STROKE} stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH} strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
width="7.7" width="7.7"
x="47.8" x="47.8"
y="22.7" y="22.7"
/> />
<path <path
d="M2 4.4h7.3m46.2 0H62M2 24.1h7.3m46.2 0H62M25.5 43.4V48" data-pnrp17-part="horizontal-extensions"
d="M2 4.4h7.3m46.2 0H62M2 24.1h7.3m46.2 0H62"
fill="none"
stroke={PNRP17_STROKE} stroke={PNRP17_STROKE}
strokeLinecap="butt" strokeLinecap="butt"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH} strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/> />
{[15.3, 33].map((centerY) => ( <path
<g data-pnrp17-part="bottom-port"
d="M25.5 43.4V48"
fill="none" fill="none"
key={centerY}
stroke={PNRP17_STROKE} stroke={PNRP17_STROKE}
strokeLinecap="butt" strokeLinecap="butt"
strokeLinejoin="miter" strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
strokeWidth={PNRP17_DETAIL_STROKE_WIDTH}
>
<path d={`M28.8 ${centerY}h-3.4`} />
<path
d={`M25.4 ${centerY - 2.5} 21.5 ${centerY}l3.9 2.5Z`}
fill="#ffffff"
/>
<path d={`M30.9 ${centerY}h3.4`} />
<path
d={`M34.3 ${centerY - 2.5} 38.3 ${centerY} 34.3 ${centerY + 2.5}Z`}
fill="#ffffff"
/> />
<Pnrp17ArrowPair centerY={15.15} />
<Pnrp17ArrowPair centerY={32.9} />
</g> </g>
))}
</SymbolSvg> </SymbolSvg>
); );
} }
+82 -33
View File
@@ -118,6 +118,26 @@ export function AmesimPnvo001SignalSymbol() {
type PipeSection = "C" | "R"; type PipeSection = "C" | "R";
const PIPE_NETWORK_CONTENT_CENTER = { x: 32, y: 24 } as const;
const PIPE_NETWORK_CONTENT_SCALE = 0.64;
const PIPE_NETWORK_CONTENT_TRANSFORM = `translate(${PIPE_NETWORK_CONTENT_CENTER.x} ${PIPE_NETWORK_CONTENT_CENTER.y}) scale(${PIPE_NETWORK_CONTENT_SCALE}) translate(${-PIPE_NETWORK_CONTENT_CENTER.x} ${-PIPE_NETWORK_CONTENT_CENTER.y})`;
function scaledPipeNetworkPoint(x: number, y: number) {
return {
x:
PIPE_NETWORK_CONTENT_CENTER.x +
(x - PIPE_NETWORK_CONTENT_CENTER.x) * PIPE_NETWORK_CONTENT_SCALE,
y:
PIPE_NETWORK_CONTENT_CENTER.y +
(y - PIPE_NETWORK_CONTENT_CENTER.y) * PIPE_NETWORK_CONTENT_SCALE,
};
}
export const PIPE_NETWORK_PORT_ANCHORS = {
left: scaledPipeNetworkPoint(2, 24),
right: scaledPipeNetworkPoint(62, 24),
} as const;
function PipeNetworkBody({ sections }: { sections: readonly PipeSection[] }) { function PipeNetworkBody({ sections }: { sections: readonly PipeSection[] }) {
const left = 9; const left = 9;
const right = 55; const right = 55;
@@ -126,7 +146,10 @@ function PipeNetworkBody({ sections }: { sections: readonly PipeSection[] }) {
const sectionWidth = (right - left) / sections.length; const sectionWidth = (right - left) / sections.length;
return ( return (
<> <g
data-pipe-network-content="true"
transform={PIPE_NETWORK_CONTENT_TRANSFORM}
>
<path <path
d="M2 24h7M55 24h7" d="M2 24h7M55 24h7"
stroke={SYMBOL_STROKE} stroke={SYMBOL_STROKE}
@@ -169,7 +192,7 @@ function PipeNetworkBody({ sections }: { sections: readonly PipeSection[] }) {
{section} {section}
</text> </text>
))} ))}
</> </g>
); );
} }
@@ -209,34 +232,50 @@ export function AmesimPnl0003Symbol() {
); );
} }
function JunctionTerminal({ x, y }: { x: number; y: number }) { const JUNCTION_STROKE = "#8b134f";
return ( const JUNCTION_FINE_STROKE_WIDTH = 1.5;
<circle const JUNCTION_COLLECTOR_STROKE_WIDTH = 4;
cx={x} const JUNCTION_CENTER_RADIUS = 2.75;
cy={y}
fill={SYMBOL_FILL} export const PN3NODE2_PORT_ANCHORS = {
r="2" port1: { x: 36.6, y: 11.2 },
stroke={SYMBOL_STROKE} port2: { x: 24.6, y: 24 },
strokeWidth="1.3" port3: { x: 36.6, y: 36.8 },
/> } as const;
);
} export const P4NODE2_PORT_ANCHORS = {
port1: { x: 32, y: 11.2 },
port2: { x: 19.2, y: 24 },
port3: { x: 44.8, y: 24 },
port4: { x: 32, y: 36.8 },
} as const;
/** Three-port pneumatic junction with one inlet-side and two outlet-side ports. */ /** Three-port pneumatic junction with one inlet-side and two outlet-side ports. */
export function AmesimPn3Node2Symbol() { export function AmesimPn3Node2Symbol() {
return ( return (
<SymbolSvg className="component-symbol-amesim-pn3node2-svg" domain="pneumatic"> <SymbolSvg className="component-symbol-amesim-pn3node2-svg" domain="pneumatic">
<path <path
d="M4 24h25M29 24C41 24 42 12 60 12M29 24c12 0 13 12 31 12" d="M36.6 11.2V36.8M24.6 24H27.5"
stroke={SYMBOL_STROKE} data-junction-part="fine-branches"
strokeLinecap="round" stroke={JUNCTION_STROKE}
strokeLinejoin="round" strokeLinecap="butt"
strokeWidth={PNEUMATIC_STROKE_WIDTH} strokeWidth={JUNCTION_FINE_STROKE_WIDTH}
/>
<path
d="M27.5 24H36.6"
data-junction-part="collector-branch"
data-port-name="port_2"
stroke={JUNCTION_STROKE}
strokeLinecap="butt"
strokeWidth={JUNCTION_COLLECTOR_STROKE_WIDTH}
/>
<circle
cx="36.6"
cy="24"
data-junction-part="center"
fill={JUNCTION_STROKE}
r={JUNCTION_CENTER_RADIUS}
/> />
<circle cx="29" cy="24" fill={SYMBOL_ACCENT} r="3" />
<JunctionTerminal x={3} y={24} />
<JunctionTerminal x={61} y={12} />
<JunctionTerminal x={61} y={36} />
</SymbolSvg> </SymbolSvg>
); );
} }
@@ -246,17 +285,27 @@ export function AmesimP4Node2Symbol() {
return ( return (
<SymbolSvg className="component-symbol-amesim-p4node2-svg" domain="pneumatic"> <SymbolSvg className="component-symbol-amesim-p4node2-svg" domain="pneumatic">
<path <path
d="M4 24h24M28 24C40 24 42 8 60 8M28 24h32M28 24c12 0 14 16 32 16" d="M32 11.2V36.8M32 24H44.8M19.2 24H22.1"
stroke={SYMBOL_STROKE} data-junction-part="fine-branches"
strokeLinecap="round" stroke={JUNCTION_STROKE}
strokeLinejoin="round" strokeLinecap="butt"
strokeWidth={PNEUMATIC_STROKE_WIDTH} strokeWidth={JUNCTION_FINE_STROKE_WIDTH}
/>
<path
d="M22.1 24H32"
data-junction-part="collector-branch"
data-port-name="port_2"
stroke={JUNCTION_STROKE}
strokeLinecap="butt"
strokeWidth={JUNCTION_COLLECTOR_STROKE_WIDTH}
/>
<circle
cx="32"
cy="24"
data-junction-part="center"
fill={JUNCTION_STROKE}
r={JUNCTION_CENTER_RADIUS}
/> />
<circle cx="28" cy="24" fill={SYMBOL_ACCENT} r="3" />
<JunctionTerminal x={3} y={24} />
<JunctionTerminal x={61} y={8} />
<JunctionTerminal x={61} y={24} />
<JunctionTerminal x={61} y={40} />
</SymbolSvg> </SymbolSvg>
); );
} }
+4 -2
View File
@@ -42,8 +42,6 @@ export type ComponentSymbolLayout = {
symbolSize: ComponentSymbolSize; symbolSize: ComponentSymbolSize;
nodeSize: ComponentSymbolSize; nodeSize: ComponentSymbolSize;
portAnchors?: ComponentSymbolPortAnchors; portAnchors?: ComponentSymbolPortAnchors;
/** 仅用于识别 presentationLayoutVersion 之前保存的固定尺寸接触连接。 */
legacyPortAnchors?: ComponentSymbolPortAnchors;
}; };
export type ComponentSymbolParameters = Readonly< export type ComponentSymbolParameters = Readonly<
@@ -52,11 +50,15 @@ export type ComponentSymbolParameters = Readonly<
export type ComponentSymbolRendererProps = { export type ComponentSymbolRendererProps = {
parameters?: ComponentSymbolParameters; parameters?: ComponentSymbolParameters;
surface?: "palette" | "canvas";
}; };
export type SymbolDefinition = { export type SymbolDefinition = {
presentation: ComponentSymbolPresentation; presentation: ComponentSymbolPresentation;
render: ComponentType<ComponentSymbolRendererProps>; render: ComponentType<ComponentSymbolRendererProps>;
layout: ComponentSymbolLayout; layout: ComponentSymbolLayout;
layoutForParameters?: (
parameters?: ComponentSymbolParameters,
) => ComponentSymbolLayout;
variantKey?: (parameters?: ComponentSymbolParameters) => string; variantKey?: (parameters?: ComponentSymbolParameters) => string;
}; };
+536 -171
View File
@@ -460,7 +460,7 @@ textarea {
.palette-list { .palette-list {
display: block; display: block;
padding: 12px; padding: 10px;
} }
.parameter-form { .parameter-form {
@@ -965,46 +965,14 @@ textarea {
flex-wrap: wrap; flex-wrap: wrap;
} }
.palette-display-mode { .palette-panel-title > span {
display: inline-flex; font-size: 16px;
flex: 0 0 auto; font-weight: 700;
gap: 1px; line-height: 1.25;
padding: 1px;
border: 1px solid #cad5e1;
border-radius: 5px;
background: #f4f7fa;
}
.palette-display-mode-button {
display: grid;
width: 23px;
height: 22px;
padding: 0;
border: 0;
border-radius: 3px;
place-items: center;
color: #64748b;
background: transparent;
cursor: pointer;
}
.palette-display-mode-button:hover {
color: #155a94;
background: #e6f0f8;
}
.palette-display-mode-button:focus-visible {
outline: 2px solid #1d6fb8;
outline-offset: 1px;
}
.palette-display-mode-button[aria-pressed="true"] {
color: #155a94;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.16);
} }
.catalog-source { .catalog-source {
position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
@@ -1014,19 +982,54 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.catalog-source > span { .catalog-source-dot {
flex: 0 0 auto;
width: 7px; width: 7px;
height: 7px; height: 7px;
border-radius: 50%; border-radius: 50%;
background: #d49b16; background: #d49b16;
} }
.catalog-source.ready > span { .catalog-source.ready > .catalog-source-dot {
background: #198754; background: #198754;
} }
.catalog-source.fallback > span { .catalog-source.error {
background: #b45309; color: #9f1d1d;
cursor: help;
}
.catalog-source.error > .catalog-source-dot {
background: #c53030;
}
.catalog-source-tooltip {
position: absolute;
top: calc(100% + 7px);
right: 0;
z-index: 30;
width: 250px;
box-sizing: border-box;
padding: 8px 10px;
border: 1px solid #e3a4a4;
border-radius: 5px;
color: #7f1d1d;
font-size: 11px;
font-weight: 500;
line-height: 1.45;
white-space: normal;
background: #fff7f7;
box-shadow: 0 6px 16px rgba(75, 20, 20, 0.18);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 120ms ease, visibility 120ms ease;
}
.catalog-source.error:hover .catalog-source-tooltip,
.catalog-source.error:focus-visible .catalog-source-tooltip {
opacity: 1;
visibility: visible;
} }
.palette-library + .palette-library { .palette-library + .palette-library {
@@ -1039,18 +1042,24 @@ textarea {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 4px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.palette-library-heading strong { .palette-library-heading strong {
min-width: 0;
overflow: hidden;
color: #17212b; color: #17212b;
font-size: 12px; font-size: 14px;
font-weight: 700;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
} }
.palette-library-heading small { .palette-library-heading small {
color: #64748b; color: #64748b;
font-size: 10px; font-size: 8px;
white-space: nowrap; white-space: nowrap;
} }
@@ -1061,8 +1070,12 @@ textarea {
.palette-category-title { .palette-category-title {
margin-bottom: 6px; margin-bottom: 6px;
color: #526172; color: #526172;
font-size: 10px; }
font-weight: 700;
.palette-category-title > span {
font-size: 12px;
font-weight: 600;
line-height: 1.35;
} }
.palette-collapse-toggle { .palette-collapse-toggle {
@@ -1107,10 +1120,6 @@ textarea {
.palette-category-items { .palette-category-items {
display: grid; display: grid;
gap: 8px;
}
.palette[data-view-mode="icons"] .palette-category-items {
grid-template-columns: repeat(auto-fill, 76px); grid-template-columns: repeat(auto-fill, 76px);
gap: 6px; gap: 6px;
justify-content: start; justify-content: start;
@@ -1126,15 +1135,19 @@ textarea {
.palette-item { .palette-item {
display: grid; display: grid;
grid-template-columns: 36px minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
align-items: center; grid-template-rows: 48px 16px;
gap: 10px; gap: 4px;
min-height: 52px; width: 76px;
padding: 8px; height: 82px;
min-height: 82px;
box-sizing: border-box;
padding: 6px 5px;
border: 1px solid #d6dee9; border: 1px solid #d6dee9;
border-radius: 6px; border-radius: 6px;
place-items: center;
color: #22303f; color: #22303f;
text-align: left; text-align: center;
background: #ffffff; background: #ffffff;
cursor: grab; cursor: grab;
transition: border-color 120ms ease, box-shadow 120ms ease, transition: border-color 120ms ease, box-shadow 120ms ease,
@@ -1159,34 +1172,36 @@ textarea {
} }
.palette-item-copy { .palette-item-copy {
display: block;
width: 100%;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
line-height: 16px;
text-align: center;
} }
.palette-item-copy strong { .palette-item-copy strong {
display: block; display: block;
width: 100%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; font-size: 10px;
white-space: nowrap; line-height: 16px;
}
.palette-item small {
display: block;
margin-top: 2px;
overflow: hidden;
color: #64748b;
font-size: 11px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.palette-icon { .palette-icon {
display: grid; display: grid;
width: 36px; flex: 0 0 48px;
height: 36px; width: 48px;
height: 48px;
place-items: center; place-items: center;
} }
.palette-canvas-drag-templates {
display: none;
}
.component-symbol { .component-symbol {
display: grid; display: grid;
width: 100%; width: 100%;
@@ -1252,51 +1267,6 @@ textarea {
font-weight: 600; font-weight: 600;
} }
.palette[data-view-mode="icons"] .palette-list {
padding: 10px;
}
.palette[data-view-mode="icons"] .palette-item {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 48px 16px;
gap: 4px;
width: 76px;
height: 82px;
min-height: 82px;
box-sizing: border-box;
padding: 6px 5px;
place-items: center;
text-align: center;
}
.palette[data-view-mode="icons"] .palette-item-copy {
display: block;
width: 100%;
min-width: 0;
overflow: hidden;
line-height: 16px;
text-align: center;
}
.palette[data-view-mode="icons"] .palette-item-copy strong {
width: 100%;
overflow: hidden;
font-size: 10px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette[data-view-mode="icons"] .palette-item-copy small {
display: none;
}
.palette[data-view-mode="icons"] .palette-icon {
flex: 0 0 48px;
width: 48px;
height: 48px;
}
.palette-icon-preview { .palette-icon-preview {
position: fixed; position: fixed;
z-index: 10000; z-index: 10000;
@@ -1360,6 +1330,99 @@ textarea {
background: #f8fafc; background: #f8fafc;
} }
.modeling-canvas-stack {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.flow-viewport-scrollbars {
--flow-viewport-scrollbar-size: 12px;
position: absolute;
z-index: 7;
inset: 0;
pointer-events: none;
}
.flow-viewport-scrollbar {
position: absolute;
margin: 0;
overflow: scroll;
background: #e5eaf0;
outline: none;
overscroll-behavior: contain;
pointer-events: auto;
scrollbar-color: #8493a5 #e5eaf0;
scrollbar-width: thin;
}
.flow-viewport-scrollbar-horizontal {
right: var(--flow-viewport-scrollbar-size);
bottom: 0;
left: 0;
height: var(--flow-viewport-scrollbar-size);
overflow-x: scroll;
overflow-y: hidden;
}
.flow-viewport-scrollbar-vertical {
top: 0;
right: 0;
bottom: var(--flow-viewport-scrollbar-size);
width: var(--flow-viewport-scrollbar-size);
overflow-x: hidden;
overflow-y: scroll;
}
.flow-viewport-scrollbar-spacer {
display: block;
min-width: 1px;
min-height: 1px;
pointer-events: none;
}
.flow-viewport-scrollbar-corner {
position: absolute;
right: 0;
bottom: 0;
width: var(--flow-viewport-scrollbar-size);
height: var(--flow-viewport-scrollbar-size);
background: #e5eaf0;
box-shadow: inset 1px 1px #d5dce5;
pointer-events: auto;
}
.flow-viewport-scrollbar:focus-visible {
box-shadow: inset 0 0 0 2px #2f72ad;
}
.flow-viewport-scrollbar::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.flow-viewport-scrollbar::-webkit-scrollbar-track {
background: #e5eaf0;
}
.flow-viewport-scrollbar::-webkit-scrollbar-thumb {
min-width: 28px;
min-height: 28px;
border: 2px solid #e5eaf0;
border-radius: 999px;
background: #8493a5;
}
.flow-viewport-scrollbar::-webkit-scrollbar-thumb:hover {
background: #65768a;
}
.flow-canvas .react-flow__panel.bottom {
bottom: 14px;
}
.recovery-banner button { .recovery-banner button {
min-width: 48px; min-width: 48px;
height: 28px; height: 28px;
@@ -1461,6 +1524,41 @@ textarea {
pointer-events: auto; pointer-events: auto;
} }
.flow-canvas .react-flow__node.pending-paste-node,
.flow-canvas .react-flow__node.pending-paste-node .sim-node,
.flow-canvas .react-flow__node.pending-paste-node .node-symbol-envelope,
.flow-canvas .react-flow__node.pending-paste-node .port-handle {
cursor: none;
pointer-events: none !important;
}
.flow-canvas .react-flow__node.pending-paste-node {
opacity: 0.72;
filter: drop-shadow(0 0 4px rgba(29, 111, 184, 0.5));
}
.flow-canvas .react-flow__edge.pending-paste-edge {
opacity: 0.72;
pointer-events: none;
}
.flow-canvas .react-flow__edge.pending-paste-edge .react-flow__edge-path {
stroke: #1d6fb8;
stroke-dasharray: 5 4;
}
.pending-paste-hint {
padding: 7px 11px;
border: 1px solid #76a8cf;
border-radius: 5px;
color: #174f7a;
background: rgba(239, 248, 255, 0.96);
box-shadow: 0 2px 8px rgba(23, 79, 122, 0.15);
font-size: 12px;
font-weight: 650;
pointer-events: none;
}
.react-flow__node.validation-error .sim-node-frame { .react-flow__node.validation-error .sim-node-frame {
border-color: #c63b32; border-color: #c63b32;
box-shadow: 0 0 0 3px rgba(198, 59, 50, 0.14); box-shadow: 0 0 0 3px rgba(198, 59, 50, 0.14);
@@ -1575,26 +1673,57 @@ textarea {
} }
.port-handle { .port-handle {
--port-handle-visual-size: 6px;
z-index: 3; z-index: 3;
width: 9px; width: var(--port-handle-visual-size);
height: 9px; min-width: var(--port-handle-visual-size);
max-width: var(--port-handle-visual-size);
height: var(--port-handle-visual-size);
min-height: var(--port-handle-visual-size);
max-height: var(--port-handle-visual-size);
aspect-ratio: 1 / 1;
box-sizing: border-box; box-sizing: border-box;
border: 1.5px solid var(--port-handle-color, #475569); border: 0;
border-radius: 50%; border-radius: 50%;
background: #ffffff; background: transparent;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.82); box-shadow: none;
opacity: 1; opacity: 1;
transition: opacity 100ms ease, border-color 100ms ease, transition: opacity 100ms ease;
background 100ms ease, box-shadow 100ms ease; }
.port-handle::before {
position: absolute;
inset: -4.5px;
border-radius: inherit;
background: transparent;
content: "";
pointer-events: inherit;
} }
.port-handle-physical { .port-handle-physical {
--port-handle-color: #a12b70; --port-handle-color: #a12b70;
border-radius: 50%;
} }
.port-handle-signal { .port-handle-signal {
--port-handle-color: #dc2626; --port-handle-color: #dc2626;
border-radius: 2px; border-radius: 1.5px;
}
.port-handle-glyph {
display: block;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
transition: filter 100ms ease;
}
.port-handle-glyph-shape {
fill: var(--port-handle-fill, #ffffff);
stroke: var(--port-handle-color, #475569);
stroke-width: 1;
transition: fill 100ms ease, stroke 100ms ease;
} }
.port-handle.port-connection-connected { .port-handle.port-connection-connected {
@@ -1602,18 +1731,46 @@ textarea {
opacity: 0; opacity: 0;
} }
.model-contract-warning {
position: absolute;
z-index: 6;
top: -9px;
right: -9px;
display: grid;
width: 18px;
height: 18px;
border: 2px solid #ffffff;
border-radius: 50%;
color: #ffffff;
background: #c53b32;
box-shadow: 0 1px 4px rgba(127, 29, 29, 0.42);
font-size: 12px;
font-weight: 800;
line-height: 1;
pointer-events: auto;
place-items: center;
}
.sim-node.model-contract-incompatible .component-symbol-svg {
filter: drop-shadow(0 0 3px rgba(198, 59, 50, 0.72));
}
.port-handle.port-connection-origin { .port-handle.port-connection-origin {
border-color: #d97706; --port-handle-color: #d97706;
background: #fff7d6; --port-handle-fill: #fff7d6;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.24), }
0 0 8px rgba(217, 119, 6, 0.42);
.port-handle.port-connection-origin .port-handle-glyph {
filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.46));
} }
.port-handle.port-connection-compatible { .port-handle.port-connection-compatible {
border-color: #15803d; --port-handle-color: #15803d;
background: #dcfce7; --port-handle-fill: #dcfce7;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.24), }
0 0 9px rgba(21, 128, 61, 0.48);
.port-handle.port-connection-compatible .port-handle-glyph {
filter: drop-shadow(0 0 4px rgba(21, 128, 61, 0.5));
} }
.port-handle.port-connection-incompatible { .port-handle.port-connection-incompatible {
@@ -1669,38 +1826,6 @@ textarea {
filter: drop-shadow(0 0 2px rgba(29, 111, 184, 0.42)); filter: drop-shadow(0 0 2px rgba(29, 111, 184, 0.42));
} }
.contact-edge-hit {
cursor: pointer;
fill: transparent;
pointer-events: all;
stroke: transparent;
}
.contact-edge-marker {
fill: #ffffff;
opacity: 0;
pointer-events: none;
stroke: #64748b;
stroke-width: 1.5;
transition: opacity 120ms ease-out, fill 120ms ease-out, stroke 120ms ease-out;
vector-effect: non-scaling-stroke;
}
.react-flow__edge.editor-edge-contact:hover .contact-edge-marker,
.react-flow__edge.editor-edge-contact.selected .contact-edge-marker,
.react-flow__edge.editor-edge-contact.editor-edge-selected .contact-edge-marker,
.contact-edge-hit:focus-visible + .contact-edge-marker {
opacity: 1;
}
.react-flow__edge.editor-edge-contact.selected .contact-edge-marker,
.react-flow__edge.editor-edge-contact.editor-edge-selected .contact-edge-marker,
.contact-edge-hit:focus-visible + .contact-edge-marker {
fill: #dbeafe;
stroke: #1d6fb8;
stroke-width: 2;
}
.field { .field {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -2150,15 +2275,6 @@ textarea {
stroke-width: 1.5; stroke-width: 1.5;
} }
.results-system-canvas .contact-edge-hit {
cursor: default;
pointer-events: none;
}
.results-system-canvas .contact-edge-marker {
opacity: 0;
}
.results-chart-workspace { .results-chart-workspace {
position: relative; position: relative;
z-index: 0; z-index: 0;
@@ -2844,6 +2960,255 @@ textarea {
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22); box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22);
} }
/* 功能 4 的审阅版使用独立 dock 前缀;旧浮动控制台样式保留,便于快速回退。 */
.simulation-console-dock {
position: relative;
z-index: 30;
display: grid;
grid-template-rows: 38px 50px minmax(0, 1fr);
width: 100%;
height: var(--simulation-console-dock-height, 180px);
min-height: 0;
overflow: visible;
border-top: 1px solid #718297;
color: #263442;
background: #ffffff;
box-shadow: 0 -3px 12px rgba(15, 23, 42, 0.14);
}
.simulation-console-dock.minimized {
display: block;
overflow: hidden;
}
.simulation-console-dock.resizing {
cursor: row-resize;
user-select: none;
}
.simulation-console-dock-resizer {
position: absolute;
z-index: 2;
top: -4px;
right: 0;
left: 0;
height: 8px;
cursor: row-resize;
touch-action: none;
}
.simulation-console-dock-resizer::after {
position: absolute;
top: 3px;
right: 0;
left: 0;
height: 2px;
background: transparent;
content: "";
transition: background 120ms ease;
}
.simulation-console-dock-resizer:hover::after,
.simulation-console-dock-resizer:focus-visible::after,
.simulation-console-dock.resizing .simulation-console-dock-resizer::after {
background: #2f72ad;
}
.simulation-console-dock-resizer:focus-visible {
outline: 2px solid #2f72ad;
outline-offset: -2px;
}
.simulation-console-dock-header {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
padding: 0 10px 0 12px;
border-bottom: 1px solid #d4dde7;
background: #f4f7f9;
}
.simulation-console-dock.minimized .simulation-console-dock-header {
height: 40px;
border-bottom: 0;
}
.simulation-console-dock-title,
.simulation-console-dock-actions {
display: flex;
align-items: center;
}
.simulation-console-dock-title {
min-width: 0;
flex: 1 1 auto;
gap: 8px;
}
.simulation-console-dock-title strong {
flex: 0 0 auto;
font-size: 12px;
}
.simulation-console-dock-summary {
min-width: 0;
overflow: hidden;
color: #657487;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.simulation-console-dock-actions {
flex: 0 0 auto;
gap: 2px;
margin-left: 12px;
}
.simulation-console-dock-actions button {
display: grid;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
color: #526172;
background: transparent;
cursor: pointer;
place-items: center;
}
.simulation-console-dock-actions button:hover:not(:disabled) {
border-color: #bdc9d5;
color: #155a94;
background: #ffffff;
}
.simulation-console-dock-actions button:disabled {
cursor: default;
opacity: 0.38;
}
.simulation-console-dock-actions button.active {
border-color: #e2aaa5;
color: #b42318;
background: #fff1f0;
}
.simulation-console-dock-progress {
padding: 8px 12px 9px;
border-bottom: 1px solid #dbe2ea;
background: #ffffff;
}
.simulation-console-dock-progress-label {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
margin-bottom: 6px;
color: #526172;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.simulation-console-dock-progress-label > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.simulation-console-dock-progress-label > span:last-of-type,
.simulation-console-dock-progress-label strong {
color: #1767a8;
font-weight: 650;
}
.simulation-console-dock-progress-track {
height: 6px;
overflow: hidden;
border-radius: 3px;
background: #e2e8f0;
}
.simulation-console-dock-progress-track > span {
display: block;
height: 100%;
background: #2478b8;
transition: width 160ms ease-out;
}
.simulation-console-dock-progress.success .simulation-console-dock-progress-track > span {
background: #2f7d55;
}
.simulation-console-dock-progress.warning .simulation-console-dock-progress-track > span {
background: #b7791f;
}
.simulation-console-dock-progress.error .simulation-console-dock-progress-track > span {
background: #c13d35;
}
.simulation-console-dock-log {
min-height: 0;
padding: 8px 12px 14px;
overflow: auto;
background: #fbfcfd;
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-size: 11px;
line-height: 1.55;
user-select: text;
}
.simulation-console-dock-empty {
padding: 12px 4px;
color: #8a97a6;
}
.simulation-console-dock-entry {
display: grid;
grid-template-columns: 66px 44px minmax(0, 1fr);
align-items: start;
gap: 8px;
padding: 3px 4px;
border-bottom: 1px solid #edf1f5;
}
.simulation-console-dock-entry time,
.simulation-console-dock-entry-level {
color: #7a8796;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.simulation-console-dock-entry.success .simulation-console-dock-entry-level {
color: #2f7d55;
}
.simulation-console-dock-entry.warning .simulation-console-dock-entry-level {
color: #9a6417;
}
.simulation-console-dock-entry.error .simulation-console-dock-entry-level {
color: #b42318;
}
.simulation-console-dock-entry-message {
min-width: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.simulation-console-dock-entry-xml {
min-width: 0;
margin: 0;
overflow: auto;
color: #334155;
white-space: pre;
}
.simulation-console.dragging { .simulation-console.dragging {
user-select: none; user-select: none;
} }
+12 -4
View File
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { prepareApp } from "./fixtures"; import { expandSimulationConsole, prepareApp } from "./fixtures";
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await prepareApp(page); await prepareApp(page);
@@ -62,8 +62,9 @@ test("拖入介质定义后可供气动元件选择,删除定义会回退到
); );
await page.getByRole("button", { name: "生成系统 XML" }).click(); await page.getByRole("button", { name: "生成系统 XML" }).click();
await expandSimulationConsole(page);
const xmlBlock = consolePanel.getByLabel("生成的系统 XML"); const xmlBlock = consolePanel.getByLabel("生成的系统 XML");
await expect(xmlBlock).toContainText('mediumReferenceVersion="1"'); await expect(xmlBlock).toContainText('schemaVersion="3"');
await expect(xmlBlock).toContainText('<Parameter name="gi" value="0"'); await expect(xmlBlock).toContainText('<Parameter name="gi" value="0"');
}); });
@@ -82,6 +83,7 @@ test("介质定义的物性计算模型会写入 XML", async ({ page }) => {
await expect(page.getByLabel("物性计算模型")).toHaveValue("0"); await expect(page.getByLabel("物性计算模型")).toHaveValue("0");
await page.getByRole("button", { name: "生成系统 XML" }).click(); await page.getByRole("button", { name: "生成系统 XML" }).click();
await expandSimulationConsole(page);
const consolePanel = page.getByRole("complementary", { const consolePanel = page.getByRole("complementary", {
name: "仿真控制台", name: "仿真控制台",
exact: true, exact: true,
@@ -133,6 +135,7 @@ test("氦气介质自动分配 gi,并以 Peng–Robinson 模型写入 XML", as
await expect(mediumSelect).toHaveValue("2"); await expect(mediumSelect).toHaveValue("2");
await page.getByRole("button", { name: "生成系统 XML" }).click(); await page.getByRole("button", { name: "生成系统 XML" }).click();
await expandSimulationConsole(page);
const consolePanel = page.getByRole("complementary", { const consolePanel = page.getByRole("complementary", {
name: "仿真控制台", name: "仿真控制台",
exact: true, exact: true,
@@ -156,8 +159,8 @@ test("氦气介质自动分配 gi,并以 Peng–Robinson 模型写入 XML", as
test("导入工程中的未知物性计算模型会被检查出来", async ({ page }) => { test("导入工程中的未知物性计算模型会被检查出来", async ({ page }) => {
await page.goto("/"); await page.goto("/");
const project = { const project = {
projectSchemaVersion: 1,
name: "invalid-property-model", name: "invalid-property-model",
mediumReferenceVersion: 1,
nodes: [ nodes: [
{ {
id: "amesim_ideal_air_medium_1", id: "amesim_ideal_air_medium_1",
@@ -196,7 +199,12 @@ test("导入工程中的未知物性计算模型会被检查出来", async ({ pa
name: "仿真控制台", name: "仿真控制台",
exact: true, exact: true,
}); });
await expect(consolePanel).toContainText( 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(
"空气介质_1:物性计算模型不在支持的可选项中", "空气介质_1:物性计算模型不在支持的可选项中",
); );
}); });
File diff suppressed because it is too large. Load diff
+214 -6
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { import {
expectAllNodesInsideCanvas, expectAllNodesInsideCanvas,
prepareApp, prepareApp,
@@ -6,14 +6,39 @@ import {
wideProject, wideProject,
} from "./fixtures"; } from "./fixtures";
async function readModelingViewport(page: Page) {
return page
.locator(".flow-canvas .react-flow__viewport")
.evaluate((viewport) => {
const values =
getComputedStyle(viewport)
.transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)
?.map(Number) ?? [];
return {
x: values[4] ?? 0,
y: values[5] ?? 0,
zoom: values[0] ?? 1,
};
});
}
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => { test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
await prepareApp(page); await prepareApp(page);
await page.goto("/"); await page.goto("/");
const viewport = page.locator(".flow-canvas .react-flow__viewport"); const viewport = page.locator(".flow-canvas .react-flow__viewport");
const zoomOut = page.locator(".flow-canvas .react-flow__controls-zoomout"); await expect(page.locator(".flow-canvas .react-flow__controls")).toHaveCount(0);
await zoomOut.click(); await expect(page.locator(".flow-canvas .react-flow__attribution")).toHaveCount(0);
await zoomOut.click(); const pane = page.locator(".flow-canvas .react-flow__pane");
const paneBox = await pane.boundingBox();
expect(paneBox).not.toBeNull();
await page.mouse.move(
paneBox!.x + paneBox!.width / 2,
paneBox!.y + paneBox!.height / 2,
);
await page.keyboard.down("Control");
await page.mouse.wheel(0, 240);
await page.keyboard.up("Control");
await page.waitForTimeout(250); await page.waitForTimeout(250);
const viewportBeforeDrop = await viewport.getAttribute("style"); const viewportBeforeDrop = await viewport.getAttribute("style");
expect(viewportBeforeDrop).toBeTruthy(); expect(viewportBeforeDrop).toBeTruthy();
@@ -31,6 +56,133 @@ test("拖入组件不自动适配画布,手动适配按钮仍然生效", async
.not.toBe(viewportBeforeDrop); .not.toBe(viewportBeforeDrop);
}); });
test("普通滚轮纵向平移,Ctrl 滚轮缩放且滚动条和中键拖动保持同步", async ({
page,
}) => {
await prepareApp(page);
await page.goto("/");
const canvas = page.locator(".flow-canvas");
const pane = canvas.locator(".react-flow__pane");
const horizontalScrollbar = page.getByRole("scrollbar", {
name: "水平滚动画布",
});
const verticalScrollbar = page.getByRole("scrollbar", {
name: "垂直滚动画布",
});
await expect(horizontalScrollbar).toBeVisible();
await expect(verticalScrollbar).toBeVisible();
const horizontalBox = await horizontalScrollbar.boundingBox();
const verticalBox = await verticalScrollbar.boundingBox();
expect(horizontalBox).not.toBeNull();
expect(verticalBox).not.toBeNull();
expect(horizontalBox!.height).toBeGreaterThanOrEqual(8);
expect(horizontalBox!.height).toBeLessThanOrEqual(12);
expect(verticalBox!.width).toBeGreaterThanOrEqual(8);
expect(verticalBox!.width).toBeLessThanOrEqual(12);
const paneBox = await pane.boundingBox();
expect(paneBox).not.toBeNull();
const pointer = {
x: paneBox!.x + paneBox!.width * 0.55,
y: paneBox!.y + paneBox!.height * 0.45,
};
await page.mouse.move(pointer.x, pointer.y);
const beforeWheel = await readModelingViewport(page);
const beforeVerticalScroll = await verticalScrollbar.evaluate(
(element) => element.scrollTop,
);
await page.mouse.wheel(0, 180);
await expect
.poll(async () => (await readModelingViewport(page)).y)
.not.toBeCloseTo(beforeWheel.y, 1);
const afterWheel = await readModelingViewport(page);
expect(afterWheel.x).toBeCloseTo(beforeWheel.x, 1);
expect(afterWheel.zoom).toBeCloseTo(beforeWheel.zoom, 3);
await expect
.poll(() => verticalScrollbar.evaluate((element) => element.scrollTop))
.not.toBeCloseTo(beforeVerticalScroll, 1);
const beforeZoom = await readModelingViewport(page);
const horizontalBeforeZoom = await horizontalScrollbar.evaluate(
(element) => element.scrollLeft,
);
const verticalBeforeZoom = await verticalScrollbar.evaluate(
(element) => element.scrollTop,
);
await page.keyboard.down("Control");
await page.mouse.wheel(0, -180);
await page.keyboard.up("Control");
await expect
.poll(async () => (await readModelingViewport(page)).zoom)
.not.toBeCloseTo(beforeZoom.zoom, 2);
const horizontalAfterZoom = await horizontalScrollbar.evaluate(
(element) => element.scrollLeft,
);
const verticalAfterZoom = await verticalScrollbar.evaluate(
(element) => element.scrollTop,
);
expect(
Math.hypot(
horizontalAfterZoom - horizontalBeforeZoom,
verticalAfterZoom - verticalBeforeZoom,
),
).toBeGreaterThan(1);
const beforeMiddleDrag = await readModelingViewport(page);
await page.mouse.move(pointer.x, pointer.y);
await page.mouse.down({ button: "middle" });
await page.mouse.move(pointer.x + 70, pointer.y + 45, { steps: 4 });
await page.mouse.up({ button: "middle" });
await expect
.poll(async () => {
const next = await readModelingViewport(page);
return Math.hypot(next.x - beforeMiddleDrag.x, next.y - beforeMiddleDrag.y);
})
.toBeGreaterThan(20);
const afterMiddleDrag = await readModelingViewport(page);
expect(afterMiddleDrag.zoom).toBeCloseTo(beforeMiddleDrag.zoom, 3);
await expect(canvas.locator(".react-flow__selection")).toHaveCount(0);
await verticalScrollbar.focus();
const beforeKeyboardScroll = await verticalScrollbar.evaluate(
(element) => element.scrollTop,
);
await page.keyboard.press("ArrowDown");
await expect
.poll(() => verticalScrollbar.evaluate((element) => element.scrollTop))
.toBeGreaterThan(beforeKeyboardScroll);
const beforeScrollbarDrag = await readModelingViewport(page);
await horizontalScrollbar.evaluate((element) => {
element.scrollLeft += 120;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect
.poll(async () => (await readModelingViewport(page)).x)
.not.toBeCloseTo(beforeScrollbarDrag.x, 1);
const afterScrollbarDrag = await readModelingViewport(page);
expect(afterScrollbarDrag.y).toBeCloseTo(beforeScrollbarDrag.y, 1);
expect(afterScrollbarDrag.zoom).toBeCloseTo(beforeScrollbarDrag.zoom, 3);
const horizontalMaximum = await horizontalScrollbar.evaluate(
(element) => element.scrollWidth - element.clientWidth,
);
const verticalMaximum = await verticalScrollbar.evaluate(
(element) => element.scrollHeight - element.clientHeight,
);
await expect(horizontalScrollbar).toHaveAttribute(
"aria-valuemax",
String(Math.round(horizontalMaximum)),
);
await expect(verticalScrollbar).toHaveAttribute(
"aria-valuemax",
String(Math.round(verticalMaximum)),
);
});
test("加载本地工程后自动适配建模画布", async ({ page }) => { test("加载本地工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page); await prepareApp(page);
await page.addInitScript((project) => { await page.addInitScript((project) => {
@@ -48,7 +200,10 @@ test("加载本地工程后自动适配建模画布", async ({ page }) => {
name: "仿真控制台", name: "仿真控制台",
exact: true, exact: true,
}); });
await expect(consolePanel).not.toContainText("工程已加载"); await expect(consolePanel.locator(".simulation-console-dock-summary")).toContainText(
"工程已加载",
);
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
await page.getByRole("button", { name: "展开仿真控制台" }).click(); await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toContainText("工程已加载"); await expect(consolePanel).toContainText("工程已加载");
}); });
@@ -83,7 +238,10 @@ test("恢复自动保存工程后自动适配建模画布", async ({ page }) =>
name: "仿真控制台", name: "仿真控制台",
exact: true, exact: true,
}); });
await expect(consolePanel).not.toContainText("已恢复上次自动保存的工程"); await expect(consolePanel.locator(".simulation-console-dock-summary")).toContainText(
"已恢复上次自动保存的工程",
);
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
await page.getByRole("button", { name: "展开仿真控制台" }).click(); await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toContainText("已恢复上次自动保存的工程"); await expect(consolePanel).toContainText("已恢复上次自动保存的工程");
}); });
@@ -113,6 +271,9 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
); );
await page.getByRole("tab", { name: /^结果/ }).click(); await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas"); await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await expect(
page.locator(".results-system-canvas .react-flow__attribution"),
).toHaveCount(0);
await expect( await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }), page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toHaveCount(0); ).toHaveCount(0);
@@ -145,4 +306,51 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
); );
await expect(idleResultHandles).toHaveCount(2); await expect(idleResultHandles).toHaveCount(2);
await expect(connectedResultHandles.first()).toHaveCSS("opacity", "0"); await expect(connectedResultHandles.first()).toHaveCSS("opacity", "0");
await expect(
idleResultHandles.locator('[data-port-glyph-shape="circle"]'),
).toHaveCount(2);
const resultViewportZoom = await page
.locator(".results-system-canvas .react-flow__viewport")
.evaluate(
(element) =>
new DOMMatrixReadOnly(getComputedStyle(element).transform).a,
);
const idleResultPortShapes = await idleResultHandles.evaluateAll((elements) =>
elements.map((element) => {
const bounds = element.getBoundingClientRect();
const glyphBounds = element
.querySelector<SVGSVGElement>(".port-handle-glyph")
?.getBoundingClientRect();
const circle = element.querySelector<SVGCircleElement>(
'[data-port-glyph-shape="circle"]',
);
return {
circle: circle
? {
cx: circle.cx.baseVal.value,
cy: circle.cy.baseVal.value,
r: circle.r.baseVal.value,
}
: null,
glyphHeight: glyphBounds?.height ?? null,
glyphWidth: glyphBounds?.width ?? null,
height: bounds.height,
width: bounds.width,
};
}),
);
for (const shape of idleResultPortShapes) {
expect(Math.abs(shape.width - 6)).toBeLessThan(0.05);
expect(Math.abs(shape.height - 6)).toBeLessThan(0.05);
expect(shape.glyphWidth).not.toBeNull();
expect(shape.glyphHeight).not.toBeNull();
expect(
Math.abs(shape.glyphWidth! - 6 * resultViewportZoom),
).toBeLessThan(0.1);
expect(
Math.abs(shape.glyphHeight! - 6 * resultViewportZoom),
).toBeLessThan(0.1);
expect(Math.abs(shape.glyphWidth! - shape.glyphHeight!)).toBeLessThan(0.01);
expect(shape.circle).toEqual({ cx: 3, cy: 3, r: 2.5 });
}
}); });
+23 -1
View File
@@ -1,5 +1,15 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
export async function expandSimulationConsole(page: Page) {
const expandButton = page.getByRole("button", {
name: "展开仿真控制台",
exact: true,
});
if ((await expandButton.count()) > 0) {
await expandButton.click();
}
}
const physicalPort = ( const physicalPort = (
name: string, name: string,
side: "left" | "right", side: "left" | "right",
@@ -73,6 +83,15 @@ const ud00StageParameterNames = Array.from(
export const componentCatalog = { export const componentCatalog = {
schemaVersion: 1, schemaVersion: 1,
libraries: [ libraries: [
{
id: "experimental",
label: "临时测试组件库",
version: "0.1.0",
sourcePackage: "tests.e2e.experimental",
temporary: true,
order: 100,
components: [],
},
{ {
id: "e2e-library", id: "e2e-library",
label: "自动化测试组件库", label: "自动化测试组件库",
@@ -323,7 +342,7 @@ export const componentCatalog = {
type: "amesim_mecmas21", type: "amesim_mecmas21",
label: "MECMAS21 编码测试元件", label: "MECMAS21 编码测试元件",
modelType: "amesim_mecmas21", modelType: "amesim_mecmas21",
modelVersion: "1.0.0", modelVersion: "0.2.0",
symbol: "symbol-not-registered", symbol: "symbol-not-registered",
order: 6, order: 6,
category: { category: {
@@ -868,6 +887,7 @@ const projectPort = (
}); });
export const wideProject = { export const wideProject = {
projectSchemaVersion: 1,
name: "demo-system", name: "demo-system",
nodes: Array.from({ length: 4 }, (_, index) => ({ nodes: Array.from({ length: 4 }, (_, index) => ({
id: `generic_sensor_${index + 1}`, id: `generic_sensor_${index + 1}`,
@@ -880,6 +900,7 @@ export const wideProject = {
label: `generic_sensor_${index + 1}`, label: `generic_sensor_${index + 1}`,
componentType: "generic_sensor", componentType: "generic_sensor",
modelType: "generic_sensor", modelType: "generic_sensor",
modelVersion: "1.0.0",
symbol: "symbol-not-registered", symbol: "symbol-not-registered",
ports: [ ports: [
projectPort("port_a", "left", "bidirectional"), projectPort("port_a", "left", "bidirectional"),
@@ -897,6 +918,7 @@ export const wideProject = {
target: `generic_sensor_${index + 2}`, target: `generic_sensor_${index + 2}`,
sourceHandle: "port_b", sourceHandle: "port_b",
targetHandle: "port_a", targetHandle: "port_a",
data: { isContactEdge: false },
})), })),
simulation: { simulation: {
t_start: 0, t_start: 0,
+397 -6
View File
@@ -1,11 +1,11 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { prepareApp } from "./fixtures"; import { prepareApp, wideProject } from "./fixtures";
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await prepareApp(page); await prepareApp(page);
}); });
test("未知图形元件使用带端口和名称的默认节点,并将动作结果写入控制台", async ({ test("未知图形元件使用带端口和名称的默认节点且拖入动作不污染仿真控制台", async ({
page, page,
}) => { }) => {
await page.goto("/"); await page.goto("/");
@@ -31,26 +31,113 @@ test("未知图形元件使用带端口和名称的默认节点,并将动作
name: "仿真控制台", name: "仿真控制台",
exact: true, exact: true,
}); });
await expect(consolePanel).toHaveClass(/minimized/);
await expect(consolePanel).toHaveCSS("height", "40px");
await expect(consolePanel).not.toHaveClass(/has-unread/); await expect(consolePanel).not.toHaveClass(/has-unread/);
await expect(consolePanel.locator(".simulation-console-unread")).toHaveCount(0); await expect(consolePanel.locator(".simulation-console-unread")).toHaveCount(0);
await expect(consolePanel).not.toContainText("已添加组件"); await expect(consolePanel.locator(".simulation-console-dock-summary")).toHaveText(
"等待仿真任务",
);
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
await page.getByRole("button", { name: "生成系统 XML" }).click(); await page.getByRole("button", { name: "生成系统 XML" }).click();
await expect(consolePanel).toContainText("XML 已生成"); await expect(consolePanel).toHaveClass(/minimized/);
await expect(consolePanel).toHaveCSS("height", "40px");
await expect(consolePanel.locator(".simulation-console-dock-summary")).toHaveText(
"系统 XML 已生成",
);
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
await page.getByRole("button", { name: "展开仿真控制台" }).click();
const xmlBlock = consolePanel.getByLabel("生成的系统 XML"); const xmlBlock = consolePanel.getByLabel("生成的系统 XML");
await expect(xmlBlock).toBeVisible(); await expect(xmlBlock).toBeVisible();
await expect(xmlBlock).toContainText('<?xml version="1.0" encoding="UTF-8"?>'); await expect(xmlBlock).toContainText('<?xml version="1.0" encoding="UTF-8"?>');
await expect(xmlBlock).toContainText('<Component id="generic_sensor_1"'); await expect(xmlBlock).toContainText('schemaVersion="3"');
await expect(xmlBlock).toContainText('unitSystem="SI"');
await expect(xmlBlock).toContainText('sampleStep="0.1"');
await expect(xmlBlock).toContainText(
'<Component id="generic_sensor_1" type="generic_sensor" modelVersion="1.0.0"',
);
await expect(xmlBlock).toContainText('<Parameter name="gain" value="1"');
await expect(xmlBlock).not.toContainText("<Port");
await expect(xmlBlock).not.toContainText("componentType=");
await expect(xmlBlock).not.toContainText("rotation=");
await expect(xmlBlock).not.toContainText("mirrored=");
await expect(page.locator(".properties .xml-output")).toHaveCount(0); await expect(page.locator(".properties .xml-output")).toHaveCount(0);
await expect(page.locator(".properties")).not.toContainText("系统 XML"); await expect(page.locator(".properties")).not.toContainText("系统 XML");
await page.getByRole("button", { name: "收起仿真控制台" }).click();
await page.getByRole("button", { name: "检查模型" }).click(); await page.getByRole("button", { name: "检查模型" }).click();
await expect(consolePanel).toContainText(/模型检查/); await expect(consolePanel).toHaveClass(/minimized/);
await expect(consolePanel).toHaveCSS("height", "40px");
await expect(consolePanel.locator(".simulation-console-dock-summary")).toContainText(
"模型检查",
);
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
await expect(page.locator(".status-strip")).toHaveCount(0); await expect(page.locator(".status-strip")).toHaveCount(0);
await expect(page.locator(".validation-section")).toHaveCount(0); await expect(page.locator(".validation-section")).toHaveCount(0);
await expect(page.locator(".properties")).not.toContainText("仿真结果"); await expect(page.locator(".properties")).not.toContainText("仿真结果");
}); });
test("XML 导出拒绝目录未定义的组件参数", async ({ page }) => {
await page.goto("/");
await page
.getByRole("button", { name: /通用测试元件/ })
.dragTo(page.locator(".flow-canvas .react-flow__pane"));
await page.getByRole("button", { name: "保存工程", exact: true }).click();
await page.evaluate(() => {
const key = "system-simulation-flow:project:demo-system";
const raw = window.localStorage.getItem(key);
if (!raw) {
throw new Error("测试工程未保存");
}
const project = JSON.parse(raw) as {
nodes: Array<{ data: { parameters: Record<string, number> } }>;
};
project.nodes[0].data.parameters.typoGain = 2;
window.localStorage.setItem(key, JSON.stringify(project));
});
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).toContainText("XML 生成失败");
await expect(consolePanel).toContainText("目录未定义的参数:typoGain");
await expect(consolePanel.getByLabel("生成的系统 XML")).toHaveCount(0);
});
test("XML 参数使用可往返的双精度文本", async ({ page }) => {
await page.goto("/");
await page
.getByRole("button", { name: /通用测试元件/ })
.dragTo(page.locator(".flow-canvas .react-flow__pane"));
await page.getByRole("button", { name: "保存工程", exact: true }).click();
await page.evaluate(() => {
const key = "system-simulation-flow:project:demo-system";
const raw = window.localStorage.getItem(key);
if (!raw) {
throw new Error("测试工程未保存");
}
const project = JSON.parse(raw) as {
nodes: Array<{ data: { parameters: Record<string, number> } }>;
};
project.nodes[0].data.parameters.gain = 1.2345678901234567;
window.localStorage.setItem(key, JSON.stringify(project));
});
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expect(page.getByLabel("生成的系统 XML")).toContainText(
'<Parameter name="gain" value="1.2345678901234567"',
);
});
test("拖入组件后仅选择新组件,变换操作不会继续作用于旧选择", async ({ test("拖入组件后仅选择新组件,变换操作不会继续作用于旧选择", async ({
page, page,
}) => { }) => {
@@ -91,6 +178,310 @@ test("拖入组件后仅选择新组件,变换操作不会继续作用于旧
); );
}); });
test("复制后粘贴进入待放置状态,跟随鼠标并在左键确认时写入工程", async ({
page,
}) => {
await page.goto("/");
const pane = page.locator(".flow-canvas .react-flow__pane");
await page
.getByRole("button", { name: /通用测试元件/ })
.dragTo(pane, { targetPosition: { x: 250, y: 220 } });
const original = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"]',
);
await original.click();
await page.keyboard.press("Control+c");
const paneBounds = await pane.boundingBox();
expect(paneBounds).not.toBeNull();
const firstPointer = {
x: paneBounds!.x + paneBounds!.width * 0.62,
y: paneBounds!.y + paneBounds!.height * 0.54,
};
await page.mouse.move(firstPointer.x, firstPointer.y);
await page.keyboard.press("Control+v");
const preview = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_2"]',
);
await expect(preview).toHaveClass(/pending-paste-node/);
await expect(page.getByText(/待放置:左键放置/)).toBeVisible();
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(2);
await expect(page.getByLabel("组件名称")).toHaveCount(0);
await expect(page.getByRole("button", { name: "撤销 (Ctrl+Z)" })).toBeDisabled();
await expect(page.getByRole("button", { name: "复制 (Ctrl+C)" })).toBeDisabled();
await expect(page.getByRole("button", { name: "粘贴 (Ctrl+V)" })).toBeDisabled();
await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeDisabled();
const previewBeforeMove = await preview.boundingBox();
expect(previewBeforeMove).not.toBeNull();
await page.mouse.move(firstPointer.x + 90, firstPointer.y + 55, { steps: 4 });
await expect
.poll(async () => (await preview.boundingBox())?.x ?? 0)
.toBeGreaterThan(previewBeforeMove!.x + 40);
const viewport = page.locator(".flow-canvas .react-flow__viewport");
const viewportBeforeZoom = await viewport.getAttribute("style");
await page.keyboard.down("Control");
await page.mouse.wheel(0, -160);
await page.keyboard.up("Control");
await expect(viewport).not.toHaveAttribute("style", viewportBeforeZoom ?? "");
await expect(preview).toHaveClass(/pending-paste-node/);
const previewBeforeScroll = await preview.boundingBox();
expect(previewBeforeScroll).not.toBeNull();
const horizontalScrollbar = page.getByRole("scrollbar", {
name: "水平滚动画布",
});
const viewportBeforeScroll = await viewport.getAttribute("style");
await horizontalScrollbar.focus();
await page.keyboard.press("ArrowRight");
await expect(viewport).not.toHaveAttribute("style", viewportBeforeScroll ?? "");
await expect(preview).toHaveClass(/pending-paste-node/);
const previewAfterScroll = await preview.boundingBox();
expect(previewAfterScroll).not.toBeNull();
expect(
Math.abs(
previewAfterScroll!.x +
previewAfterScroll!.width / 2 -
(previewBeforeScroll!.x + previewBeforeScroll!.width / 2),
),
).toBeLessThanOrEqual(10);
expect(
Math.abs(
previewAfterScroll!.y +
previewAfterScroll!.height / 2 -
(previewBeforeScroll!.y + previewBeforeScroll!.height / 2),
),
).toBeLessThanOrEqual(10);
const verticalBefore = await preview.locator(".sim-node").evaluate((element) =>
element.classList.contains("orientation-vertical"),
);
const previewBounds = await preview.boundingBox();
expect(previewBounds).not.toBeNull();
await page.mouse.click(
previewBounds!.x + previewBounds!.width / 2,
previewBounds!.y + previewBounds!.height / 2,
{ button: "middle" },
);
await expect
.poll(() =>
preview.locator(".sim-node").evaluate((element) =>
element.classList.contains("orientation-vertical"),
),
)
.not.toBe(verticalBefore);
const finalBounds = await preview.boundingBox();
expect(finalBounds).not.toBeNull();
await page.mouse.click(
finalBounds!.x + finalBounds!.width / 2,
finalBounds!.y + finalBounds!.height / 2,
);
await expect(preview).not.toHaveClass(/pending-paste-node/);
await expect(preview).toHaveClass(/selected/);
await expect(page.getByLabel("组件名称")).toHaveValue("generic_sensor_1_副本");
});
test("复制已连接组件时副本 ID 避让画布中的同名节点且不会移动原元件", async ({
page,
}) => {
const project = structuredClone(wideProject);
project.nodes = project.nodes.slice(0, 2);
project.edges = project.edges.slice(0, 1);
project.nodes[1] = {
...project.nodes[1],
data: {
...project.nodes[1].data,
label: "amesim_pneumatic_test_1",
componentType: "amesim_pneumatic_test",
modelType: "amesim_pneumatic_test",
},
};
await page.addInitScript((storedProject) => {
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(storedProject),
);
}, project);
await page.goto("/");
await page.getByRole("button", { name: "加载工程" }).click();
const firstNode = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"]',
);
const occupiedIdNode = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_2"]',
);
await expect(firstNode).toBeVisible();
await expect(occupiedIdNode).toBeVisible();
const firstBoundsBefore = await firstNode.boundingBox();
const occupiedBoundsBefore = await occupiedIdNode.boundingBox();
expect(firstBoundsBefore).not.toBeNull();
expect(occupiedBoundsBefore).not.toBeNull();
await firstNode.click();
await page.keyboard.press("Control+c");
const pane = page.locator(".flow-canvas .react-flow__pane");
const paneBounds = await pane.boundingBox();
expect(paneBounds).not.toBeNull();
await page.mouse.move(
paneBounds!.x + paneBounds!.width * 0.62,
paneBounds!.y + paneBounds!.height * 0.68,
);
await page.keyboard.press("Control+v");
const preview = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_3"]',
);
await expect(preview).toHaveClass(/pending-paste-node/);
await expect(preview.locator(".node-label")).toHaveText(
"generic_sensor_1_副本",
);
await expect(occupiedIdNode).not.toHaveClass(/pending-paste-node/);
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(3);
const previewBounds = await preview.boundingBox();
expect(previewBounds).not.toBeNull();
await page.mouse.click(
previewBounds!.x + previewBounds!.width / 2,
previewBounds!.y + previewBounds!.height / 2,
);
await expect(preview).not.toHaveClass(/pending-paste-node/);
await expect(preview).toHaveClass(/selected/);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
const firstBoundsAfter = await firstNode.boundingBox();
const occupiedBoundsAfter = await occupiedIdNode.boundingBox();
expect(firstBoundsAfter).toEqual(firstBoundsBefore);
expect(occupiedBoundsAfter).toEqual(occupiedBoundsBefore);
const nodeIds = await page
.locator(".flow-canvas .react-flow__node")
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-id")));
expect(new Set(nodeIds).size).toBe(nodeIds.length);
});
test("控制台文本选择使用浏览器原生复制,不触发元件复制", async ({ page }) => {
await page.goto("/");
await page
.getByRole("button", { name: /通用测试元件/ })
.dragTo(page.locator(".flow-canvas .react-flow__pane"));
const node = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"]',
);
await node.click();
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await page.getByRole("button", { name: "生成系统 XML" }).click();
const xml = page.getByLabel("生成的系统 XML");
await expect(xml).toBeVisible();
await xml.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
});
const copiedModelMessagesBefore = await page
.locator(".simulation-console-dock-entry-message", { hasText: "已复制" })
.count();
await page.keyboard.press("Control+c");
await expect(page.locator(".flow-canvas .pending-paste-node")).toHaveCount(0);
await expect
.poll(() =>
page
.locator(".simulation-console-dock-entry-message", { hasText: "已复制" })
.count(),
)
.toBe(copiedModelMessagesBefore);
});
test("底部仿真控制台可以调整高度并折叠显示摘要", async ({ page }) => {
await page.goto("/");
const workbench = page.locator(".workbench");
const palette = page.locator(".palette");
const canvas = page.locator(".flow-canvas");
const properties = page.locator(".properties");
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
const expectClose = (actual: number, expected: number, tolerance = 2) => {
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance);
};
await expect(consolePanel).toHaveClass(/minimized/);
await expect(consolePanel).toHaveCSS("height", "40px");
const initialWorkbench = await workbench.boundingBox();
const initialPalette = await palette.boundingBox();
const initialCanvas = await canvas.boundingBox();
const initialProperties = await properties.boundingBox();
const initialConsole = await consolePanel.boundingBox();
expect(initialWorkbench).not.toBeNull();
expect(initialPalette).not.toBeNull();
expect(initialCanvas).not.toBeNull();
expect(initialProperties).not.toBeNull();
expect(initialConsole).not.toBeNull();
expectClose(initialConsole!.x, initialCanvas!.x);
expectClose(initialConsole!.width, initialCanvas!.width);
expectClose(initialConsole!.y, initialCanvas!.y + initialCanvas!.height);
expectClose(
initialConsole!.y + initialConsole!.height,
initialWorkbench!.y + initialWorkbench!.height,
);
expectClose(initialPalette!.y, initialWorkbench!.y);
expectClose(initialPalette!.height, initialWorkbench!.height);
expectClose(initialProperties!.y, initialWorkbench!.y);
expectClose(initialProperties!.height, initialWorkbench!.height);
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toHaveClass(/normal/);
const expandedCanvas = await canvas.boundingBox();
const expandedConsole = await consolePanel.boundingBox();
const expandedPalette = await palette.boundingBox();
const expandedProperties = await properties.boundingBox();
expect(expandedCanvas).not.toBeNull();
expect(expandedConsole).not.toBeNull();
expect(expandedPalette).not.toBeNull();
expect(expandedProperties).not.toBeNull();
expect(expandedCanvas!.height).toBeLessThan(initialCanvas!.height - 100);
expectClose(expandedConsole!.x, expandedCanvas!.x);
expectClose(expandedConsole!.width, expandedCanvas!.width);
expectClose(expandedPalette!.y, initialPalette!.y);
expectClose(expandedPalette!.height, initialPalette!.height);
expectClose(expandedProperties!.y, initialProperties!.y);
expectClose(expandedProperties!.height, initialProperties!.height);
const resizer = page.getByRole("separator", {
name: "调整仿真控制台高度",
});
const resizerBox = await resizer.boundingBox();
expect(resizerBox).not.toBeNull();
await page.mouse.move(resizerBox!.x + resizerBox!.width / 2, resizerBox!.y + 3);
await page.mouse.down();
await page.mouse.move(resizerBox!.x + resizerBox!.width / 2, resizerBox!.y - 55, {
steps: 5,
});
await page.mouse.up();
await expect
.poll(async () => (await consolePanel.boundingBox())?.height ?? 0)
.toBeGreaterThan(expandedConsole!.height + 35);
const resizedConsole = await consolePanel.boundingBox();
const resizedCanvas = await canvas.boundingBox();
expect(resizedConsole).not.toBeNull();
expect(resizedCanvas).not.toBeNull();
expectClose(
resizedConsole!.height - expandedConsole!.height,
expandedCanvas!.height - resizedCanvas!.height,
3,
);
await page.getByRole("button", { name: "收起仿真控制台" }).click();
await expect(consolePanel).toHaveCSS("height", "40px");
await expect(consolePanel.locator(".simulation-console-dock-summary")).not.toBeEmpty();
const collapsedCanvas = await canvas.boundingBox();
expect(collapsedCanvas).not.toBeNull();
expectClose(collapsedCanvas!.height, initialCanvas!.height);
});
test("框选结束后隐藏整体包围框并保留每个组件的虚线选中框", async ({ test("框选结束后隐藏整体包围框并保留每个组件的虚线选中框", async ({
page, page,
}) => { }) => {
+127 -38
View File
@@ -28,18 +28,116 @@ async function expectFixedIconCardSize(
expect(iconBox!.height).toBeLessThanOrEqual(49); expect(iconBox!.height).toBeLessThanOrEqual(49);
} }
test("组件库可切换详细与仅图标显示,并在刷新后保留选择", async ({ page }) => { test("组件库标题、库分组和类别分组保持清晰的三级字号", async ({ page }) => {
await page.goto("/");
const paletteTitle = page.locator(".palette-panel-title > span");
const libraryTitle = page.locator(".palette-library-heading strong").first();
const categoryTitle = page.locator(".palette-category-title > span").first();
await expect(paletteTitle).toHaveCSS("font-size", "16px");
await expect(paletteTitle).toHaveCSS("font-weight", "700");
await expect(libraryTitle).toHaveCSS("font-size", "14px");
await expect(libraryTitle).toHaveCSS("font-weight", "700");
await expect(categoryTitle).toHaveCSS("font-size", "12px");
await expect(categoryTitle).toHaveCSS("font-weight", "600");
const [paletteSize, librarySize, categorySize] = await Promise.all(
[paletteTitle, libraryTitle, categoryTitle].map((locator) =>
locator.evaluate((element) => parseFloat(getComputedStyle(element).fontSize)),
),
);
expect(paletteSize).toBeGreaterThan(librarySize);
expect(librarySize).toBeGreaterThan(categorySize);
});
test("组件目录成功时显示绿色状态且隐藏临时测试组件库", async ({ page }) => {
await page.goto("/");
const status = page.locator(".catalog-source");
await expect(status).toHaveClass(/ready/);
await expect(status).toHaveText("已加载 1 个组件库");
await expect(status).toHaveAttribute("title", "已加载 1 个组件库");
await expect(status.locator(".catalog-source-dot")).toHaveCSS(
"background-color",
"rgb(25, 135, 84)",
);
await expect(page.getByText("临时测试组件库", { exact: true })).toHaveCount(0);
await expect(
page.getByRole("button", { name: "自动化测试组件库", exact: true }),
).toBeVisible();
});
test("组件目录失败时不显示兜底库并在状态悬停时说明失败范围", async ({
page,
}) => {
await page.unroute("**/api/components/catalog");
await page.route("**/api/components/catalog", async (route) => {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "catalog unavailable" }),
});
});
await page.goto("/");
const status = page.locator(".catalog-source");
await expect(status).toHaveClass(/error/);
await expect(status).toContainText("加载失败");
await expect(status.locator(".catalog-source-dot")).toHaveCSS(
"background-color",
"rgb(197, 48, 48)",
);
await expect(page.locator("aside.palette .palette-library")).toHaveCount(0);
await expect(page.locator("aside.palette .palette-item")).toHaveCount(0);
await expect(page.getByText("临时测试组件库", { exact: true })).toHaveCount(0);
await status.hover();
const detail = page.getByRole("tooltip");
await expect(detail).toBeVisible();
await expect(detail).toContainText("AMESim 组件库");
await expect(detail).toContainText("503");
});
test("组件目录格式错误时在悬停详情中指出失败的组件库", async ({ page }) => {
await page.unroute("**/api/components/catalog");
await page.route("**/api/components/catalog", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
schemaVersion: 1,
libraries: [
{
id: "broken-library",
label: "损坏组件库",
components: [],
},
],
}),
});
});
await page.goto("/");
const status = page.locator(".catalog-source");
await expect(status).toHaveClass(/error/);
await status.hover();
await expect(page.getByRole("tooltip")).toContainText(
"加载失败的组件库:损坏组件库",
);
await expect(page.locator("aside.palette .palette-library")).toHaveCount(0);
});
test("组件库始终使用图标布局并忽略旧的列表模式设置", async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem(
"system-simulation-flow:palette-display-mode",
"detailed",
);
});
await page.goto("/"); await page.goto("/");
const palette = page.locator("aside.palette"); const palette = page.locator("aside.palette");
const detailedButton = page.getByRole("button", {
name: "详细显示",
exact: true,
});
const iconsButton = page.getByRole("button", {
name: "仅图标显示",
exact: true,
});
const airItem = palette.getByRole("button", { const airItem = palette.getByRole("button", {
name: "空气介质定义", name: "空气介质定义",
exact: true, exact: true,
@@ -49,9 +147,13 @@ test("组件库可切换详细与仅图标显示,并在刷新后保留选择",
exact: true, exact: true,
}); });
await expect(palette).toHaveAttribute("data-view-mode", "detailed"); await expect(palette).not.toHaveAttribute("data-view-mode");
await expect(detailedButton).toHaveAttribute("aria-pressed", "true"); await expect(
await expect(iconsButton).toHaveAttribute("aria-pressed", "false"); page.getByRole("group", { name: "组件库显示方式" }),
).toHaveCount(0);
await expect(page.getByRole("button", { name: "详细显示" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "仅图标显示" })).toHaveCount(0);
await expect(page.getByText("临时测试组件库", { exact: true })).toHaveCount(0);
await expect(airItem.locator(".palette-item-copy")).toBeVisible(); await expect(airItem.locator(".palette-item-copy")).toBeVisible();
const airSymbol = airItem.locator( const airSymbol = airItem.locator(
@@ -76,22 +178,19 @@ test("组件库可切换详细与仅图标显示,并在刷新后保留选择",
.locator('[data-component-symbol="generic"]'); .locator('[data-component-symbol="generic"]');
await expect(unknownSymbol.locator("svg")).toBeVisible(); await expect(unknownSymbol.locator("svg")).toBeVisible();
await iconsButton.click();
await expect(palette).toHaveAttribute("data-view-mode", "icons");
await expect(detailedButton).toHaveAttribute("aria-pressed", "false");
await expect(iconsButton).toHaveAttribute("aria-pressed", "true");
await expect(airItem).toBeVisible(); await expect(airItem).toBeVisible();
await expect(airItem.locator(".palette-item-name")).toBeVisible(); await expect(airItem.locator(".palette-item-name")).toBeVisible();
await expect(airItem.locator(".palette-item-name")).toHaveText("空气介质定义"); await expect(airItem.locator(".palette-item-name")).toHaveText("空气介质定义");
await expect(airItem.locator(".palette-item-meta")).toBeHidden(); await expect(airItem.locator(".palette-item-meta")).toHaveCount(0);
await expect(airItem).not.toHaveAttribute("title"); await expect(airItem).not.toHaveAttribute("title");
await expectFixedIconCardSize(airItem);
await page.reload(); await page.reload();
await expect(palette).toHaveAttribute("data-view-mode", "icons"); await expect(palette).not.toHaveAttribute("data-view-mode");
await expect(iconsButton).toHaveAttribute("aria-pressed", "true"); await expect(page.getByRole("button", { name: "详细显示" })).toHaveCount(0);
await expect(airItem.locator(".palette-item-name")).toBeVisible(); await expect(airItem.locator(".palette-item-name")).toBeVisible();
await expect(airItem.locator(".palette-item-meta")).toBeHidden(); await expect(airItem.locator(".palette-item-meta")).toHaveCount(0);
}); });
test("组件库和分类支持独立折叠并同步可访问状态", async ({ page }) => { test("组件库和分类支持独立折叠并同步可访问状态", async ({ page }) => {
@@ -146,11 +245,8 @@ test("组件库和分类支持独立折叠并同步可访问状态", async ({ pa
).toHaveAttribute("aria-expanded", "true"); ).toHaveAttribute("aria-expanded", "true");
}); });
test("仅图标模式名称截断且卡片尺寸不随侧栏和视口改变", async ({ page }) => { test("图标名称截断且卡片尺寸不随侧栏和视口改变", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page
.getByRole("button", { name: "仅图标显示", exact: true })
.click();
const palette = page.locator("aside.palette"); const palette = page.locator("aside.palette");
const longNameItem = palette.getByRole("button", { const longNameItem = palette.getByRole("button", {
@@ -162,7 +258,7 @@ test("仅图标模式名称截断且卡片尺寸不随侧栏和视口改变", as
await expect(name).toBeVisible(); await expect(name).toBeVisible();
await expect(name).toHaveText("AMESim 气动测试元件"); await expect(name).toHaveText("AMESim 气动测试元件");
await expect(meta).toBeHidden(); await expect(meta).toHaveCount(0);
await expect(longNameItem).not.toHaveAttribute("title"); await expect(longNameItem).not.toHaveAttribute("title");
const truncation = await name.evaluate((element) => { const truncation = await name.evaluate((element) => {
const style = window.getComputedStyle(element); const style = window.getComputedStyle(element);
@@ -204,11 +300,8 @@ test("仅图标模式名称截断且卡片尺寸不随侧栏和视口改变", as
await expectFixedIconCardSize(longNameItem); await expectFixedIconCardSize(longNameItem);
}); });
test("仅图标模式悬停后显示完整名称和图标预览并在移开后关闭", async ({ page }) => { test("图标悬停后显示完整名称和预览并在移开后关闭", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page
.getByRole("button", { name: "仅图标显示", exact: true })
.click();
const item = page.locator("aside.palette").getByRole("button", { const item = page.locator("aside.palette").getByRole("button", {
name: "AMESim 气动测试元件", name: "AMESim 气动测试元件",
@@ -234,13 +327,9 @@ test("仅图标模式悬停后显示完整名称和图标预览并在移开后
await expect(item).not.toHaveAttribute("aria-describedby", "palette-icon-preview"); await expect(item).not.toHaveAttribute("aria-describedby", "palette-icon-preview");
}); });
test("仅图标模式仍可拖入氦气介质并在画布显示专用图标", async ({ page }) => { test("图标布局仍可拖入氦气介质并在画布显示专用图标", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page
.getByRole("button", { name: "仅图标显示", exact: true })
.click();
const palette = page.locator("aside.palette"); const palette = page.locator("aside.palette");
const heliumItem = palette.getByRole("button", { const heliumItem = palette.getByRole("button", {
name: "氦气介质定义", name: "氦气介质定义",
@@ -292,7 +381,7 @@ test("从组件库拖入时使用工作区图标作为待拖入预览", async ({
__componentDragPreview?: { __componentDragPreview?: {
backgroundColor: string; backgroundColor: string;
borderTopWidth: string; borderTopWidth: string;
hasLegacyCardLabel: boolean; hasCardLabel: boolean;
height: number; height: number;
hotspotX: number; hotspotX: number;
hotspotY: number; hotspotY: number;
@@ -305,7 +394,7 @@ test("从组件库拖入时使用工作区图标作为待拖入预览", async ({
).__componentDragPreview = { ).__componentDragPreview = {
backgroundColor: style.backgroundColor, backgroundColor: style.backgroundColor,
borderTopWidth: style.borderTopWidth, borderTopWidth: style.borderTopWidth,
hasLegacyCardLabel: Boolean( hasCardLabel: Boolean(
image.querySelector(".component-drag-preview-label"), image.querySelector(".component-drag-preview-label"),
), ),
height: bounds.height, height: bounds.height,
@@ -336,7 +425,7 @@ test("从组件库拖入时使用工作区图标作为待拖入预览", async ({
__componentDragPreview?: { __componentDragPreview?: {
backgroundColor: string; backgroundColor: string;
borderTopWidth: string; borderTopWidth: string;
hasLegacyCardLabel: boolean; hasCardLabel: boolean;
height: number; height: number;
hotspotX: number; hotspotX: number;
hotspotY: number; hotspotY: number;
@@ -352,7 +441,7 @@ test("从组件库拖入时使用工作区图标作为待拖入预览", async ({
expect(preview!.rootClassName).toContain("component-drag-preview"); expect(preview!.rootClassName).toContain("component-drag-preview");
expect(preview!.symbolClassName).toContain("component-symbol-canvas"); expect(preview!.symbolClassName).toContain("component-symbol-canvas");
expect(preview!.symbolClassName).not.toContain("component-symbol-palette"); expect(preview!.symbolClassName).not.toContain("component-symbol-palette");
expect(preview!.hasLegacyCardLabel).toBe(false); expect(preview!.hasCardLabel).toBe(false);
expect(preview!.borderTopWidth).toBe("0px"); expect(preview!.borderTopWidth).toBe("0px");
expect(preview!.backgroundColor).toBe("rgba(0, 0, 0, 0)"); expect(preview!.backgroundColor).toBe("rgba(0, 0, 0, 0)");
expect(Math.abs(preview!.hotspotX - preview!.width / 2)).toBeLessThanOrEqual(0.5); expect(Math.abs(preview!.hotspotX - preview!.width / 2)).toBeLessThanOrEqual(0.5);
+188 -20
View File
@@ -1,6 +1,6 @@
import { expect, test, type Locator, type Page } from "@playwright/test"; import { expect, test, type Locator, type Page } from "@playwright/test";
import { prepareApp } from "./fixtures"; import { expandSimulationConsole, prepareApp } from "./fixtures";
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await prepareApp(page); await prepareApp(page);
@@ -695,7 +695,7 @@ test("MECMAS21 参数组折叠和条件切换保留值并清理详情浮层", as
); );
}); });
test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本", async ({ test("MECMAS21 当前参数编码由工程版本统一并写入 XML", async ({
page, page,
}) => { }) => {
await page.goto("/"); await page.goto("/");
@@ -709,7 +709,7 @@ test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本",
await expect(frictionGroup).toHaveCount(0); await expect(frictionGroup).toHaveCount(0);
await page.getByRole("button", { name: "保存工程", exact: true }).click(); await page.getByRole("button", { name: "保存工程", exact: true }).click();
const savedEncodingVersions = await page.evaluate(() => const savedContracts = await page.evaluate(() =>
Object.keys(window.localStorage) Object.keys(window.localStorage)
.filter((key) => key.startsWith("system-simulation-flow:project:")) .filter((key) => key.startsWith("system-simulation-flow:project:"))
.flatMap((key) => { .flatMap((key) => {
@@ -718,24 +718,32 @@ test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本",
return []; return [];
} }
const project = JSON.parse(raw) as { const project = JSON.parse(raw) as {
amesimParameterEncodingVersion?: number | null; projectSchemaVersion?: number;
nodes?: Array<{ id?: string }>; nodes?: Array<{ id?: string; data?: { modelVersion?: string } }>;
}; };
return project.nodes?.some( const node = project.nodes?.find(
(node) => node.id === "amesim_mecmas21_1", (node) => node.id === "amesim_mecmas21_1",
) );
? [project.amesimParameterEncodingVersion] return node
? [{
projectSchemaVersion: project.projectSchemaVersion,
modelVersion: node.data?.modelVersion,
}]
: []; : [];
}), }),
); );
expect(savedEncodingVersions).toContain(1); expect(savedContracts).toContainEqual({
projectSchemaVersion: 1,
modelVersion: "0.2.0",
});
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
await expect(page.getByLabel("生成的系统 XML")).toContainText( await expect(page.getByLabel("生成的系统 XML")).toContainText(
'amesimParameterEncodingVersion="1"', 'type="amesim_mecmas21" modelVersion="0.2.0"',
); );
const migratedProjectCount = await page.evaluate(() => { const changedProjectCount = await page.evaluate(() => {
let changed = 0; let changed = 0;
Object.keys(window.localStorage) Object.keys(window.localStorage)
.filter((key) => .filter((key) =>
@@ -750,8 +758,6 @@ test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本",
return; return;
} }
const project = JSON.parse(raw) as { const project = JSON.parse(raw) as {
mediumReferenceVersion?: number | null;
amesimParameterEncodingVersion?: number | null;
nodes?: Array<{ nodes?: Array<{
id?: string; id?: string;
data?: { data?: {
@@ -766,18 +772,13 @@ test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本",
if (!node?.data?.parameters) { if (!node?.data?.parameters) {
return; return;
} }
project.amesimParameterEncodingVersion = null;
project.mediumReferenceVersion = null;
delete node.data.modelType;
node.data.parameters.useFriction = 0;
delete node.data.parameters.strib;
node.data.parameters.unrelatedCode = 9; node.data.parameters.unrelatedCode = 9;
window.localStorage.setItem(key, JSON.stringify(project)); window.localStorage.setItem(key, JSON.stringify(project));
changed += 1; changed += 1;
}); });
return changed; return changed;
}); });
expect(migratedProjectCount).toBeGreaterThanOrEqual(2); expect(changedProjectCount).toBeGreaterThanOrEqual(2);
await page.getByRole("button", { name: "加载工程", exact: true }).click(); await page.getByRole("button", { name: "加载工程", exact: true }).click();
await page await page
@@ -791,6 +792,7 @@ test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本",
const frictionType = page.getByLabel("摩擦类型", { exact: true }); const frictionType = page.getByLabel("摩擦类型", { exact: true });
await frictionType.selectOption("2"); await frictionType.selectOption("2");
const strib = page.getByLabel("Stribeck 选项", { exact: true }); const strib = page.getByLabel("Stribeck 选项", { exact: true });
await strib.selectOption("2");
await expect(strib).toHaveValue("2"); await expect(strib).toHaveValue("2");
await expect(strib.locator("option:checked")).toHaveText("启用"); await expect(strib.locator("option:checked")).toHaveText("启用");
await expect(page.getByLabel("无关编码", { exact: true })).toHaveValue("9"); await expect(page.getByLabel("无关编码", { exact: true })).toHaveValue("9");
@@ -813,6 +815,166 @@ test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本",
await expect(strib).toHaveValue("2"); await expect(strib).toHaveValue("2");
}); });
test("MECMAS21 旧的 0/1 编码不转换并阻止执行", async ({
page,
}) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const changed = await page.evaluate(() => {
const key = Object.keys(window.localStorage).find((candidate) =>
candidate.startsWith("system-simulation-flow:project:"),
);
const raw = key ? window.localStorage.getItem(key) : null;
if (!key || !raw) {
return false;
}
const project = JSON.parse(raw) as {
nodes?: Array<{
id?: string;
data?: {
parameters?: Record<string, number | string>;
};
}>;
};
const node = project.nodes?.find(
(candidate) => candidate.id === "amesim_mecmas21_1",
);
if (!node?.data?.parameters) {
return false;
}
node.data.parameters.useFriction = 0;
window.localStorage.setItem(key, JSON.stringify(project));
return true;
});
expect(changed).toBe(true);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
const node = page.locator(
'.flow-canvas .react-flow__node[data-id="amesim_mecmas21_1"]',
);
await expect(node.locator('[data-model-contract-compatible="true"]')).toBeVisible();
await node.click();
const useFriction = page.getByLabel("启用摩擦", { exact: true });
await expect(useFriction).toHaveValue("");
await expect(useFriction.locator("option:checked")).toHaveText(
"当前选项无效,请重新选择",
);
const consolePanel = page.getByRole("complementary", {
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(
"useFriction=0",
);
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("模型检查发现");
});
test("缺失模型版本的工程可查看但不补默认参数且不能执行", async ({
page,
}) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const changed = await page.evaluate(() => {
const key = Object.keys(window.localStorage).find((candidate) =>
candidate.startsWith("system-simulation-flow:project:"),
);
const raw = key ? window.localStorage.getItem(key) : null;
if (!key || !raw) {
return false;
}
const project = JSON.parse(raw) as {
nodes?: Array<{
id?: string;
data?: {
modelVersion?: string;
parameters?: Record<string, number | string>;
};
}>;
};
const node = project.nodes?.find(
(candidate) => candidate.id === "amesim_mecmas21_1",
);
if (!node?.data?.parameters) {
return false;
}
delete node.data.modelVersion;
delete node.data.parameters.strib;
window.localStorage.setItem(key, JSON.stringify(project));
return true;
});
expect(changed).toBe(true);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
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 page.getByRole("button", { name: "保存工程", exact: true }).click();
const persistedWithoutDefaults = await page.evaluate(() => {
const key = Object.keys(window.localStorage).find((candidate) =>
candidate.startsWith("system-simulation-flow:project:"),
);
const raw = key ? window.localStorage.getItem(key) : null;
if (!raw) {
return false;
}
const project = JSON.parse(raw) as {
nodes?: Array<{
id?: string;
data?: {
modelVersion?: string;
parameters?: Record<string, number | string>;
};
}>;
};
const data = project.nodes?.find(
(candidate) => candidate.id === "amesim_mecmas21_1",
)?.data;
return Boolean(
data &&
data.modelVersion === undefined &&
data.parameters &&
!("strib" in data.parameters),
);
});
expect(persistedWithoutDefaults).toBe(true);
const consolePanel = page.getByRole("complementary", {
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 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("模型检查发现");
});
test("详情浮层只在参数列悬停且下拉选择期间不触发", async ({ page }) => { test("详情浮层只在参数列悬停且下拉选择期间不触发", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await addMecmasEncodingTestComponent(page); await addMecmasEncodingTestComponent(page);
@@ -995,6 +1157,7 @@ test("参数说明只在参数列悬停显示,单位切换不改变 XML 基础
); );
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
const consolePanel = page.getByRole("complementary", { const consolePanel = page.getByRole("complementary", {
name: "仿真控制台", name: "仿真控制台",
exact: true, exact: true,
@@ -1056,6 +1219,7 @@ test("科学计数法在提交、单位换算和工程加载后保持", async ({
await expect(temperatureInput).toHaveValue("1e-10"); await expect(temperatureInput).toHaveValue("1e-10");
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
await expect(page.getByLabel("生成的系统 XML")).toContainText( await expect(page.getByLabel("生成的系统 XML")).toContainText(
'<Parameter name="reference_pressure" value="1200000"', '<Parameter name="reference_pressure" value="1200000"',
); );
@@ -1235,6 +1399,7 @@ test("组件参数公式保留原式,并在说明中显示计算值且 XML 只
); );
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
const consolePanel = page.getByRole("complementary", { const consolePanel = page.getByRole("complementary", {
name: "仿真控制台", name: "仿真控制台",
exact: true, exact: true,
@@ -1275,6 +1440,7 @@ test("非法组件参数公式显示错误并阻止 XML 生成", async ({ page }
).toBeVisible(); ).toBeVisible();
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
const consolePanel = page.getByRole("complementary", { const consolePanel = page.getByRole("complementary", {
name: "仿真控制台", name: "仿真控制台",
exact: true, exact: true,
@@ -1305,6 +1471,7 @@ test("仿真数值设置同样保留公式,并在执行协议中使用计算
).toContainText("7 s"); ).toContainText("7 s");
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
const xmlBlock = page.getByLabel("生成的系统 XML"); const xmlBlock = page.getByLabel("生成的系统 XML");
await expect(xmlBlock).toContainText('tStop="7"'); await expect(xmlBlock).toContainText('tStop="7"');
await expect(xmlBlock).not.toContainText(expression); await expect(xmlBlock).not.toContainText(expression);
@@ -1330,7 +1497,8 @@ test("仿真数值设置中的科学计数法在保存加载后保持", async ({
await expect(stepInput).toHaveValue("1e-5"); await expect(stepInput).toHaveValue("1e-5");
await expect(maxStepInput).toHaveValue("1.20E-4"); await expect(maxStepInput).toHaveValue("1.20E-4");
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
const xmlBlock = page.getByLabel("生成的系统 XML"); const xmlBlock = page.getByLabel("生成的系统 XML");
await expect(xmlBlock).toContainText('step="0.00001"'); await expect(xmlBlock).toContainText('sampleStep="0.00001"');
await expect(xmlBlock).toContainText('maxStep="0.00012"'); await expect(xmlBlock).toContainText('maxStep="0.00012"');
}); });
-137
View File
@@ -1,137 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<xs:simpleType name="NonEmptyString">
<xs:restriction base="xs:string">
<xs:whiteSpace value="collapse"/>
<xs:minLength value="1"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="PositiveDouble">
<xs:restriction base="xs:double">
<xs:minExclusive value="0"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="PortKind">
<xs:restriction base="xs:string">
<xs:enumeration value="physical"/>
<xs:enumeration value="signal"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="PortNominalRole">
<xs:restriction base="xs:string">
<xs:enumeration value="inlet"/>
<xs:enumeration value="outlet"/>
<xs:enumeration value="bidirectional"/>
<xs:enumeration value="input"/>
<xs:enumeration value="output"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="PortSide">
<xs:restriction base="xs:string">
<xs:enumeration value="left"/>
<xs:enumeration value="right"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="EndpointRole">
<xs:restriction base="xs:string">
<xs:enumeration value="source"/>
<xs:enumeration value="target"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="NodeRotation">
<xs:restriction base="xs:integer">
<xs:enumeration value="0"/>
<xs:enumeration value="90"/>
<xs:enumeration value="180"/>
<xs:enumeration value="270"/>
</xs:restriction>
</xs:simpleType>
<xs:element name="System">
<xs:complexType>
<xs:sequence>
<xs:element name="Simulation">
<xs:complexType>
<xs:attribute name="tStart" type="xs:double" use="required"/>
<xs:attribute name="tStop" type="xs:double" use="required"/>
<xs:attribute name="step" type="PositiveDouble" use="required"/>
<xs:attribute name="maxStep" type="PositiveDouble" use="required"/>
<xs:attribute name="method" type="NonEmptyString" use="required"/>
</xs:complexType>
</xs:element>
<xs:element name="Components">
<xs:complexType>
<xs:sequence>
<xs:element name="Component" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:sequence>
<xs:element name="Port" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
<xs:attribute name="kind" type="PortKind" use="required"/>
<xs:attribute name="domain" type="NonEmptyString" use="required"/>
<xs:attribute name="nominalRole" type="PortNominalRole" use="required"/>
<xs:attribute name="positiveFlowDirection" use="optional" fixed="intoComponent"/>
<xs:attribute name="side" type="PortSide" use="required"/>
</xs:complexType>
</xs:element>
<xs:element name="Parameter" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
<xs:attribute name="value" type="xs:double" use="required"/>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="id" type="NonEmptyString" use="required"/>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
<xs:attribute name="type" type="NonEmptyString" use="required"/>
<xs:attribute name="componentType" type="NonEmptyString" use="required"/>
<xs:attribute name="x" type="xs:double" use="required"/>
<xs:attribute name="y" type="xs:double" use="required"/>
<xs:attribute name="rotation" type="NodeRotation" use="optional" default="0"/>
<xs:attribute name="mirrored" type="xs:boolean" use="optional" default="false"/>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="Connections">
<xs:complexType>
<xs:sequence>
<xs:element name="Connection" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:sequence>
<xs:element name="Endpoint" minOccurs="2" maxOccurs="2">
<xs:complexType>
<xs:attribute name="component" type="NonEmptyString" use="required"/>
<xs:attribute name="port" type="NonEmptyString" use="required"/>
<xs:attribute name="role" type="EndpointRole" use="optional"/>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="id" type="NonEmptyString" use="required"/>
<xs:attribute name="kind" type="PortKind" use="required"/>
<xs:attribute name="domain" type="NonEmptyString" use="required"/>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
<xs:attribute name="schemaVersion" use="required" fixed="2"/>
<xs:attribute name="unitSystem" use="required" fixed="SI"/>
<xs:attribute name="mediumReferenceVersion" use="optional" fixed="1"/>
<xs:attribute name="amesimParameterEncodingVersion" use="optional" fixed="1"/>
</xs:complexType>
</xs:element>
</xs:schema>
@@ -2,6 +2,7 @@
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified"> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<xs:simpleType name="NonEmptyString"> <xs:simpleType name="NonEmptyString">
<xs:restriction base="xs:string"> <xs:restriction base="xs:string">
<xs:whiteSpace value="collapse"/>
<xs:minLength value="1"/> <xs:minLength value="1"/>
</xs:restriction> </xs:restriction>
</xs:simpleType> </xs:simpleType>
@@ -19,7 +20,7 @@
<xs:complexType> <xs:complexType>
<xs:attribute name="tStart" type="xs:double" use="required"/> <xs:attribute name="tStart" type="xs:double" use="required"/>
<xs:attribute name="tStop" type="xs:double" use="required"/> <xs:attribute name="tStop" type="xs:double" use="required"/>
<xs:attribute name="step" type="PositiveDouble" use="required"/> <xs:attribute name="sampleStep" type="PositiveDouble" use="required"/>
<xs:attribute name="maxStep" type="PositiveDouble" use="required"/> <xs:attribute name="maxStep" type="PositiveDouble" use="required"/>
<xs:attribute name="method" type="NonEmptyString" use="required"/> <xs:attribute name="method" type="NonEmptyString" use="required"/>
</xs:complexType> </xs:complexType>
@@ -31,11 +32,6 @@
<xs:element name="Component" minOccurs="0" maxOccurs="unbounded"> <xs:element name="Component" minOccurs="0" maxOccurs="unbounded">
<xs:complexType> <xs:complexType>
<xs:sequence> <xs:sequence>
<xs:element name="Port" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
</xs:complexType>
</xs:element>
<xs:element name="Parameter" minOccurs="0" maxOccurs="unbounded"> <xs:element name="Parameter" minOccurs="0" maxOccurs="unbounded">
<xs:complexType> <xs:complexType>
<xs:attribute name="name" type="NonEmptyString" use="required"/> <xs:attribute name="name" type="NonEmptyString" use="required"/>
@@ -44,11 +40,8 @@
</xs:element> </xs:element>
</xs:sequence> </xs:sequence>
<xs:attribute name="id" type="NonEmptyString" use="required"/> <xs:attribute name="id" type="NonEmptyString" use="required"/>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
<xs:attribute name="type" type="NonEmptyString" use="required"/> <xs:attribute name="type" type="NonEmptyString" use="required"/>
<xs:attribute name="componentType" type="NonEmptyString" use="required"/> <xs:attribute name="modelVersion" type="NonEmptyString" use="required"/>
<xs:attribute name="x" type="xs:double" use="required"/>
<xs:attribute name="y" type="xs:double" use="required"/>
</xs:complexType> </xs:complexType>
</xs:element> </xs:element>
</xs:sequence> </xs:sequence>
@@ -60,11 +53,15 @@
<xs:sequence> <xs:sequence>
<xs:element name="Connection" minOccurs="0" maxOccurs="unbounded"> <xs:element name="Connection" minOccurs="0" maxOccurs="unbounded">
<xs:complexType> <xs:complexType>
<xs:attribute name="id" type="NonEmptyString" use="required"/> <xs:sequence>
<xs:attribute name="source" type="NonEmptyString" use="required"/> <xs:element name="Endpoint" minOccurs="2" maxOccurs="2">
<xs:attribute name="sourcePort" type="NonEmptyString" use="required"/> <xs:complexType>
<xs:attribute name="target" type="NonEmptyString" use="required"/> <xs:attribute name="component" type="NonEmptyString" use="required"/>
<xs:attribute name="targetPort" type="NonEmptyString" use="required"/> <xs:attribute name="port" type="NonEmptyString" use="required"/>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="id" type="NonEmptyString" use="optional"/>
</xs:complexType> </xs:complexType>
</xs:element> </xs:element>
</xs:sequence> </xs:sequence>
@@ -72,8 +69,8 @@
</xs:element> </xs:element>
</xs:sequence> </xs:sequence>
<xs:attribute name="name" type="NonEmptyString" use="required"/> <xs:attribute name="name" type="NonEmptyString" use="optional"/>
<xs:attribute name="schemaVersion" use="required" fixed="1"/> <xs:attribute name="schemaVersion" use="required" fixed="3"/>
<xs:attribute name="unitSystem" use="required" fixed="SI"/> <xs:attribute name="unitSystem" use="required" fixed="SI"/>
</xs:complexType> </xs:complexType>
</xs:element> </xs:element>
+23 -5
View File
@@ -6,6 +6,7 @@ from app.main import (
ReactFlowProjectPayload, ReactFlowProjectPayload,
build_reactflow_system_xml, build_reactflow_system_xml,
compile_reactflow_network, compile_reactflow_network,
compile_system_xml_network,
) )
from app.system_xml import validate_system_xml_document from app.system_xml import validate_system_xml_document
from app.simulation.components.amesim.gases import ( from app.simulation.components.amesim.gases import (
@@ -176,6 +177,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
registry.resolve(2, component_name="pipe_1") registry.resolve(2, component_name="pipe_1")
project = ReactFlowProjectPayload( project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="undefined-gas", name="undefined-gas",
nodes=[pneumatic_pipe_node("pipe_1", gi=2.0)], nodes=[pneumatic_pipe_node("pipe_1", gi=2.0)],
) )
@@ -221,6 +223,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
) )
project = ReactFlowProjectPayload( project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="project-air-slot", name="project-air-slot",
nodes=[ nodes=[
pneumatic_pipe_node("pipe_1", gi=1.0), pneumatic_pipe_node("pipe_1", gi=1.0),
@@ -267,6 +270,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_project_medium_definition_may_appear_after_its_references(self) -> None: def test_project_medium_definition_may_appear_after_its_references(self) -> None:
project = ReactFlowProjectPayload( project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="two-phase-medium-resolution", name="two-phase-medium-resolution",
nodes=[ nodes=[
pneumatic_chamber_node("chamber_1", gi=7.0), pneumatic_chamber_node("chamber_1", gi=7.0),
@@ -285,6 +289,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
self, self,
) -> None: ) -> None:
original = ReactFlowProjectPayload( original = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="medium-xml-round-trip", name="medium-xml-round-trip",
nodes=[ nodes=[
ideal_air_medium_node( ideal_air_medium_node(
@@ -302,7 +307,6 @@ class AmesimGasRegistryTests(unittest.TestCase):
self.assertTrue(report.valid, report.issues) self.assertTrue(report.valid, report.issues)
self.assertIsNotNone(report.document) self.assertIsNotNone(report.document)
assert report.document is not None assert report.document is not None
self.assertEqual(report.document.medium_reference_version, "1")
components = { components = {
component.id: component for component in report.document.components component.id: component for component in report.document.components
} }
@@ -310,6 +314,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
components["air_properties"].model_type, components["air_properties"].model_type,
"amesim_ideal_air_medium", "amesim_ideal_air_medium",
) )
self.assertEqual(components["air_properties"].model_version, "0.2.0")
self.assertEqual( self.assertEqual(
{item.name: item.value for item in components["air_properties"].parameters}, {item.name: item.value for item in components["air_properties"].parameters},
{"gi": 4.0, "property_model": 0.0}, {"gi": 4.0, "property_model": 0.0},
@@ -320,11 +325,17 @@ class AmesimGasRegistryTests(unittest.TestCase):
], ],
4.0, 4.0,
) )
model_components = {
parsed_project = ReactFlowProjectPayload( component["id"]: component
**report.document.as_project_data() for component in report.document.as_model_data()["components"]
}
self.assertEqual(
model_components["air_properties"]["parameters"],
{"gi": 4.0, "property_model": 0.0},
) )
network = compile_reactflow_network(parsed_project) self.assertEqual(model_components["pipe_1"]["parameters"]["gi"], 4.0)
network = compile_system_xml_network(report.document)
self.assertNotIn("air_properties", network.components) self.assertNotIn("air_properties", network.components)
self.assertIsInstance( self.assertIsInstance(
@@ -334,6 +345,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_duplicate_project_medium_indices_are_rejected(self) -> None: def test_duplicate_project_medium_indices_are_rejected(self) -> None:
project = ReactFlowProjectPayload( project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="duplicate-medium-index", name="duplicate-medium-index",
nodes=[ nodes=[
ideal_air_medium_node("air_properties_1", gi=1.0), ideal_air_medium_node("air_properties_1", gi=1.0),
@@ -349,6 +361,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_medium_definition_cannot_claim_builtin_slot_zero(self) -> None: def test_medium_definition_cannot_claim_builtin_slot_zero(self) -> None:
project = ReactFlowProjectPayload( project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="reserved-medium-index", name="reserved-medium-index",
nodes=[ideal_air_medium_node("air_properties", gi=0.0)], nodes=[ideal_air_medium_node("air_properties", gi=0.0)],
) )
@@ -358,6 +371,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_default_compile_uses_air_before_state_initialization(self) -> None: def test_default_compile_uses_air_before_state_initialization(self) -> None:
project = ReactFlowProjectPayload( project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="default-air", name="default-air",
nodes=[pneumatic_chamber_node("chamber_1")], nodes=[pneumatic_chamber_node("chamber_1")],
) )
@@ -374,6 +388,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_gi_is_resolved_before_dynamic_state_initialization(self) -> None: def test_gi_is_resolved_before_dynamic_state_initialization(self) -> None:
registry, _, future_helium = two_slot_registry() registry, _, future_helium = two_slot_registry()
project = ReactFlowProjectPayload( project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="selected-medium-initialization", name="selected-medium-initialization",
nodes=[pneumatic_chamber_node("chamber_1", gi=2.0)], nodes=[pneumatic_chamber_node("chamber_1", gi=2.0)],
) )
@@ -399,6 +414,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
) -> None: ) -> None:
registry, _, future_helium = two_slot_registry() registry, _, future_helium = two_slot_registry()
project = ReactFlowProjectPayload( project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="future-helium-circuit", name="future-helium-circuit",
nodes=[ nodes=[
pneumatic_pipe_node("pnl_1", gi=2.0), pneumatic_pipe_node("pnl_1", gi=2.0),
@@ -425,6 +441,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_connected_circuit_rejects_conflicting_gas_indices(self) -> None: def test_connected_circuit_rejects_conflicting_gas_indices(self) -> None:
registry, _, _ = two_slot_registry() registry, _, _ = two_slot_registry()
project = ReactFlowProjectPayload( project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="conflicting-gases", name="conflicting-gases",
nodes=[ nodes=[
pneumatic_pipe_node("air_pipe", gi=0.0), pneumatic_pipe_node("air_pipe", gi=0.0),
@@ -453,6 +470,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_disconnected_circuits_may_use_different_gas_indices(self) -> None: def test_disconnected_circuits_may_use_different_gas_indices(self) -> None:
registry, air, future_helium = two_slot_registry() registry, air, future_helium = two_slot_registry()
project = ReactFlowProjectPayload( project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="separate-gases", name="separate-gases",
nodes=[ nodes=[
pneumatic_pipe_node("air_pipe", gi=0.0), pneumatic_pipe_node("air_pipe", gi=0.0),
+1
View File
@@ -214,6 +214,7 @@ class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase):
temperature = 287.7322 temperature = 287.7322
volume = 0.01 volume = 0.01
project = ReactFlowProjectPayload( project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="helium-peng-robinson-compile", name="helium-peng-robinson-compile",
nodes=[ nodes=[
component_node( component_node(
@@ -54,6 +54,7 @@ def helium_step_equalization_project() -> ReactFlowProjectPayload:
"""Programmatic form of the user XML that failed at the 3.04 s sample.""" """Programmatic form of the user XML that failed at the 3.04 s sample."""
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-helium-step-equalization-regression", name="amesim-helium-step-equalization-regression",
nodes=[ nodes=[
component_node( component_node(
@@ -43,14 +43,36 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
self.assertAlmostEqual(converter.output_force, 20.0) self.assertAlmostEqual(converter.output_force, 20.0)
self.assertAlmostEqual(residuals[0].value, 0.0) self.assertAlmostEqual(residuals[0].value, 0.0)
self.assertEqual(converter.component_result_values(), {"force": 20.0}) self.assertEqual(converter.component_result_values(), {"force": 20.0})
self.assertEqual(converter.parameter_values, {"direction": 1.0})
converter.apply_layout_transform(rotation=180, mirrored=False) def test_forc_negative_direction_reverses_applied_force(self) -> None:
converter = AmesimForc.create(
name="force_reverse",
medium=self.medium,
parameters={"direction": -1.0},
)
converter.res.signal = 20.0
converter.port_2.f = 20.0 converter.port_2.f = 20.0
self.assertAlmostEqual( self.assertAlmostEqual(
converter.pressure_flow_equation_residuals()[0].value, converter.pressure_flow_equation_residuals()[0].value,
0.0, 0.0,
) )
self.assertEqual(converter.component_result_values(), {"force": 20.0}) self.assertEqual(converter.parameter_values, {"direction": -1.0})
self.assertEqual(converter.component_result_values(), {"force": -20.0})
def test_forc_direction_only_accepts_positive_or_negative_one(self) -> None:
definition = AmesimForc.PARAMETERS[0]
self.assertEqual(AmesimForc.MODEL_VERSION, "0.2.0")
self.assertEqual(definition.name, "direction")
self.assertEqual(definition.default, 1.0)
self.assertEqual(
tuple((option.value, option.label) for option in definition.options),
((1.0, "正向"), (-1.0, "反向")),
)
with self.assertRaisesRegex(ValueError, "must be one of 1, -1"):
AmesimForc("invalid_force", direction=0.0)
def test_mecmas21_acceleration_uses_connected_port_forces(self) -> None: def test_mecmas21_acceleration_uses_connected_port_forces(self) -> None:
mass = AmesimMecmas21( mass = AmesimMecmas21(
@@ -113,6 +135,8 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
"strib": ((1.0, "否"), (2.0, "是")), "strib": ((1.0, "否"), (2.0, "是")),
"frictionType": ((1.0, "简单"), (2.0, "高级")), "frictionType": ((1.0, "简单"), (2.0, "高级")),
} }
self.assertEqual(definitions["useFriction"].default, 2.0)
self.assertEqual(definitions["stoptype"].default, 1.0)
for name, options in expected_options.items(): for name, options in expected_options.items():
with self.subTest(name=name): with self.subTest(name=name):
@@ -200,19 +224,25 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
node.port_2.x = 0.2 node.port_2.x = 0.2
node.port_2.v = 0.3 node.port_2.v = 0.3
node.port_2.f = 6.0 node.port_2.f = 6.0
node.port_9.x = 0.2 node.port_3.x = 0.2
node.port_9.v = 0.3 node.port_3.v = 0.3
node.port_9.f = -10.0 node.port_3.f = -10.0
residuals = node.pressure_flow_equation_residuals() residuals = node.pressure_flow_equation_residuals()
self.assertEqual(node.active_ports, ("port_1", "port_2", "port_9")) self.assertEqual(node.active_ports, ("port_1", "port_2", "port_3"))
self.assertAlmostEqual(node.total_force, 10.0) self.assertAlmostEqual(node.total_force, 10.0)
self.assertAlmostEqual(node.force_balance, 0.0) self.assertAlmostEqual(node.force_balance, 0.0)
self.assertEqual(len(residuals), 5) self.assertEqual(len(residuals), 5 + 18 * 3)
self.assertTrue(all(abs(residual.value) <= 1.0e-12 for residual in residuals)) self.assertTrue(all(abs(residual.value) <= 1.0e-12 for residual in residuals))
self.assertEqual(node.component_result_values(), {"tforce": 10.0}) self.assertEqual(node.component_result_values(), {"tforce": 10.0})
def test_lmechn1_defaults_to_two_right_ports(self) -> None:
node = AmesimLmechn1("node_default", self.medium)
self.assertEqual(node.v1, 2)
self.assertEqual(node.active_ports, ("port_1", "port_2", "port_3"))
def test_lmechn1_registry_rejects_fractional_integer_options(self) -> None: def test_lmechn1_registry_rejects_fractional_integer_options(self) -> None:
with self.assertRaisesRegex(ValueError, "v1 must be an integer"): with self.assertRaisesRegex(ValueError, "v1 must be an integer"):
COMPONENT_MODEL_REGISTRY["amesim_lmechn1"].create( COMPONENT_MODEL_REGISTRY["amesim_lmechn1"].create(
@@ -221,6 +251,16 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
{"v1": 2.5}, {"v1": 2.5},
) )
def test_lmechn1_supports_twenty_right_ports(self) -> None:
node = AmesimLmechn1("node_20", self.medium, v1=20.0, sum=1.0)
self.assertEqual(
node.active_ports,
tuple(f"port_{index}" for index in range(1, 22)),
)
with self.assertRaisesRegex(ValueError, "must be at most 20"):
AmesimLmechn1("node_21", self.medium, v1=21.0, sum=1.0)
def test_lstp00a_generates_opposing_contact_forces(self) -> None: def test_lstp00a_generates_opposing_contact_forces(self) -> None:
contact = AmesimLstp00a( contact = AmesimLstp00a(
"contact_1", "contact_1",
+69 -183
View File
@@ -7,7 +7,7 @@ from app.main import (
ReactFlowProjectPayload, ReactFlowProjectPayload,
build_reactflow_system_xml, build_reactflow_system_xml,
compile_reactflow_network, compile_reactflow_network,
reactflow_project_storage_data, compile_system_xml_network,
run_system_xml_simulation, run_system_xml_simulation,
) )
from app.simulation.systems.generic import GenericFluidSystem from app.simulation.systems.generic import GenericFluidSystem
@@ -64,23 +64,17 @@ def mechanical_port(name: str, side: str) -> dict[str, str]:
return physical_port(name, "bidirectional", side, domain="mechanical") return physical_port(name, "bidirectional", side, domain="mechanical")
def mecmas_parameter_encoding_project( def mecmas_project(
use_friction: float, use_friction: float,
strib: float, strib: float,
*,
encoding_version: int | None,
) -> ReactFlowProjectPayload: ) -> ReactFlowProjectPayload:
parameters = { parameters = {
**MECMAS21_DEFAULTS, **MECMAS21_DEFAULTS,
"useFriction": use_friction, "useFriction": use_friction,
"strib": strib, "strib": strib,
} }
version = (
{"amesimParameterEncodingVersion": encoding_version}
if encoding_version is not None
else {}
)
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-mecmas21-parameter-encoding", name="amesim-mecmas21-parameter-encoding",
nodes=[ nodes=[
component_node( component_node(
@@ -101,7 +95,6 @@ def mecmas_parameter_encoding_project(
"max_step": 0.01, "max_step": 0.01,
"method": "BDF", "method": "BDF",
}, },
**version,
) )
@@ -117,8 +110,8 @@ def mecmas_xml_parameter_values(xml: bytes) -> tuple[ET.Element, dict[str, float
def zero_force_mass_project() -> ReactFlowProjectPayload: def zero_force_mass_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-mechanical-zero-force-smoke", name="amesim-mechanical-zero-force-smoke",
amesimParameterEncodingVersion=1,
nodes=[ nodes=[
component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]), component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]),
component_node( component_node(
@@ -141,8 +134,8 @@ def signal_force_mass_project() -> ReactFlowProjectPayload:
parameters = dict(MECMAS21_DEFAULTS) parameters = dict(MECMAS21_DEFAULTS)
parameters["mass"] = 2.0 parameters["mass"] = 2.0
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-mechanical-signal-force-smoke", name="amesim-mechanical-signal-force-smoke",
amesimParameterEncodingVersion=1,
nodes=[ nodes=[
component_node( component_node(
"force_signal", "force_signal",
@@ -206,8 +199,8 @@ def elastic_contact_project() -> ReactFlowProjectPayload:
right_parameters = dict(MECMAS21_DEFAULTS) right_parameters = dict(MECMAS21_DEFAULTS)
right_parameters["x0"] = 0.0 right_parameters["x0"] = 0.0
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-mechanical-elastic-contact-smoke", name="amesim-mechanical-elastic-contact-smoke",
amesimParameterEncodingVersion=1,
nodes=[ nodes=[
component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]), component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]),
component_node( component_node(
@@ -240,20 +233,20 @@ def elastic_contact_project() -> ReactFlowProjectPayload:
) )
def force_node_mass_project() -> ReactFlowProjectPayload: def force_node_mass_project(right_port_count: int = 8) -> ReactFlowProjectPayload:
node_ports = [mechanical_port(f"port_{index}", "left") for index in range(1, 9)] node_ports = [mechanical_port(f"port_{index}", "right") for index in range(1, 21)]
node_ports.append(mechanical_port("port_9", "right")) node_ports.append(mechanical_port("port_21", "left"))
zero_nodes = [ zero_nodes = [
component_node(f"zero_{index}", "amesim_f000", [mechanical_port("port_1", "right")]) component_node(f"zero_{index}", "amesim_f000", [mechanical_port("port_1", "right")])
for index in range(2, 9) for index in range(2, right_port_count + 1)
] ]
zero_edges = [ zero_edges = [
physical_edge(f"edge-zero-{index}", f"zero_{index}", "port_1", "node_1", f"port_{index}") physical_edge(f"edge-zero-{index}", f"zero_{index}", "port_1", "node_1", f"port_{index}")
for index in range(2, 9) for index in range(2, right_port_count + 1)
] ]
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-mechanical-node-smoke", name="amesim-mechanical-node-smoke",
amesimParameterEncodingVersion=1,
nodes=[ nodes=[
component_node( component_node(
"step_1", "step_1",
@@ -266,7 +259,12 @@ def force_node_mass_project() -> ReactFlowProjectPayload:
"amesim_forc", "amesim_forc",
[signal_port("res", "input", "left"), mechanical_port("port_2", "right")], [signal_port("res", "input", "left"), mechanical_port("port_2", "right")],
), ),
component_node("node_1", "amesim_lmechn1", node_ports, {"v1": 8.0, "sum": 1.0}), component_node(
"node_1",
"amesim_lmechn1",
node_ports,
{"v1": float(right_port_count), "sum": 1.0},
),
*zero_nodes, *zero_nodes,
component_node( component_node(
"mass_1", "mass_1",
@@ -280,7 +278,13 @@ def force_node_mass_project() -> ReactFlowProjectPayload:
signal_edge("signal-1", "step_1", "out", "force_1", "res"), signal_edge("signal-1", "step_1", "out", "force_1", "res"),
physical_edge("edge-force", "force_1", "port_2", "node_1", "port_1"), physical_edge("edge-force", "force_1", "port_2", "node_1", "port_1"),
*zero_edges, *zero_edges,
physical_edge("edge-mass", "node_1", "port_9", "mass_1", "port_1"), physical_edge(
"edge-mass",
"node_1",
f"port_{right_port_count + 1}",
"mass_1",
"port_1",
),
physical_edge("edge-right", "mass_1", "port_2", "zero_right", "port_1"), physical_edge("edge-right", "mass_1", "port_2", "zero_right", "port_1"),
], ],
simulation={"t_start": 0.0, "t_stop": 0.02, "step": 0.01, "max_step": 0.005, "method": "BDF"}, simulation={"t_start": 0.0, "t_stop": 0.02, "step": 0.01, "max_step": 0.005, "method": "BDF"},
@@ -299,168 +303,66 @@ class AmesimMechanicalXmlTests(unittest.TestCase):
self.assertEqual(len(tolerances), len(system.initial_state_vector())) self.assertEqual(len(tolerances), len(system.initial_state_vector()))
def test_sparse_legacy_reactflow_mecmas_defaults_are_canonicalized( def test_sparse_reactflow_mecmas_parameters_use_registered_defaults(self) -> None:
self, project = mecmas_project(1.0, 1.0)
) -> None:
project = mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=None,
)
project.nodes[0].data.parameters.pop("useFriction") project.nodes[0].data.parameters.pop("useFriction")
project.nodes[0].data.parameters.pop("strib") project.nodes[0].data.parameters.pop("strib")
network = compile_reactflow_network(project) network = compile_reactflow_network(project)
mass = network.components["mass_1"] mass = network.components["mass_1"]
self.assertEqual(mass.parameter_values["useFriction"], 2.0) self.assertEqual(mass.parameter_values["useFriction"], 2.0)
self.assertEqual(mass.parameter_values["strib"], 2.0) self.assertEqual(mass.parameter_values["strib"], 1.0)
self.assertTrue(mass.use_friction) self.assertTrue(mass.use_friction)
xml = build_reactflow_system_xml(project) xml = build_reactflow_system_xml(project)
root, parameters = mecmas_xml_parameter_values(xml) _, parameters = mecmas_xml_parameter_values(xml)
self.assertEqual(root.get("amesimParameterEncodingVersion"), "1")
self.assertEqual(parameters["useFriction"], 2.0) self.assertEqual(parameters["useFriction"], 2.0)
self.assertEqual(parameters["strib"], 2.0) self.assertEqual(parameters["strib"], 1.0)
report = validate_system_xml_document(xml) report = validate_system_xml_document(xml)
self.assertTrue(report.valid, report.as_dict()) self.assertTrue(report.valid, report.as_dict())
assert report.document is not None assert report.document is not None
normalized_project = ReactFlowProjectPayload( normalized_mass = compile_system_xml_network(report.document).components["mass_1"]
**report.document.as_project_data()
)
normalized_mass = compile_reactflow_network(normalized_project).components[
"mass_1"
]
self.assertEqual(normalized_mass.parameter_values["useFriction"], 2.0) self.assertEqual(normalized_mass.parameter_values["useFriction"], 2.0)
self.assertEqual(normalized_mass.parameter_values["strib"], 2.0) self.assertEqual(normalized_mass.parameter_values["strib"], 1.0)
self.assertTrue(normalized_mass.use_friction) self.assertTrue(normalized_mass.use_friction)
stored_data = reactflow_project_storage_data(project) def test_removed_zero_based_mecmas_code_is_rejected(self) -> None:
self.assertEqual(stored_data["amesimParameterEncodingVersion"], 1) project = mecmas_project(0.0, 0.0)
stored_parameters = stored_data["nodes"][0]["data"]["parameters"]
self.assertEqual(stored_parameters["useFriction"], 2.0)
self.assertEqual(stored_parameters["strib"], 2.0)
stored_mass = compile_reactflow_network(
ReactFlowProjectPayload(**stored_data)
).components["mass_1"]
self.assertTrue(stored_mass.use_friction)
def test_legacy_reactflow_mecmas_codes_compile_and_export_as_canonical( with self.assertRaisesRegex(ValueError, "must be one of 1, 2"):
self, compile_reactflow_network(project)
) -> None: with self.assertRaisesRegex(ValueError, "must be one of 1, 2"):
for legacy_value, canonical_value in ((0.0, 1.0), (1.0, 2.0)):
with self.subTest(legacy_value=legacy_value):
project = mecmas_parameter_encoding_project(
legacy_value,
legacy_value,
encoding_version=None,
)
network = compile_reactflow_network(project)
mass = network.components["mass_1"]
self.assertEqual(
mass.parameter_values["useFriction"],
canonical_value,
)
self.assertEqual(
mass.parameter_values["strib"],
canonical_value,
)
self.assertEqual(
mass.use_friction,
canonical_value == 2.0,
)
root, parameters = mecmas_xml_parameter_values(
build_reactflow_system_xml(project) build_reactflow_system_xml(project)
)
self.assertEqual(
root.get("amesimParameterEncodingVersion"),
"1",
)
self.assertEqual(
parameters["useFriction"],
canonical_value,
)
self.assertEqual(parameters["strib"], canonical_value)
def test_versioned_reactflow_mecmas_codes_are_not_reinterpreted(self) -> None: def test_current_mecmas_codes_are_not_reinterpreted(self) -> None:
project = mecmas_parameter_encoding_project( project = mecmas_project(1.0, 2.0)
1.0,
2.0,
encoding_version=1,
)
network = compile_reactflow_network(project) network = compile_reactflow_network(project)
mass = network.components["mass_1"] mass = network.components["mass_1"]
self.assertEqual(mass.parameter_values["useFriction"], 1.0) self.assertEqual(mass.parameter_values["useFriction"], 1.0)
self.assertEqual(mass.parameter_values["strib"], 2.0) self.assertEqual(mass.parameter_values["strib"], 2.0)
def test_legacy_system_xml_mecmas_codes_are_migrated_with_warning(self) -> None: def test_v3_rejects_removed_system_xml_encoding_marker(self) -> None:
for legacy_value, canonical_value in ((0.0, 1.0), (1.0, 2.0)):
with self.subTest(legacy_value=legacy_value):
root, _ = mecmas_xml_parameter_values( root, _ = mecmas_xml_parameter_values(
build_reactflow_system_xml( build_reactflow_system_xml(mecmas_project(1.0, 1.0))
mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=1,
) )
) root.set("amesimParameterEncodingVersion", "1")
)
del root.attrib["amesimParameterEncodingVersion"]
component = root.find("./Components/Component[@id='mass_1']")
assert component is not None
for parameter in component.findall("Parameter"):
if parameter.get("name") in {"useFriction", "strib"}:
parameter.set("value", str(legacy_value))
report = validate_system_xml_document( report = validate_system_xml_document(
ET.tostring(root, encoding="utf-8", xml_declaration=True) ET.tostring(root, encoding="utf-8", xml_declaration=True)
) )
self.assertTrue(report.valid, report.as_dict()) self.assertFalse(report.valid)
migration_issues = [
issue
for issue in report.issues
if issue.code == "AMESIM_PARAMETER_ENCODING_MIGRATED"
]
self.assertEqual(len(migration_issues), 1)
self.assertTrue(
all(issue.severity == "warning" for issue in migration_issues)
)
assert report.document is not None
migrated = {
parameter.name: parameter.value
for parameter in report.document.components[0].parameters
}
self.assertEqual( self.assertEqual(
migrated["useFriction"], {issue.code for issue in report.issues},
canonical_value, {"XSD_VALIDATION_ERROR"},
)
self.assertEqual(migrated["strib"], canonical_value)
self.assertEqual(
report.document.amesim_parameter_encoding_version,
"1",
)
self.assertEqual(
report.document.as_project_data()[
"amesimParameterEncodingVersion"
],
1,
) )
def test_sparse_legacy_system_xml_mecmas_defaults_are_migrated(self) -> None: def test_v3_requires_complete_mecmas_parameter_set(self) -> None:
root, _ = mecmas_xml_parameter_values( root, _ = mecmas_xml_parameter_values(
build_reactflow_system_xml( build_reactflow_system_xml(mecmas_project(1.0, 1.0))
mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=1,
) )
)
)
del root.attrib["amesimParameterEncodingVersion"]
component = root.find("./Components/Component[@id='mass_1']") component = root.find("./Components/Component[@id='mass_1']")
assert component is not None assert component is not None
for parameter in list(component.findall("Parameter")): for parameter in list(component.findall("Parameter")):
@@ -471,49 +373,20 @@ class AmesimMechanicalXmlTests(unittest.TestCase):
ET.tostring(root, encoding="utf-8", xml_declaration=True) ET.tostring(root, encoding="utf-8", xml_declaration=True)
) )
self.assertTrue(report.valid, report.as_dict()) self.assertFalse(report.valid)
migration_issues = [ self.assertEqual(
issue [issue.code for issue in report.issues].count(
for issue in report.issues "PARAMETER_REQUIRED_MISSING"
if issue.code == "AMESIM_PARAMETER_ENCODING_MIGRATED" ),
] 2,
self.assertEqual(len(migration_issues), 1) )
self.assertEqual(migration_issues[0].severity, "warning")
assert report.document is not None
self.assertEqual(report.document.amesim_parameter_encoding_version, "1")
parameters = {
parameter.name: parameter.value
for parameter in report.document.components[0].parameters
}
self.assertEqual(parameters["useFriction"], 2.0)
self.assertEqual(parameters["strib"], 2.0)
normalized_project = ReactFlowProjectPayload( def test_system_xml_mecmas_codes_are_preserved(self) -> None:
**report.document.as_project_data() xml = build_reactflow_system_xml(mecmas_project(1.0, 1.0))
)
normalized_mass = compile_reactflow_network(normalized_project).components[
"mass_1"
]
self.assertEqual(normalized_mass.parameter_values["useFriction"], 2.0)
self.assertEqual(normalized_mass.parameter_values["strib"], 2.0)
self.assertTrue(normalized_mass.use_friction)
def test_versioned_system_xml_mecmas_codes_are_not_migrated(self) -> None:
xml = build_reactflow_system_xml(
mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=1,
)
)
report = validate_system_xml_document(xml) report = validate_system_xml_document(xml)
self.assertTrue(report.valid, report.as_dict()) self.assertTrue(report.valid, report.as_dict())
self.assertNotIn(
"AMESIM_PARAMETER_ENCODING_MIGRATED",
{issue.code for issue in report.issues},
)
assert report.document is not None assert report.document is not None
parameters = { parameters = {
parameter.name: parameter.value parameter.name: parameter.value
@@ -539,6 +412,19 @@ class AmesimMechanicalXmlTests(unittest.TestCase):
self.assertGreater(result["series"]["mass_1.v"][-1], 0.0) self.assertGreater(result["series"]["mass_1.v"][-1], 0.0)
self.assertGreater(result["series"]["mass_1.x"][-1], 0.0) self.assertGreater(result["series"]["mass_1.x"][-1], 0.0)
def test_force_node_with_two_right_ports_ignores_reserved_catalog_ports(self) -> None:
project = force_node_mass_project(2)
xml = build_reactflow_system_xml(project)
report = validate_system_xml_document(xml)
self.assertTrue(report.valid, report.as_dict())
network = compile_reactflow_network(project)
self.assertTrue(network.pressure_flow_structure_dict()["isSquare"])
result = run_system_xml_simulation(xml)
self.assertTrue(result["success"], result["message"])
self.assertAlmostEqual(result["series"]["mass_1.a"][0], 5.0)
def test_elastic_contact_project_compiles_and_simulates(self) -> None: def test_elastic_contact_project_compiles_and_simulates(self) -> None:
xml = build_reactflow_system_xml(elastic_contact_project()) xml = build_reactflow_system_xml(elastic_contact_project())
report = validate_system_xml_document(xml) report = validate_system_xml_document(xml)
+1
View File
@@ -24,6 +24,7 @@ def _pnpl_node(index: int) -> object:
def amesim_pnch012_project() -> ReactFlowProjectPayload: def amesim_pnch012_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnch012-smoke", name="amesim-pnch012-smoke",
nodes=[ nodes=[
component_node( component_node(
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pnch023_project() -> ReactFlowProjectPayload: def amesim_pnch023_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnch023-smoke", name="amesim-pnch023-smoke",
nodes=[ nodes=[
component_node( component_node(
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pn3node_project() -> ReactFlowProjectPayload: def amesim_pn3node_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pn3node-smoke", name="amesim-pn3node-smoke",
nodes=[ nodes=[
component_node( component_node(
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pnl0001_project() -> ReactFlowProjectPayload: def amesim_pnl0001_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnl0001-smoke", name="amesim-pnl0001-smoke",
nodes=[ nodes=[
component_node( component_node(
+2
View File
@@ -59,6 +59,7 @@ def _resistance_node(name: str) -> object:
def amesim_pnl0002_project() -> ReactFlowProjectPayload: def amesim_pnl0002_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnl0002-smoke", name="amesim-pnl0002-smoke",
nodes=[ nodes=[
_storage_nodes()[0], _storage_nodes()[0],
@@ -89,6 +90,7 @@ def amesim_pnl0003_project() -> ReactFlowProjectPayload:
} }
params.update({"p1_0": 300000.0, "T1_0": 300.0, "p2_0": 300000.0, "T2_0": 300.0}) params.update({"p1_0": 300000.0, "T1_0": 300.0, "p2_0": 300000.0, "T2_0": 300.0})
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnl0003-smoke", name="amesim-pnl0003-smoke",
nodes=[ nodes=[
_storage_nodes(source_pressure=300000.0, sink_pressure=300000.0)[0], _storage_nodes(source_pressure=300000.0, sink_pressure=300000.0)[0],
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pnl00r_project() -> ReactFlowProjectPayload: def amesim_pnl00r_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnl00r-smoke", name="amesim-pnl00r-smoke",
nodes=[ nodes=[
component_node( component_node(
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pnor001_project() -> ReactFlowProjectPayload: def amesim_pnor001_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnor001-smoke", name="amesim-pnor001-smoke",
nodes=[ nodes=[
component_node( component_node(
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pnpl01_project() -> ReactFlowProjectPayload: def amesim_pnpl01_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnpl01-smoke", name="amesim-pnpl01-smoke",
nodes=[ nodes=[
component_node( component_node(
+1 -1
View File
@@ -45,8 +45,8 @@ def _mass_node(name: str) -> dict[str, object]:
def pnrp17_coupled_project() -> ReactFlowProjectPayload: def pnrp17_coupled_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnrp17-coupled-smoke", name="amesim-pnrp17-coupled-smoke",
amesimParameterEncodingVersion=1,
nodes=[ nodes=[
component_node( component_node(
"chamber_1", "chamber_1",
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pnvo001_fixed_project() -> ReactFlowProjectPayload: def amesim_pnvo001_fixed_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnvo001-fixed-smoke", name="amesim-pnvo001-fixed-smoke",
nodes=[ nodes=[
component_node( component_node(
+2
View File
@@ -36,6 +36,7 @@ def signal_edge(edge_id: str, source: str, source_port: str, target: str, target
def amesim_pnvo001_signal_project() -> ReactFlowProjectPayload: def amesim_pnvo001_signal_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnvo001-signal-smoke", name="amesim-pnvo001-signal-smoke",
nodes=[ nodes=[
component_node( component_node(
@@ -90,6 +91,7 @@ def high_pressure_helium_step_project() -> ReactFlowProjectPayload:
physical_port("port_2", "bidirectional", "right"), physical_port("port_2", "bidirectional", "right"),
] ]
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnvo001-helium-step-regression", name="amesim-pnvo001-helium-step-regression",
nodes=[ nodes=[
component_node( component_node(
+26
View File
@@ -116,6 +116,32 @@ class AmesimSignalComponentTests(unittest.TestCase):
self.assertAlmostEqual(valve.opening, 0.75) self.assertAlmostEqual(valve.opening, 0.75)
self.assertEqual(resolver.event_times(0.0, 0.2), (0.1,)) self.assertEqual(resolver.event_times(0.0, 0.2), (0.1,))
def test_signal_output_can_fan_out_but_input_has_one_driver(self) -> None:
network = SimulationNetwork("signal-cardinality")
step_1 = AmesimStep0(
"step_1", self.medium, initial=0.0, final=0.75, time=0.1
)
step_2 = AmesimStep0(
"step_2", self.medium, initial=1.0, final=0.25, time=0.1
)
valve_1 = AmesimPnvo001SignalOpening(
"valve_1", self.medium, opening0=0.0
)
valve_2 = AmesimPnvo001SignalOpening(
"valve_2", self.medium, opening0=0.0
)
for component in (step_1, step_2, valve_1, valve_2):
network.add_component(component)
network.connect("step_1", "out", "valve_1", "res")
network.connect("step_1", "out", "valve_2", "res")
with self.assertRaisesRegex(ValueError, "already has a driver"):
network.connect("step_2", "out", "valve_1", "res")
SignalResolver(network).solve(0.2)
self.assertAlmostEqual(valve_1.res.signal, 0.75)
self.assertAlmostEqual(valve_2.res.signal, 0.75)
def test_signal_resolver_event_times_are_sorted_and_deduplicated(self) -> None: def test_signal_resolver_event_times_are_sorted_and_deduplicated(self) -> None:
network = SimulationNetwork("signal-events") network = SimulationNetwork("signal-events")
network.add_component( network.add_component(
+1
View File
@@ -16,6 +16,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_ud00_signal_project() -> ReactFlowProjectPayload: def amesim_ud00_signal_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-ud00-signal-smoke", name="amesim-ud00-signal-smoke",
nodes=[ nodes=[
component_node( component_node(
+10 -2
View File
@@ -208,6 +208,8 @@ class ComponentCatalogTests(unittest.TestCase):
self.assertNotIn("parameterGroups", components["amesim_f000"]) self.assertNotIn("parameterGroups", components["amesim_f000"])
self.assertEqual(mecmas_parameters["mass"]["unit"], "kg") self.assertEqual(mecmas_parameters["mass"]["unit"], "kg")
self.assertEqual(mecmas_parameters["Kbmin"]["unit"], "N/m") self.assertEqual(mecmas_parameters["Kbmin"]["unit"], "N/m")
self.assertEqual(mecmas_parameters["useFriction"]["default"], 2.0)
self.assertEqual(mecmas_parameters["stoptype"]["default"], 1.0)
self.assertEqual(mecmas_parameters["theta"]["label"], "倾角(度)") self.assertEqual(mecmas_parameters["theta"]["label"], "倾角(度)")
self.assertIn("+90°", mecmas_parameters["theta"]["description"]) self.assertIn("+90°", mecmas_parameters["theta"]["description"])
self.assertIn("-90°", mecmas_parameters["theta"]["description"]) self.assertIn("-90°", mecmas_parameters["theta"]["description"])
@@ -364,7 +366,7 @@ class ComponentCatalogTests(unittest.TestCase):
for parameter in components["amesim_lmechn1"]["parameters"] for parameter in components["amesim_lmechn1"]["parameters"]
} }
self.assertEqual(components["amesim_lmechn1"]["category"]["id"], "mechanical") self.assertEqual(components["amesim_lmechn1"]["category"]["id"], "mechanical")
self.assertEqual([port["name"] for port in components["amesim_lmechn1"]["ports"]], [f"port_{index}" for index in range(1, 10)]) self.assertEqual([port["name"] for port in components["amesim_lmechn1"]["ports"]], [f"port_{index}" for index in range(1, 22)])
pnrp_parameters = { pnrp_parameters = {
parameter["name"]: parameter parameter["name"]: parameter
for parameter in components["amesim_pnrp17"]["parameters"] for parameter in components["amesim_pnrp17"]["parameters"]
@@ -372,7 +374,13 @@ class ComponentCatalogTests(unittest.TestCase):
self.assertEqual(components["amesim_pnrp17"]["category"]["id"], "mechanical") self.assertEqual(components["amesim_pnrp17"]["category"]["id"], "mechanical")
self.assertEqual([port["name"] for port in components["amesim_pnrp17"]["ports"]], ["port_1", "port_3", "port_2", "port_4", "port_5"]) self.assertEqual([port["name"] for port in components["amesim_pnrp17"]["ports"]], ["port_1", "port_3", "port_2", "port_4", "port_5"])
self.assertEqual(pnrp_parameters["dp"]["unit"], "m") self.assertEqual(pnrp_parameters["dp"]["unit"], "m")
self.assertEqual(lmechn_parameters["v1"]["maximum"], 8.0) self.assertEqual(lmechn_parameters["v1"]["maximum"], 20.0)
self.assertEqual(lmechn_parameters["v1"]["default"], 2.0)
self.assertEqual(lmechn_parameters["sum"]["editor"], "choice")
self.assertEqual(
lmechn_parameters["sum"]["options"],
[{"value": 1.0, "label": "各端口力代数和为零(标准节点)"}],
)
self.assertEqual(components["amesim_pnvo001"]["category"]["id"], "flow") self.assertEqual(components["amesim_pnvo001"]["category"]["id"], "flow")
self.assertEqual([port["name"] for port in components["amesim_pnvo001"]["ports"]], ["res", "port_2", "port_3"]) self.assertEqual([port["name"] for port in components["amesim_pnvo001"]["ports"]], ["res", "port_2", "port_3"])
+5 -1
View File
@@ -24,6 +24,7 @@ from app.main import (
simulate_system_xml, simulate_system_xml,
) )
from app.simulation.components.experimental.flow.resistive_pipe import ResistivePipe from app.simulation.components.experimental.flow.resistive_pipe import ResistivePipe
from app.simulation.registry import get_component_model_spec
from app.simulation.solvers.solver import SolveIVPConfig from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import ( from app.simulation.systems.generic import (
GenericFluidSystem, GenericFluidSystem,
@@ -46,6 +47,7 @@ def component_node(
"label": component_id, "label": component_id,
"componentType": model_type, "componentType": model_type,
"modelType": model_type, "modelType": model_type,
"modelVersion": get_component_model_spec(model_type).model_version,
"ports": ports, "ports": ports,
"parameters": parameters or {}, "parameters": parameters or {},
}, },
@@ -101,6 +103,7 @@ def chain_project(*, reverse_edges: bool = False) -> ReactFlowProjectPayload:
for edge in edges for edge in edges
] ]
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="generic-chain", name="generic-chain",
nodes=[ nodes=[
component_node( component_node(
@@ -147,6 +150,7 @@ def chain_project(*, reverse_edges: bool = False) -> ReactFlowProjectPayload:
def branched_project() -> ReactFlowProjectPayload: def branched_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="generic-branch", name="generic-branch",
nodes=[ nodes=[
component_node( component_node(
@@ -536,7 +540,7 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
def test_streaming_endpoint_returns_structured_validation_error(self) -> None: def test_streaming_endpoint_returns_structured_validation_error(self) -> None:
events = [ events = [
json.loads(line) json.loads(line)
for line in simulation_event_stream(b"<System schemaVersion='2'>") for line in simulation_event_stream(b"<System schemaVersion='3'>")
] ]
self.assertEqual(events[-1]["event"], "error") self.assertEqual(events[-1]["event"], "error")
+55 -104
View File
@@ -129,35 +129,25 @@ CONNECTED_GAS_REFERENCE_SPEC = ComponentModelSpec(
def system_xml( def system_xml(
*, *,
gi: float | None, gi: float | None,
medium_reference_version: str | None = None,
medium_gas_indices: tuple[float, ...] = (), medium_gas_indices: tuple[float, ...] = (),
) -> bytes: ) -> bytes:
version_attribute = (
f' mediumReferenceVersion="{medium_reference_version}"'
if medium_reference_version is not None
else ""
)
reference_parameter = ( reference_parameter = (
f'<Parameter name="gi" value="{gi}"/>' if gi is not None else "" f'<Parameter name="gi" value="{gi}"/>' if gi is not None else ""
) )
medium_components = "".join( medium_components = "".join(
f""" f"""
<Component id="medium_{index}" name="medium_{index}" <Component id="medium_{index}" type="gas_medium_definition_fixture"
type="gas_medium_definition_fixture" modelVersion="1.0.0">
componentType="gas_medium_definition_fixture"
x="0" y="0">
<Parameter name="gi" value="{gas_index}"/> <Parameter name="gi" value="{gas_index}"/>
</Component>""" </Component>"""
for index, gas_index in enumerate(medium_gas_indices, start=1) for index, gas_index in enumerate(medium_gas_indices, start=1)
) )
return f"""<?xml version="1.0" encoding="UTF-8"?> return f"""<?xml version="1.0" encoding="UTF-8"?>
<System name="medium-contract" schemaVersion="2" unitSystem="SI"{version_attribute}> <System name="medium-contract" schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="1" step="0.1" maxStep="0.01" method="BDF"/> <Simulation tStart="0" tStop="1" sampleStep="0.1" maxStep="0.01" method="BDF"/>
<Components> <Components>
<Component id="consumer_1" name="consumer_1" <Component id="consumer_1" type="gas_reference_fixture"
type="gas_reference_fixture" modelVersion="1.0.0">
componentType="gas_reference_fixture"
x="0" y="0">
{reference_parameter} {reference_parameter}
</Component>{medium_components} </Component>{medium_components}
</Components> </Components>
@@ -174,40 +164,27 @@ def connected_system_xml(
) -> bytes: ) -> bytes:
medium_components = "".join( medium_components = "".join(
f""" f"""
<Component id="medium_{index}" name="medium_{index}" <Component id="medium_{index}" type="gas_medium_definition_fixture"
type="gas_medium_definition_fixture" modelVersion="1.0.0">
componentType="gas_medium_definition_fixture"
x="0" y="0">
<Parameter name="gi" value="{gas_index}"/> <Parameter name="gi" value="{gas_index}"/>
</Component>""" </Component>"""
for index, gas_index in enumerate(medium_gas_indices, start=1) for index, gas_index in enumerate(medium_gas_indices, start=1)
) )
return f"""<?xml version="1.0" encoding="UTF-8"?> return f"""<?xml version="1.0" encoding="UTF-8"?>
<System name="connected-medium-contract" schemaVersion="2" unitSystem="SI" <System name="connected-medium-contract" schemaVersion="3" unitSystem="SI">
mediumReferenceVersion="1"> <Simulation tStart="0" tStop="1" sampleStep="0.1" maxStep="0.01" method="BDF"/>
<Simulation tStart="0" tStop="1" step="0.1" maxStep="0.01" method="BDF"/>
<Components> <Components>
<Component id="consumer_1" name="consumer_1" <Component id="consumer_1" type="connected_gas_reference_fixture"
type="connected_gas_reference_fixture" modelVersion="1.0.0">
componentType="connected_gas_reference_fixture"
x="0" y="0">
<Port name="port_1" kind="physical" domain="pneumatic"
nominalRole="bidirectional" positiveFlowDirection="intoComponent"
side="right"/>
<Parameter name="gi" value="{first_gi}"/> <Parameter name="gi" value="{first_gi}"/>
</Component> </Component>
<Component id="consumer_2" name="consumer_2" <Component id="consumer_2" type="connected_gas_reference_fixture"
type="connected_gas_reference_fixture" modelVersion="1.0.0">
componentType="connected_gas_reference_fixture"
x="100" y="0">
<Port name="port_1" kind="physical" domain="pneumatic"
nominalRole="bidirectional" positiveFlowDirection="intoComponent"
side="left"/>
<Parameter name="gi" value="{second_gi}"/> <Parameter name="gi" value="{second_gi}"/>
</Component>{medium_components} </Component>{medium_components}
</Components> </Components>
<Connections> <Connections>
<Connection id="gas_connection" kind="physical" domain="pneumatic"> <Connection id="gas_connection">
<Endpoint component="consumer_1" port="port_1"/> <Endpoint component="consumer_1" port="port_1"/>
<Endpoint component="consumer_2" port="port_1"/> <Endpoint component="consumer_2" port="port_1"/>
</Connection> </Connection>
@@ -223,14 +200,11 @@ def air_medium_system_xml(*, property_model: float | None) -> bytes:
else "" else ""
) )
return f"""<?xml version="1.0" encoding="UTF-8"?> return f"""<?xml version="1.0" encoding="UTF-8"?>
<System name="air-medium-contract" schemaVersion="2" unitSystem="SI" <System name="air-medium-contract" schemaVersion="3" unitSystem="SI">
mediumReferenceVersion="1"> <Simulation tStart="0" tStop="1" sampleStep="0.1" maxStep="0.01" method="BDF"/>
<Simulation tStart="0" tStop="1" step="0.1" maxStep="0.01" method="BDF"/>
<Components> <Components>
<Component id="air_1" name="air_1" <Component id="air_1" type="amesim_ideal_air_medium"
type="amesim_ideal_air_medium" modelVersion="0.2.0">
componentType="amesim_ideal_air_medium"
x="0" y="0">
<Parameter name="gi" value="1"/> <Parameter name="gi" value="1"/>
{property_parameter} {property_parameter}
</Component> </Component>
@@ -294,57 +268,41 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
self.registry_patch.start() self.registry_patch.start()
self.addCleanup(self.registry_patch.stop) self.addCleanup(self.registry_patch.stop)
def test_legacy_gi_one_without_definition_maps_to_builtin_air(self) -> None: def test_positive_reference_without_definition_is_not_reinterpreted(self) -> None:
report = validate_system_xml_document(system_xml(gi=1.0)) report = validate_system_xml_document(system_xml(gi=1.0))
self.assertTrue(report.valid)
self.assertEqual(
{issue.code for issue in report.issues},
{"AMESIM_GI_LEGACY_AIR_MAPPED"},
)
assert report.document is not None
self.assertEqual(report.document.medium_reference_version, "1")
self.assertEqual(
report.document.as_project_data()["mediumReferenceVersion"],
1,
)
self.assertEqual(
report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"],
0.0,
)
def test_legacy_missing_gi_injects_builtin_air(self) -> None:
report = validate_system_xml_document(system_xml(gi=None))
self.assertTrue(report.valid)
self.assertEqual(
{issue.code for issue in report.issues},
{"AMESIM_GI_DEFAULT_INJECTED"},
)
assert report.document is not None
self.assertEqual(
report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"],
0.0,
)
def test_versioned_gi_one_is_not_reinterpreted(self) -> None:
report = validate_system_xml_document(
system_xml(gi=1.0, medium_reference_version="1")
)
self.assertFalse(report.valid) self.assertFalse(report.valid)
self.assertEqual( self.assertEqual(
{issue.code for issue in report.issues}, {issue.code for issue in report.issues},
{"AMESIM_GAS_REFERENCE_UNDEFINED"}, {"AMESIM_GAS_REFERENCE_UNDEFINED"},
) )
assert report.document is not None assert report.document is not None
self.assertEqual(report.document.medium_reference_version, "1")
self.assertEqual( self.assertEqual(
report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"], report.document.as_model_data()["components"][0]["parameters"]["gi"],
1.0, 1.0,
) )
def test_legacy_document_with_definition_is_not_reinterpreted(self) -> None: def test_missing_gi_is_not_defaulted(self) -> None:
report = validate_system_xml_document(system_xml(gi=None))
self.assertFalse(report.valid)
self.assertEqual(
{issue.code for issue in report.issues},
{"PARAMETER_REQUIRED_MISSING"},
)
def test_builtin_air_reference_zero_is_valid_canonical_data(self) -> None:
report = validate_system_xml_document(system_xml(gi=0.0))
self.assertTrue(report.valid)
self.assertEqual(report.issues, ())
assert report.document is not None
self.assertEqual(
report.document.as_model_data()["components"][0]["parameters"]["gi"],
0.0,
)
def test_positive_reference_with_definition_is_preserved(self) -> None:
report = validate_system_xml_document( report = validate_system_xml_document(
system_xml(gi=1.0, medium_gas_indices=(1.0,)) system_xml(gi=1.0, medium_gas_indices=(1.0,))
) )
@@ -353,15 +311,14 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
self.assertEqual(report.issues, ()) self.assertEqual(report.issues, ())
assert report.document is not None assert report.document is not None
self.assertEqual( self.assertEqual(
report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"], report.document.as_model_data()["components"][0]["parameters"]["gi"],
1.0, 1.0,
) )
def test_versioned_positive_reference_requires_matching_definition(self) -> None: def test_positive_reference_accepts_matching_definition(self) -> None:
report = validate_system_xml_document( report = validate_system_xml_document(
system_xml( system_xml(
gi=2.0, gi=2.0,
medium_reference_version="1",
medium_gas_indices=(2.0,), medium_gas_indices=(2.0,),
) )
) )
@@ -369,22 +326,16 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
self.assertTrue(report.valid) self.assertTrue(report.valid)
self.assertEqual(report.issues, ()) self.assertEqual(report.issues, ())
def test_missing_property_model_is_defaulted_for_versioned_medium(self) -> None: def test_missing_property_model_is_not_defaulted(self) -> None:
report = validate_system_xml_document( report = validate_system_xml_document(
air_medium_system_xml(property_model=None) air_medium_system_xml(property_model=None)
) )
self.assertTrue(report.valid) self.assertFalse(report.valid)
self.assertEqual( self.assertEqual(
{issue.code for issue in report.issues}, {issue.code for issue in report.issues},
{"AMESIM_GAS_PROPERTY_MODEL_DEFAULTED"}, {"PARAMETER_REQUIRED_MISSING"},
) )
assert report.document is not None
parameters = report.document.as_project_data()["nodes"][0]["data"][
"parameters"
]
self.assertEqual(parameters["gi"], 1.0)
self.assertEqual(parameters["property_model"], 0.0)
def test_explicit_supported_property_model_round_trips_without_warning(self) -> None: def test_explicit_supported_property_model_round_trips_without_warning(self) -> None:
report = validate_system_xml_document( report = validate_system_xml_document(
@@ -411,14 +362,12 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
fractional = validate_system_xml_document( fractional = validate_system_xml_document(
system_xml( system_xml(
gi=0.0, gi=0.0,
medium_reference_version="1",
medium_gas_indices=(1.5,), medium_gas_indices=(1.5,),
) )
) )
zero = validate_system_xml_document( zero = validate_system_xml_document(
system_xml( system_xml(
gi=0.0, gi=0.0,
medium_reference_version="1",
medium_gas_indices=(0.0,), medium_gas_indices=(0.0,),
) )
) )
@@ -436,7 +385,6 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
report = validate_system_xml_document( report = validate_system_xml_document(
system_xml( system_xml(
gi=1.0, gi=1.0,
medium_reference_version="1",
medium_gas_indices=(1.0, 1.0), medium_gas_indices=(1.0, 1.0),
) )
) )
@@ -449,10 +397,10 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
def test_gas_reference_index_must_be_integer_in_range(self) -> None: def test_gas_reference_index_must_be_integer_in_range(self) -> None:
fractional = validate_system_xml_document( fractional = validate_system_xml_document(
system_xml(gi=1.5, medium_reference_version="1") system_xml(gi=1.5)
) )
out_of_range = validate_system_xml_document( out_of_range = validate_system_xml_document(
system_xml(gi=100.0, medium_reference_version="1") system_xml(gi=100.0)
) )
self.assertIn( self.assertIn(
@@ -491,10 +439,13 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
self.assertTrue(report.valid) self.assertTrue(report.valid)
self.assertEqual(report.issues, ()) self.assertEqual(report.issues, ())
def test_unknown_medium_reference_version_is_rejected_by_xsd(self) -> None: def test_removed_medium_reference_version_is_rejected_by_xsd(self) -> None:
report = validate_system_xml_document( removed_marker_xml = system_xml(gi=0.0).replace(
system_xml(gi=0.0, medium_reference_version="2") b'<System name="medium-contract"',
b'<System name="medium-contract" mediumReferenceVersion="1"',
1,
) )
report = validate_system_xml_document(removed_marker_xml)
self.assertFalse(report.valid) self.assertFalse(report.valid)
self.assertIsNone(report.document) self.assertIsNone(report.document)
@@ -1,104 +0,0 @@
from __future__ import annotations
import unittest
from app.main import (
ReactFlowProjectPayload,
build_reactflow_system_xml,
pydantic_to_jsonable,
)
class ReactFlowProjectMediumReferenceVersionTests(unittest.TestCase):
def test_version_one_is_accepted_and_serialized(self) -> None:
project = ReactFlowProjectPayload(
name="versioned-medium-references",
mediumReferenceVersion=1,
)
self.assertEqual(project.mediumReferenceVersion, 1)
self.assertEqual(
pydantic_to_jsonable(project)["mediumReferenceVersion"],
1,
)
def test_other_medium_reference_versions_are_rejected(self) -> None:
for version in (0, 2, -1, "1"):
with self.subTest(version=version):
with self.assertRaises(ValueError):
ReactFlowProjectPayload(
name="unsupported-medium-references",
mediumReferenceVersion=version,
)
def test_legacy_project_without_version_remains_compatible(self) -> None:
project = ReactFlowProjectPayload(name="legacy-project")
self.assertIsNone(project.mediumReferenceVersion)
self.assertIsNone(project.amesimParameterEncodingVersion)
self.assertNotIn(
"amesimParameterEncodingVersion",
pydantic_to_jsonable(project),
)
def test_xml_export_adds_default_property_model_to_legacy_medium_node(self) -> None:
project = ReactFlowProjectPayload(
name="legacy-medium-node",
mediumReferenceVersion=1,
nodes=[
{
"id": "air_1",
"position": {"x": 10, "y": 20},
"data": {
"label": "Air 1",
"componentType": "amesim_ideal_air_medium",
"modelType": "amesim_ideal_air_medium",
"ports": [],
"parameters": {"gi": 1},
},
}
],
)
xml = build_reactflow_system_xml(project).decode("utf-8")
self.assertIn('<Parameter name="gi" value="1"', xml)
self.assertIn('<Parameter name="property_model" value="0.0"', xml)
class ReactFlowPresentationLayoutVersionTests(unittest.TestCase):
def test_layout_version_is_preserved_but_not_written_to_xml(self) -> None:
project = ReactFlowProjectPayload(
name="versioned-presentation-layout",
presentationLayoutVersion=1,
)
self.assertEqual(project.presentationLayoutVersion, 1)
self.assertEqual(
pydantic_to_jsonable(project)["presentationLayoutVersion"],
1,
)
xml = build_reactflow_system_xml(project).decode("utf-8")
self.assertNotIn("presentationLayoutVersion", xml)
def test_layout_version_rejects_unsupported_values(self) -> None:
for version in (0, 2, -1, "1"):
with self.subTest(version=version):
with self.assertRaises(ValueError):
ReactFlowProjectPayload(
name="unsupported-presentation-layout",
presentationLayoutVersion=version,
)
def test_legacy_layout_version_remains_missing(self) -> None:
project = ReactFlowProjectPayload(name="legacy-presentation-layout")
self.assertIsNone(project.presentationLayoutVersion)
self.assertNotIn(
"presentationLayoutVersion",
pydantic_to_jsonable(project),
)
if __name__ == "__main__":
unittest.main()
+227
View File
@@ -0,0 +1,227 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from xml.etree import ElementTree as ET
from fastapi import HTTPException
from app.main import (
ReactFlowParameterScientificNotation,
ReactFlowProjectPayload,
build_reactflow_system_xml,
compile_reactflow_network,
load_reactflow_project,
reactflow_project_storage_data,
save_reactflow_project,
)
def medium_project(**root_overrides: object) -> ReactFlowProjectPayload:
payload: dict[str, object] = {
"projectSchemaVersion": 1,
"name": "current-medium-project",
"nodes": [
{
"id": "air_1",
"position": {"x": 10, "y": 20},
"data": {
"label": "Air 1",
"componentType": "amesim_ideal_air_medium",
"modelType": "amesim_ideal_air_medium",
"modelVersion": "0.2.0",
"ports": [],
"parameters": {"gi": 1},
},
}
],
}
payload.update(root_overrides)
return ReactFlowProjectPayload(**payload)
class ReactFlowProjectSchemaTests(unittest.TestCase):
def test_current_project_version_is_accepted(self) -> None:
project = medium_project()
self.assertEqual(project.projectSchemaVersion, 1)
self.assertEqual(
project.model_dump(mode="json")["projectSchemaVersion"],
1,
)
self.assertEqual(project.nodes[0].data.modelVersion, "0.2.0")
def test_missing_node_model_version_can_be_loaded_for_inspection(self) -> None:
project = medium_project()
project.nodes[0].data.modelVersion = None
reloaded = ReactFlowProjectPayload.model_validate(
project.model_dump(mode="json", exclude_none=True)
)
self.assertIsNone(reloaded.nodes[0].data.modelVersion)
def test_execution_rejects_missing_or_mismatched_node_model_version(self) -> None:
missing = medium_project()
missing.nodes[0].data.modelVersion = None
for execute in (build_reactflow_system_xml, compile_reactflow_network):
with self.subTest(case="missing", execute=execute.__name__):
with self.assertRaisesRegex(
ValueError,
"COMPONENT_MODEL_VERSION_MISSING",
):
execute(missing)
mismatch = medium_project()
mismatch.nodes[0].data.modelVersion = "0.1.0"
for execute in (build_reactflow_system_xml, compile_reactflow_network):
with self.subTest(case="mismatch", execute=execute.__name__):
with self.assertRaisesRegex(
ValueError,
"COMPONENT_MODEL_VERSION_MISMATCH",
):
execute(mismatch)
def test_execution_rejects_divergent_component_and_model_types(self) -> None:
project = medium_project()
project.nodes[0].data.componentType = "amesim_helium_medium"
for execute in (build_reactflow_system_xml, compile_reactflow_network):
with self.subTest(execute=execute.__name__):
with self.assertRaisesRegex(
ValueError,
"COMPONENT_MODEL_TYPE_MISMATCH",
):
execute(project)
def test_unsupported_project_version_is_rejected(self) -> None:
for version in (0, 2, -1, "1"):
with self.subTest(version=version):
with self.assertRaises(ValueError):
medium_project(projectSchemaVersion=version)
def test_missing_project_version_is_rejected(self) -> None:
with self.assertRaises(ValueError):
ReactFlowProjectPayload(name="missing-project-version")
def test_removed_compatibility_markers_are_rejected(self) -> None:
for field in (
"mediumReferenceVersion",
"amesimParameterEncodingVersion",
"presentationLayoutVersion",
):
with self.subTest(field=field):
with self.assertRaises(ValueError):
medium_project(**{field: 1})
def test_string_port_definition_is_rejected(self) -> None:
with self.assertRaises(ValueError):
ReactFlowProjectPayload(
projectSchemaVersion=1,
name="invalid-string-port",
nodes=[
{
"id": "tank_1",
"data": {
"componentType": "tank",
"modelType": "tank",
"ports": ["port_a"],
},
}
],
)
def test_xml_export_fills_registered_medium_defaults(self) -> None:
root = ET.fromstring(build_reactflow_system_xml(medium_project()))
parameters = {
parameter.get("name"): float(str(parameter.get("value")))
for parameter in root.findall("./Components/Component/Parameter")
}
self.assertEqual(parameters["gi"], 1.0)
self.assertEqual(parameters["property_model"], 0.0)
self.assertIsNone(root.get("projectSchemaVersion"))
def test_storage_preserves_parameter_display_metadata(self) -> None:
project = medium_project()
project.nodes[0].data.parameterUnits = {
"gi": "",
"property_model": "",
}
project.nodes[0].data.parameterScientificNotation = {
"gi": ReactFlowParameterScientificNotation(text="1e0", unit="")
}
data = reactflow_project_storage_data(project)
stored_node = data["nodes"][0]["data"]
self.assertEqual(stored_node["modelVersion"], "0.2.0")
self.assertEqual(stored_node["parameterUnits"], project.nodes[0].data.parameterUnits)
self.assertEqual(
stored_node["parameterScientificNotation"],
{"gi": {"text": "1e0", "unit": ""}},
)
def test_project_storage_round_trip_preserves_current_contract(self) -> None:
project = medium_project()
project.nodes[0].data.parameterUnits = {"gi": ""}
project.nodes[0].data.parameterScientificNotation = {
"gi": ReactFlowParameterScientificNotation(text="1e0", unit="")
}
project = ReactFlowProjectPayload.model_validate(
{
**project.model_dump(mode="json"),
"edges": [
{
"id": "contact-edge",
"source": "air_1",
"target": "air_1",
"sourceHandle": "definition",
"targetHandle": "definition",
"data": {
"isContactEdge": True,
"futureDisplayMetadata": "preserved",
},
}
],
}
)
with tempfile.TemporaryDirectory() as directory:
storage = Path(directory)
with patch("app.main.PROJECT_STORAGE_DIR", storage):
result = save_reactflow_project("medium-project", project)
loaded = load_reactflow_project("medium-project")
self.assertEqual(result["id"], "medium-project")
self.assertEqual(loaded, reactflow_project_storage_data(project))
self.assertTrue(loaded["edges"][0]["data"]["isContactEdge"])
self.assertEqual(
loaded["edges"][0]["data"]["futureDisplayMetadata"],
"preserved",
)
def test_project_load_rejects_corrupt_or_unsupported_data(self) -> None:
with tempfile.TemporaryDirectory() as directory:
storage = Path(directory)
invalid_cases = {
"corrupt": "{not-json",
"future": json.dumps({"projectSchemaVersion": 2}),
}
with patch("app.main.PROJECT_STORAGE_DIR", storage):
for project_id, text in invalid_cases.items():
with self.subTest(project_id=project_id):
(storage / f"{project_id}.json").write_text(
text,
encoding="utf-8",
)
with self.assertRaises(HTTPException) as context:
load_reactflow_project(project_id)
self.assertEqual(context.exception.status_code, 422)
if __name__ == "__main__":
unittest.main()
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import unittest
from app.simulation.components.amesim.mechanical.translational import (
AmesimF000,
AmesimForc,
AmesimMecmas21,
)
from app.simulation.components.amesim.signals.sources import AmesimStep0
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import (
SimulationSampleTimeError,
simulation_preparation_issues,
simulation_sample_times,
)
from app.simulation.systems.network import SimulationNetwork
from app.system_xml import validate_system_xml_document
def _system_xml(
*,
t_start: str,
t_stop: str,
sample_step: str,
) -> str:
return f"""<System schemaVersion="3" unitSystem="SI">
<Simulation
tStart="{t_start}"
tStop="{t_stop}"
sampleStep="{sample_step}"
maxStep="0.01"
method="BDF"
/>
<Components>
<Component id="cylinder_1" type="cylinder" modelVersion="1.0.0">
<Parameter name="volume" value="0.01"/>
<Parameter name="p0" value="35000000"/>
<Parameter name="T0" value="300"/>
</Component>
</Components>
<Connections/>
</System>"""
def _fanout_mechanical_network(
*,
second_island_has_storage: bool,
) -> SimulationNetwork:
medium = IdealGasMedium()
network = SimulationNetwork("signal-fanout-physical-islands")
command = AmesimStep0(
"command",
medium,
initial=0.0,
final=1.0,
time=0.0,
)
force_a = AmesimForc("force_a")
mass_a = AmesimMecmas21("mass_a", medium)
zero_a = AmesimF000("zero_a")
force_b = AmesimForc("force_b")
zero_b = AmesimF000("zero_b")
components = [command, force_a, mass_a, zero_a, force_b, zero_b]
mass_b = None
if second_island_has_storage:
mass_b = AmesimMecmas21("mass_b", medium)
components.append(mass_b)
for component in components:
network.add_component(component)
network.connect("command", "out", "force_a", "res")
network.connect("command", "out", "force_b", "res")
network.connect("force_a", "port_2", "mass_a", "port_1")
network.connect("mass_a", "port_2", "zero_a", "port_1")
if mass_b is None:
network.connect("force_b", "port_2", "zero_b", "port_1")
else:
network.connect("force_b", "port_2", "mass_b", "port_1")
network.connect("mass_b", "port_2", "zero_b", "port_1")
return network
class SimulationSampleTimeSafetyTests(unittest.TestCase):
def test_grid_contains_both_endpoints_and_is_strictly_increasing(self) -> None:
times = simulation_sample_times(
SolveIVPConfig(t_start=1.0, t_stop=2.0),
0.3,
)
self.assertEqual(times[0], 1.0)
self.assertEqual(times[-1], 2.0)
self.assertGreaterEqual(len(times), 2)
self.assertTrue(
all(first < second for first, second in zip(times, times[1:]))
)
def test_step_larger_than_duration_still_returns_two_endpoints(self) -> None:
self.assertEqual(
simulation_sample_times(
SolveIVPConfig(t_start=1.0, t_stop=1.25),
10.0,
),
[1.0, 1.25],
)
def test_exact_point_limit_is_allowed(self) -> None:
times = simulation_sample_times(
SolveIVPConfig(t_start=0.0, t_stop=1.0),
0.0001,
)
self.assertEqual(len(times), 10001)
self.assertEqual(times[0], 0.0)
self.assertEqual(times[-1], 1.0)
def test_tiny_step_is_rejected_before_an_oversized_grid_is_allocated(self) -> None:
with self.assertRaises(SimulationSampleTimeError) as caught:
simulation_sample_times(
SolveIVPConfig(t_start=0.0, t_stop=1.0),
1.0e-300,
)
self.assertEqual(caught.exception.code, "SIMULATION_SAMPLE_COUNT_EXCEEDED")
def test_non_finite_derived_duration_has_a_stable_error(self) -> None:
with self.assertRaises(SimulationSampleTimeError) as caught:
simulation_sample_times(
SolveIVPConfig(t_start=-1.0e308, t_stop=1.0e308),
1.0e308,
)
self.assertEqual(caught.exception.code, "SIMULATION_TIME_SPAN_NOT_FINITE")
def test_step_that_cannot_advance_absolute_time_is_rejected(self) -> None:
with self.assertRaises(SimulationSampleTimeError) as caught:
simulation_sample_times(
SolveIVPConfig(t_start=1.0e16, t_stop=1.0e16 + 4.0),
1.0,
)
self.assertEqual(
caught.exception.code,
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
)
def test_system_xml_reports_sample_grid_failures_during_semantic_validation(
self,
) -> None:
cases = (
(
_system_xml(t_start="0", t_stop="1", sample_step="1e-300"),
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
),
(
_system_xml(
t_start="-1e308",
t_stop="1e308",
sample_step="1e308",
),
"SIMULATION_TIME_SPAN_NOT_FINITE",
),
(
_system_xml(
t_start="10000000000000000",
t_stop="10000000000000004",
sample_step="1",
),
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
),
)
for xml, expected_code in cases:
with self.subTest(expected_code=expected_code):
report = validate_system_xml_document(xml)
self.assertFalse(report.valid)
self.assertIn(
expected_code,
{issue.code for issue in report.issues},
)
class PhysicalIslandSafetyTests(unittest.TestCase):
def test_signal_fanout_does_not_hide_an_unanchored_physical_island(self) -> None:
issues = simulation_preparation_issues(
_fanout_mechanical_network(second_island_has_storage=False)
)
island_issues = [
issue
for issue in issues
if issue.code == "ALGEBRAIC_ISLAND_HAS_NO_STORAGE"
]
self.assertEqual(len(island_issues), 1)
self.assertIn("force_b", island_issues[0].message)
self.assertIn("zero_b", island_issues[0].message)
self.assertNotIn("command", island_issues[0].message)
self.assertNotIn("mass_a", island_issues[0].message)
def test_signal_fanout_between_two_anchored_physical_islands_is_allowed(
self,
) -> None:
issues = simulation_preparation_issues(
_fanout_mechanical_network(second_island_has_storage=True)
)
self.assertNotIn(
"ALGEBRAIC_ISLAND_HAS_NO_STORAGE",
{issue.code for issue in issues},
)
if __name__ == "__main__":
unittest.main()
+70 -31
View File
@@ -2,16 +2,16 @@ from __future__ import annotations
import asyncio import asyncio
import unittest import unittest
from xml.etree import ElementTree as ET
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from app.main import ( from app.main import (
ReactFlowEdgePayload, ReactFlowEdgePayload,
ReactFlowNodePayload, ReactFlowNodePayload,
ReactFlowProjectPayload,
build_reactflow_system_xml, build_reactflow_system_xml,
compile_system_xml_model, compile_system_xml_model,
compile_reactflow_network, compile_system_xml_network,
parse_system_xml, parse_system_xml,
) )
from app.system_xml import validate_system_xml_document from app.system_xml import validate_system_xml_document
@@ -32,6 +32,38 @@ def issue_codes(xml: bytes | str) -> set[str]:
} }
def with_parameter_value(
xml: bytes,
component_id: str,
parameter_name: str,
value: float,
) -> bytes:
root = ET.fromstring(xml)
parameter = root.find(
"./Components/Component[@id='{}']/Parameter[@name='{}']".format(
component_id,
parameter_name,
)
)
assert parameter is not None
parameter.set("value", str(value))
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def without_parameter(
xml: bytes,
component_id: str,
parameter_name: str,
) -> bytes:
root = ET.fromstring(xml)
component = root.find(f"./Components/Component[@id='{component_id}']")
assert component is not None
parameter = component.find(f"./Parameter[@name='{parameter_name}']")
assert parameter is not None
component.remove(parameter)
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def xml_request(body: bytes) -> Request: def xml_request(body: bytes) -> Request:
delivered = False delivered = False
@@ -54,17 +86,17 @@ def xml_request(body: bytes) -> Request:
class SystemXmlParserTests(unittest.TestCase): class SystemXmlParserTests(unittest.TestCase):
def test_valid_v2_xml_round_trips_and_compiles(self) -> None: def test_valid_v3_xml_parses_and_compiles_directly(self) -> None:
report = validate_system_xml_document(valid_xml()) report = validate_system_xml_document(valid_xml())
self.assertTrue(report.valid) self.assertTrue(report.valid)
self.assertEqual(report.issues, ()) self.assertEqual(report.issues, ())
assert report.document is not None assert report.document is not None
project = ReactFlowProjectPayload(**report.document.as_project_data()) network = compile_system_xml_network(report.document)
network = compile_reactflow_network(project)
self.assertEqual(project.name, "protocol-test") self.assertEqual(report.document.name, "protocol-test")
self.assertEqual(project.simulation.method, "BDF") self.assertEqual(report.document.simulation.method, "BDF")
self.assertEqual(report.document.simulation.sample_step, 0.2)
self.assertEqual(set(network.components), {"cylinder_1", "tank_1"}) self.assertEqual(set(network.components), {"cylinder_1", "tank_1"})
self.assertEqual(len(network.connections), 1) self.assertEqual(len(network.connections), 1)
self.assertEqual(network.connections[0].id, "edge-1") self.assertEqual(network.connections[0].id, "edge-1")
@@ -76,8 +108,24 @@ class SystemXmlParserTests(unittest.TestCase):
self.assertEqual(response["name"], "protocol-test") self.assertEqual(response["name"], "protocol-test")
self.assertEqual(len(response["components"]), 2) self.assertEqual(len(response["components"]), 2)
self.assertEqual(len(response["connections"]), 1) self.assertEqual(len(response["connections"]), 1)
self.assertEqual(response["simulation"]["sample_step"], 0.2)
self.assertTrue(response["validation"]["valid"]) self.assertTrue(response["validation"]["valid"])
def test_parse_api_returns_execution_model_not_editor_project(self) -> None:
response = asyncio.run(parse_system_xml(xml_request(valid_xml())))
self.assertTrue(response["success"])
self.assertNotIn("project", response)
model = response["model"]
self.assertEqual(model["schemaVersion"], "3")
self.assertEqual(model["unitSystem"], "SI")
self.assertEqual(model["name"], "protocol-test")
self.assertEqual(model["simulation"]["sample_step"], 0.2)
self.assertEqual(model["components"][0]["model_type"], "cylinder")
self.assertEqual(model["components"][0]["model_version"], "1.0.0")
self.assertNotIn("nodes", model)
self.assertNotIn("edges", model)
def test_parse_api_returns_structured_422_diagnostics(self) -> None: def test_parse_api_returns_structured_422_diagnostics(self) -> None:
with self.assertRaises(HTTPException) as caught: with self.assertRaises(HTTPException) as caught:
asyncio.run(parse_system_xml(xml_request(b"<broken>"))) asyncio.run(parse_system_xml(xml_request(b"<broken>")))
@@ -111,7 +159,7 @@ class SystemXmlParserTests(unittest.TestCase):
self.assertEqual(report.issues[0].code, "XML_DTD_NOT_ALLOWED") self.assertEqual(report.issues[0].code, "XML_DTD_NOT_ALLOWED")
def test_wrong_schema_version_reports_xsd_error(self) -> None: def test_wrong_schema_version_reports_xsd_error(self) -> None:
xml = valid_xml().replace(b'schemaVersion="2"', b'schemaVersion="1"') xml = valid_xml().replace(b'schemaVersion="3"', b'schemaVersion="2"')
report = validate_system_xml_document(xml) report = validate_system_xml_document(xml)
self.assertFalse(report.valid) self.assertFalse(report.valid)
@@ -121,11 +169,7 @@ class SystemXmlParserTests(unittest.TestCase):
self.assertIn("XSD_VALIDATION_ERROR", issue_codes(xml)) self.assertIn("XSD_VALIDATION_ERROR", issue_codes(xml))
def test_parameter_range_is_checked_semantically(self) -> None: def test_parameter_range_is_checked_semantically(self) -> None:
xml = valid_xml().replace( xml = with_parameter_value(valid_xml(), "cylinder_1", "volume", -1)
b'<Parameter name="volume" value="0.01"',
b'<Parameter name="volume" value="-1"',
1,
)
report = validate_system_xml_document(xml) report = validate_system_xml_document(xml)
@@ -138,25 +182,21 @@ class SystemXmlParserTests(unittest.TestCase):
self.assertIn("cylinder_1.volume", issue.message) self.assertIn("cylinder_1.volume", issue.message)
def test_zero_initial_pressure_is_rejected_before_model_creation(self) -> None: def test_zero_initial_pressure_is_rejected_before_model_creation(self) -> None:
xml = valid_xml().replace( xml = with_parameter_value(valid_xml(), "cylinder_1", "p0", 0)
b'<Parameter name="p0" value="35000000"',
b'<Parameter name="p0" value="0"',
1,
)
report = validate_system_xml_document(xml) report = validate_system_xml_document(xml)
self.assertFalse(report.valid) self.assertFalse(report.valid)
self.assertIn("PARAMETER_VALUE_INVALID", issue_codes(xml)) self.assertIn("PARAMETER_VALUE_INVALID", issue_codes(xml))
def test_registered_port_contract_is_checked_semantically(self) -> None: def test_endpoint_port_is_checked_against_registered_contract(self) -> None:
xml = valid_xml().replace( xml = valid_xml().replace(
b'nominalRole="outlet"', b'component="cylinder_1" port="port_b"',
b'nominalRole="bidirectional"', b'component="cylinder_1" port="missing_port"',
1, 1,
) )
self.assertIn("PORT_NOMINAL_ROLE_MISMATCH", issue_codes(xml)) self.assertIn("ENDPOINT_PORT_UNKNOWN", issue_codes(xml))
def test_unknown_connection_endpoint_is_reported(self) -> None: def test_unknown_connection_endpoint_is_reported(self) -> None:
xml = valid_xml().replace( xml = valid_xml().replace(
@@ -178,6 +218,7 @@ class SystemXmlParserTests(unittest.TestCase):
"label": "receiver-tank-2", "label": "receiver-tank-2",
"componentType": "tank", "componentType": "tank",
"modelType": "tank", "modelType": "tank",
"modelVersion": "1.0.0",
"ports": [physical_port("port_a", "inlet", "left")], "ports": [physical_port("port_a", "inlet", "left")],
"parameters": {"volume": 0.1, "p0": 100000, "T0": 300}, "parameters": {"volume": 0.1, "p0": 100000, "T0": 300},
}, },
@@ -193,9 +234,11 @@ class SystemXmlParserTests(unittest.TestCase):
}) })
) )
xml = build_reactflow_system_xml(project) with self.assertRaisesRegex(
ValueError,
self.assertIn("PHYSICAL_PORT_ALREADY_CONNECTED", issue_codes(xml)) "PHYSICAL_PORT_ALREADY_CONNECTED",
):
build_reactflow_system_xml(project)
def test_unconnected_registered_port_is_a_warning(self) -> None: def test_unconnected_registered_port_is_a_warning(self) -> None:
project = physical_connection_project() project = physical_connection_project()
@@ -211,11 +254,7 @@ class SystemXmlParserTests(unittest.TestCase):
) )
def test_missing_required_parameter_is_reported(self) -> None: def test_missing_required_parameter_is_reported(self) -> None:
xml = valid_xml().replace( xml = without_parameter(valid_xml(), "cylinder_1", "T0")
b' <Parameter name="T0" value="300" />\n',
b"",
1,
)
self.assertIn("PARAMETER_REQUIRED_MISSING", issue_codes(xml)) self.assertIn("PARAMETER_REQUIRED_MISSING", issue_codes(xml))
@@ -225,7 +264,7 @@ class SystemXmlParserTests(unittest.TestCase):
self.assertIn("SIMULATION_METHOD_UNSUPPORTED", issue_codes(xml)) self.assertIn("SIMULATION_METHOD_UNSUPPORTED", issue_codes(xml))
def test_non_positive_simulation_steps_are_rejected_by_schema(self) -> None: def test_non_positive_simulation_steps_are_rejected_by_schema(self) -> None:
xml = valid_xml().replace(b'step="0.2"', b'step="0"') xml = valid_xml().replace(b'sampleStep="0.2"', b'sampleStep="0"')
xml = xml.replace(b'maxStep="0.01"', b'maxStep="-1"') xml = xml.replace(b'maxStep="0.01"', b'maxStep="-1"')
report = validate_system_xml_document(xml) report = validate_system_xml_document(xml)
+58 -76
View File
@@ -32,6 +32,7 @@ def physical_connection_project(*, reverse_edge: bool = False) -> ReactFlowProje
"label": "source-cylinder", "label": "source-cylinder",
"componentType": "cylinder", "componentType": "cylinder",
"modelType": "cylinder", "modelType": "cylinder",
"modelVersion": "1.0.0",
"ports": [physical_port("port_b", "outlet", "right")], "ports": [physical_port("port_b", "outlet", "right")],
"parameters": {"volume": 0.01, "p0": 35000000, "T0": 300}, "parameters": {"volume": 0.01, "p0": 35000000, "T0": 300},
}, },
@@ -44,6 +45,7 @@ def physical_connection_project(*, reverse_edge: bool = False) -> ReactFlowProje
"label": "receiver-tank", "label": "receiver-tank",
"componentType": "tank", "componentType": "tank",
"modelType": "tank", "modelType": "tank",
"modelVersion": "1.0.0",
"ports": [physical_port("port_a", "inlet", "left")], "ports": [physical_port("port_a", "inlet", "left")],
"parameters": {"volume": 0.1, "p0": 100000, "T0": 300}, "parameters": {"volume": 0.1, "p0": 100000, "T0": 300},
}, },
@@ -66,6 +68,7 @@ def physical_connection_project(*, reverse_edge: bool = False) -> ReactFlowProje
} }
) )
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="protocol-test", name="protocol-test",
nodes=[source, target], nodes=[source, target],
edges=[edge], edges=[edge],
@@ -80,15 +83,18 @@ def physical_connection_project(*, reverse_edge: bool = False) -> ReactFlowProje
class SystemXmlProtocolTests(unittest.TestCase): class SystemXmlProtocolTests(unittest.TestCase):
def test_v2_xml_contains_port_metadata_and_neutral_physical_endpoints(self) -> None: def test_v3_xml_contains_only_execution_model_fields(self) -> None:
root = ET.fromstring(build_reactflow_system_xml(physical_connection_project())) root = ET.fromstring(build_reactflow_system_xml(physical_connection_project()))
self.assertEqual(root.tag, "System") self.assertEqual(root.tag, "System")
self.assertEqual(root.attrib["name"], "protocol-test") self.assertEqual(
self.assertEqual(root.attrib["schemaVersion"], "2") root.attrib,
self.assertEqual(root.attrib["unitSystem"], "SI") {
self.assertEqual(root.attrib["mediumReferenceVersion"], "1") "name": "protocol-test",
self.assertEqual(root.attrib["amesimParameterEncodingVersion"], "1") "schemaVersion": "3",
"unitSystem": "SI",
},
)
self.assertEqual( self.assertEqual(
[child.tag for child in root], [child.tag for child in root],
["Simulation", "Components", "Connections"], ["Simulation", "Components", "Connections"],
@@ -101,37 +107,34 @@ class SystemXmlProtocolTests(unittest.TestCase):
{ {
"tStart": "1.0", "tStart": "1.0",
"tStop": "5.0", "tStop": "5.0",
"step": "0.2", "sampleStep": "0.2",
"maxStep": "0.01", "maxStep": "0.01",
"method": "BDF", "method": "BDF",
}, },
) )
port = root.find("./Components/Component[@id='cylinder_1']/Port")
assert port is not None
component = root.find("./Components/Component[@id='cylinder_1']") component = root.find("./Components/Component[@id='cylinder_1']")
assert component is not None assert component is not None
self.assertEqual(component.attrib["rotation"], "0")
self.assertEqual(component.attrib["mirrored"], "false")
self.assertEqual( self.assertEqual(
port.attrib, component.attrib,
{ {
"name": "port_b", "id": "cylinder_1",
"kind": "physical", "type": "cylinder",
"domain": "pneumatic", "modelVersion": "1.0.0",
"nominalRole": "outlet",
"side": "right",
"positiveFlowDirection": "intoComponent",
}, },
) )
self.assertEqual(component.findall("Port"), [])
self.assertEqual(
{
parameter.attrib["name"]: float(parameter.attrib["value"])
for parameter in component.findall("Parameter")
},
{"volume": 0.01, "p0": 35_000_000.0, "T0": 300.0},
)
connection = root.find("./Connections/Connection") connection = root.find("./Connections/Connection")
assert connection is not None assert connection is not None
self.assertEqual( self.assertEqual(connection.attrib, {"id": "edge-1"})
connection.attrib,
{"id": "edge-1", "kind": "physical", "domain": "pneumatic"},
)
self.assertNotIn("source", connection.attrib)
self.assertEqual( self.assertEqual(
{ {
(endpoint.attrib["component"], endpoint.attrib["port"]) (endpoint.attrib["component"], endpoint.attrib["port"])
@@ -139,9 +142,7 @@ class SystemXmlProtocolTests(unittest.TestCase):
}, },
{("cylinder_1", "port_b"), ("tank_1", "port_a")}, {("cylinder_1", "port_b"), ("tank_1", "port_a")},
) )
self.assertTrue( self.assertTrue(all(len(endpoint.attrib) == 2 for endpoint in connection))
all("role" not in endpoint.attrib for endpoint in connection.findall("Endpoint"))
)
def test_physical_connection_meaning_does_not_depend_on_drag_direction(self) -> None: def test_physical_connection_meaning_does_not_depend_on_drag_direction(self) -> None:
forward = ET.fromstring( forward = ET.fromstring(
@@ -159,63 +160,24 @@ class SystemXmlProtocolTests(unittest.TestCase):
self.assertEqual(endpoint_set(forward), endpoint_set(reverse)) self.assertEqual(endpoint_set(forward), endpoint_set(reverse))
def test_component_orientation_is_exported_as_layout_metadata(self) -> None: def test_editor_layout_and_display_fields_are_not_exported(self) -> None:
project = physical_connection_project() project = physical_connection_project()
project.nodes[0].position.x = 987
project.nodes[0].position.y = 654
project.nodes[0].data.label = "display-only-name"
project.nodes[0].data.rotation = 90 project.nodes[0].data.rotation = 90
project.nodes[0].data.mirrored = True project.nodes[0].data.mirrored = True
root = ET.fromstring(build_reactflow_system_xml(project)) root = ET.fromstring(build_reactflow_system_xml(project))
component = root.find("./Components/Component[@id='cylinder_1']") component = root.find("./Components/Component[@id='cylinder_1']")
assert component is not None assert component is not None
self.assertEqual(component.attrib["rotation"], "90")
self.assertEqual(component.attrib["mirrored"], "true")
def test_legacy_string_port_is_migrated_when_exporting_v2(self) -> None:
project = ReactFlowProjectPayload(
name="legacy-port",
nodes=[
{
"id": "tank_1",
"data": {
"label": "tank_1",
"componentType": "tank",
"modelType": "tank",
"ports": ["port_a"],
},
}
],
)
root = ET.fromstring(build_reactflow_system_xml(project))
port = root.find("./Components/Component/Port")
assert port is not None
self.assertEqual(port.attrib["kind"], "physical")
self.assertEqual(port.attrib["domain"], "pneumatic")
self.assertEqual(port.attrib["nominalRole"], "inlet")
self.assertEqual(port.attrib["positiveFlowDirection"], "intoComponent")
self.assertEqual(port.attrib["side"], "left")
def test_legacy_tee_port_names_do_not_restore_direction_constraints(self) -> None:
project = ReactFlowProjectPayload(
name="legacy-tee",
nodes=[
{
"id": "tee_1",
"data": {
"label": "tee_1",
"componentType": "tee",
"modelType": "tee",
"ports": ["port_in", "port_out1", "port_out2"],
},
}
],
)
root = ET.fromstring(build_reactflow_system_xml(project))
ports = root.findall("./Components/Component/Port")
self.assertEqual( self.assertEqual(
[port.attrib["nominalRole"] for port in ports], component.attrib,
["bidirectional", "bidirectional", "bidirectional"], {
"id": "cylinder_1",
"type": "cylinder",
"modelVersion": "1.0.0",
},
) )
def test_incompatible_physical_domains_are_rejected(self) -> None: def test_incompatible_physical_domains_are_rejected(self) -> None:
@@ -224,9 +186,29 @@ class SystemXmlProtocolTests(unittest.TestCase):
assert not isinstance(tank_port, str) assert not isinstance(tank_port, str)
tank_port.domain = "hydraulic" tank_port.domain = "hydraulic"
with self.assertRaisesRegex(ValueError, "incompatible domains"): with self.assertRaisesRegex(ValueError, "incompatible port type"):
build_reactflow_system_xml(project) build_reactflow_system_xml(project)
def test_exporter_never_returns_xml_that_fails_the_v3_contract(self) -> None:
invalid_time = physical_connection_project()
invalid_time.simulation.t_stop = invalid_time.simulation.t_start
with self.assertRaisesRegex(ValueError, "SIMULATION_TIME_RANGE_INVALID"):
build_reactflow_system_xml(invalid_time)
duplicate_component = physical_connection_project()
duplicate_component.nodes[1].id = duplicate_component.nodes[0].id
duplicate_component.edges = []
with self.assertRaisesRegex(ValueError, "COMPONENT_ID_DUPLICATE"):
build_reactflow_system_xml(duplicate_component)
def test_blank_project_name_is_omitted(self) -> None:
project = physical_connection_project()
project.name = " "
root = ET.fromstring(build_reactflow_system_xml(project))
self.assertNotIn("name", root.attrib)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+200
View File
@@ -0,0 +1,200 @@
from __future__ import annotations
from dataclasses import replace
import unittest
from app.main import compile_system_xml_network
from app.system_xml import validate_system_xml_document
def physical_system_xml() -> str:
return """<?xml version="1.0" encoding="UTF-8"?>
<System schemaVersion="3" unitSystem="SI">
<Simulation tStart="1" tStop="5" sampleStep="0.2" maxStep="0.01" method="BDF"/>
<Components>
<Component id="cylinder_1" type="cylinder" modelVersion="1.0.0">
<Parameter name="volume" value="0.01"/>
<Parameter name="p0" value="35000000"/>
<Parameter name="T0" value="300"/>
</Component>
<Component id="tank_1" type="tank" modelVersion="1.0.0">
<Parameter name="volume" value="0.1"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="300"/>
</Component>
</Components>
<Connections>
<Connection>
<Endpoint component="cylinder_1" port="port_b"/>
<Endpoint component="tank_1" port="port_a"/>
</Connection>
</Connections>
</System>
"""
def issue_codes(xml: str) -> set[str]:
return {issue.code for issue in validate_system_xml_document(xml).issues}
class SystemXmlV3Tests(unittest.TestCase):
def test_minimal_execution_model_is_valid(self) -> None:
report = validate_system_xml_document(physical_system_xml())
self.assertTrue(report.valid)
self.assertEqual(report.issues, ())
assert report.document is not None
self.assertEqual(report.document.connections[0].id, "connection_1")
model = report.document.as_model_data()
self.assertEqual(model["name"], "untitled")
self.assertEqual(model["simulation"]["sample_step"], 0.2)
self.assertEqual(model["components"][0]["model_version"], "1.0.0")
def test_editor_and_port_snapshot_fields_are_rejected(self) -> None:
xml = physical_system_xml().replace(
'modelVersion="1.0.0">',
'modelVersion="1.0.0" x="10">\n <Port name="port_b"/>',
1,
)
report = validate_system_xml_document(xml)
self.assertFalse(report.valid)
self.assertIsNone(report.document)
self.assertTrue(all(issue.layer == "schema" for issue in report.issues))
def test_model_version_is_checked_against_registry(self) -> None:
xml = physical_system_xml().replace(
'type="cylinder" modelVersion="1.0.0"',
'type="cylinder" modelVersion="9.9.9"',
)
self.assertIn("COMPONENT_MODEL_VERSION_MISMATCH", issue_codes(xml))
def test_direct_compiler_defends_the_model_version_contract(self) -> None:
report = validate_system_xml_document(physical_system_xml())
assert report.document is not None
first = replace(
report.document.components[0],
model_version="9.9.9",
)
document = replace(
report.document,
components=(first, *report.document.components[1:]),
)
with self.assertRaisesRegex(ValueError, "expected '1.0.0'"):
compile_system_xml_network(document)
def test_component_cannot_connect_two_of_its_own_ports(self) -> None:
xml = """<System schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="1" sampleStep="0.1" maxStep="0.01" method="BDF"/>
<Components>
<Component id="pipe_1" type="pipe" modelVersion="1.0.0">
<Parameter name="length" value="1"/>
<Parameter name="diameter" value="0.02"/>
<Parameter name="lambda_darcy" value="0.02"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="300"/>
</Component>
</Components>
<Connections>
<Connection>
<Endpoint component="pipe_1" port="port_a"/>
<Endpoint component="pipe_1" port="port_b"/>
</Connection>
</Connections>
</System>"""
self.assertIn("CONNECTION_SELF_REFERENCE", issue_codes(xml))
def test_signal_direction_is_derived_from_registered_ports(self) -> None:
xml = """<System schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="1" sampleStep="0.1" maxStep="0.01" method="BDF"/>
<Components>
<Component id="step_1" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="0"/>
<Parameter name="final" value="1"/>
<Parameter name="time" value="0"/>
</Component>
<Component id="step_2" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="0"/>
<Parameter name="final" value="1"/>
<Parameter name="time" value="0"/>
</Component>
</Components>
<Connections>
<Connection id="signal_1">
<Endpoint component="step_1" port="out"/>
<Endpoint component="step_2" port="out"/>
</Connection>
</Connections>
</System>"""
self.assertIn("SIGNAL_PORT_ROLES_INVALID", issue_codes(xml))
def test_signal_input_rejects_multiple_drivers_but_output_may_fan_out(self) -> None:
components = """
<Component id="step_1" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="0"/>
<Parameter name="final" value="1"/>
<Parameter name="time" value="0"/>
</Component>
<Component id="step_2" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="1"/>
<Parameter name="final" value="0"/>
<Parameter name="time" value="0"/>
</Component>
<Component id="force_1" type="amesim_forc" modelVersion="0.2.0">
<Parameter name="direction" value="1"/>
</Component>
<Component id="force_2" type="amesim_forc" modelVersion="0.2.0">
<Parameter name="direction" value="1"/>
</Component>"""
def signal_xml(connections: str) -> str:
return f"""<System schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="1" sampleStep="0.1" maxStep="0.01" method="BDF"/>
<Components>{components}
</Components>
<Connections>{connections}
</Connections>
</System>"""
multiple_drivers = signal_xml("""
<Connection id="signal_1">
<Endpoint component="step_1" port="out"/>
<Endpoint component="force_1" port="res"/>
</Connection>
<Connection id="signal_2">
<Endpoint component="step_2" port="out"/>
<Endpoint component="force_1" port="res"/>
</Connection>""")
fan_out = signal_xml("""
<Connection id="signal_1">
<Endpoint component="step_1" port="out"/>
<Endpoint component="force_1" port="res"/>
</Connection>
<Connection id="signal_2">
<Endpoint component="step_1" port="out"/>
<Endpoint component="force_2" port="res"/>
</Connection>""")
self.assertIn(
"SIGNAL_INPUT_MULTIPLE_DRIVERS",
issue_codes(multiple_drivers),
)
self.assertTrue(validate_system_xml_document(fan_out).valid)
def test_v2_step_name_is_not_accepted(self) -> None:
xml = physical_system_xml().replace('sampleStep="0.2"', 'step="0.2"')
report = validate_system_xml_document(xml)
self.assertFalse(report.valid)
self.assertIsNone(report.document)
self.assertIn("XSD_VALIDATION_ERROR", issue_codes(xml))
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -38,6 +38,7 @@ class TestMqlExampleRunnerTests(unittest.TestCase):
def test_reactflow_helper_runs_fixed_topology_test_mql(self) -> None: def test_reactflow_helper_runs_fixed_topology_test_mql(self) -> None:
payload = ReactFlowProjectPayload( payload = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="test-mql-fixed", name="test-mql-fixed",
simulation={ simulation={
"t_start": 0.0, "t_start": 0.0,