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

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

No files matched your search

+351 -300
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Callable, Iterator
from collections.abc import Callable, Iterator, Mapping
import csv
from dataclasses import dataclass
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.responses import FileResponse, HTMLResponse, StreamingResponse
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from app.system_xml import (
SystemXmlDocument,
@@ -30,16 +30,15 @@ from app.system_xml import (
if TYPE_CHECKING:
from app.simulation.components.amesim.gases import AmesimGasRegistry
from app.simulation.core.ports import PortDefinition
from app.simulation.registry import ComponentModelSpec
from app.simulation.systems.network import SimulationNetwork
app = FastAPI(title="System Simulation ReactFlow App")
FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
SYSTEM_XML_SCHEMA_VERSION = "2"
SYSTEM_XML_SCHEMA_VERSION = "3"
SYSTEM_XML_UNIT_SYSTEM = "SI"
AMESIM_PARAMETER_ENCODING_VERSION = 1
MECMAS21_LEGACY_BINARY_PARAMETERS = ("useFriction", "strib")
SimulationProgressEmitter = Callable[
[int, str, str, float | None, float | None],
@@ -103,12 +102,29 @@ class ReactFlowPortDefinition(BaseModel):
side: Literal["left", "right"] = "left"
class ReactFlowParameterScientificNotation(BaseModel):
text: str
unit: str
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 = ""
componentType: 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)
parameterUnits: dict[str, str] = Field(default_factory=dict)
parameterScientificNotation: dict[str, ReactFlowParameterScientificNotation] = Field(
default_factory=dict
)
rotation: Literal[0, 90, 180, 270] = 0
mirrored: bool = False
@@ -120,12 +136,21 @@ class ReactFlowNodePayload(BaseModel):
data: ReactFlowNodeData
class ReactFlowEdgeData(BaseModel):
model_config = ConfigDict(extra="allow")
isContactEdge: bool = False
class ReactFlowEdgePayload(BaseModel):
model_config = ConfigDict(extra="allow")
id: str
source: str
target: str
sourceHandle: str | None = None
targetHandle: str | None = None
data: ReactFlowEdgeData = Field(default_factory=ReactFlowEdgeData)
class ReactFlowSimulationConfig(BaseModel):
@@ -137,24 +162,45 @@ class ReactFlowSimulationConfig(BaseModel):
class ReactFlowProjectPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
projectSchemaVersion: Literal[1]
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)
edges: list[ReactFlowEdgePayload] = Field(default_factory=list)
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):
key: str
componentId: str
@@ -180,43 +226,15 @@ class SimulationCancellationPayload(BaseModel):
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.dict(exclude_none=True)
return model.model_dump(mode="json", exclude_none=True)
def reactflow_project_storage_data(
project: ReactFlowProjectPayload,
) -> 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)
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
return pydantic_to_jsonable(project)
@app.get("/")
@@ -385,17 +403,16 @@ def simulation_results_csv_filename(project_name: str) -> str:
@app.get("/api/reactflow/projects")
def list_reactflow_projects() -> dict[str, object]:
PROJECT_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
projects = []
for path in sorted(PROJECT_STORAGE_DIR.glob("*.json")):
projects.append(
{
"id": path.stem,
"updatedAt": datetime.fromtimestamp(
path.stat().st_mtime,
timezone.utc,
).isoformat(),
}
)
projects = [
{
"id": path.stem,
"updatedAt": datetime.fromtimestamp(
path.stat().st_mtime,
timezone.utc,
).isoformat(),
}
for path in sorted(PROJECT_STORAGE_DIR.glob("*.json"))
]
return {"projects": projects}
@@ -404,7 +421,20 @@ def load_reactflow_project(project_id: str) -> dict[str, object]:
path = reactflow_project_path(project_id)
if not path.exists():
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}")
@@ -468,7 +498,7 @@ async def parse_system_xml(request: Request) -> dict[str, object]:
return {
"success": True,
"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]:
report = validate_system_xml_document(await request.body())
document = _validated_xml_document_or_422(report)
project, network = _compile_xml_document_or_422(document)
network = _compile_xml_document_or_422(document)
return {
"success": True,
"validation": report.as_dict(),
"simulation": pydantic_to_jsonable(project.simulation),
"simulation": document.as_model_data()["simulation"],
**network.as_interface_dict(),
}
@@ -665,37 +695,37 @@ def run_system_xml_simulation(
report = validate_system_xml_document(xml_bytes)
document = _validated_xml_document_or_422(report)
emit(0, "compilation", "正在编译组件与连接关系")
project, network = _compile_xml_document_or_422(document)
network = _compile_xml_document_or_422(document)
emit(0, "initialization", "模型编译完成,正在准备求解器")
def report_system_progress(progress: float, phase: str) -> None:
bounded_progress = min(1.0, max(0.0, progress))
simulated_time = project.simulation.t_start + bounded_progress * (
project.simulation.t_stop - project.simulation.t_start
simulated_time = document.simulation.t_start + bounded_progress * (
document.simulation.t_stop - document.simulation.t_start
)
emit(
round(100 * bounded_progress),
phase,
SIMULATION_PHASE_MESSAGES.get(phase, "正在运行仿真"),
simulated_time,
project.simulation.t_stop,
document.simulation.t_stop,
)
try:
system = GenericFluidSystem(network)
result = system.simulate(
SolveIVPConfig(
t_start=project.simulation.t_start,
t_stop=project.simulation.t_stop,
method=project.simulation.method,
t_start=document.simulation.t_start,
t_stop=document.simulation.t_stop,
method=document.simulation.method,
# The pressure-flow closure is solved to a scaled 1e-7
# residual. State-specific mechanical absolute tolerances now
# keep ideal-stop Jacobian perturbations stable, so the outer
# integrator can use its canonical 1e-6 relative accuracy.
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,
cancel_check=cancel_check,
)
@@ -764,7 +794,7 @@ def run_system_xml_simulation(
return {
"validation": report.as_dict(),
"simulation": pydantic_to_jsonable(project.simulation),
"simulation": document.as_model_data()["simulation"],
"model": network.as_interface_dict(),
**result.as_dict(),
}
@@ -922,10 +952,9 @@ def _validated_xml_document_or_422(
def _compile_xml_document_or_422(
document: SystemXmlDocument,
) -> tuple[ReactFlowProjectPayload, "SimulationNetwork"]:
project = ReactFlowProjectPayload(**document.as_project_data())
) -> "SimulationNetwork":
try:
network = compile_reactflow_network(project)
network = compile_system_xml_network(document)
except ValueError as exc:
raise HTTPException(
status_code=422,
@@ -941,118 +970,147 @@ def _compile_xml_document_or_422(
],
},
) 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:
from app.simulation.registry import get_component_model_spec
system = ET.Element(
"System",
{
"name": project.name,
"schemaVersion": SYSTEM_XML_SCHEMA_VERSION,
"unitSystem": SYSTEM_XML_UNIT_SYSTEM,
"mediumReferenceVersion": "1",
"amesimParameterEncodingVersion": str(
AMESIM_PARAMETER_ENCODING_VERSION
),
},
)
system_attributes = {
"schemaVersion": SYSTEM_XML_SCHEMA_VERSION,
"unitSystem": SYSTEM_XML_UNIT_SYSTEM,
}
if project.name.strip():
system_attributes["name"] = project.name.strip()
system = ET.Element("System", system_attributes)
ET.SubElement(
system,
"Simulation",
{
"tStart": str(project.simulation.t_start),
"tStop": str(project.simulation.t_stop),
"step": str(project.simulation.step),
"sampleStep": str(project.simulation.step),
"maxStep": str(project.simulation.max_step),
"method": project.simulation.method,
},
)
components_node = ET.SubElement(system, "Components")
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:
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(
components_node,
"Component",
{
"id": node.id,
"name": node.data.label,
"type": node.data.modelType,
"componentType": node.data.componentType,
"x": f"{node.position.x:g}",
"y": f"{node.position.y:g}",
"rotation": str(node.data.rotation),
"mirrored": str(node.data.mirrored).lower(),
"modelVersion": node.data.modelVersion or "",
},
)
for index, port in enumerate(node.data.ports):
port_definition = normalize_port_definition(
port,
index=index,
component_type=node.data.componentType,
)
for port_definition in component_spec.ports:
port_key = (node.id, port_definition.name)
if port_key in port_index:
raise ValueError(
f"Component {node.id} contains duplicate port {port_definition.name}."
)
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(
component_node,
"Parameter",
{"name": name, "value": str(value)},
{
"name": parameter.name,
"value": str(parameter_values[parameter.name]),
},
)
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(
port_index,
edge.source,
@@ -1070,88 +1128,41 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
connection_node = ET.SubElement(
connections_node,
"Connection",
{
"id": edge.id,
"kind": first.kind,
"domain": first.domain,
},
{"id": edge.id},
)
endpoints = [
(edge.source, first, None),
(edge.target, second, None),
]
endpoints = [(edge.source, first), (edge.target, second)]
if first.kind == "signal":
if first.nominalRole == "input":
if first.nominal_role == "input":
endpoints.reverse()
endpoints = [
(endpoints[0][0], endpoints[0][1], "source"),
(endpoints[1][0], endpoints[1][1], "target"),
]
for component_id, port, role in endpoints:
attributes = {"component": component_id, "port": port.name}
if role is not None:
attributes["role"] = role
ET.SubElement(connection_node, "Endpoint", attributes)
for component_id, port in endpoints:
ET.SubElement(
connection_node,
"Endpoint",
{"component": component_id, "port": port.name},
)
ET.indent(system, space=" ")
return ET.tostring(system, encoding="utf-8", xml_declaration=True)
def normalize_port_definition(
port: ReactFlowPortDefinition | str,
*,
index: int,
component_type: str,
) -> ReactFlowPortDefinition:
if isinstance(port, ReactFlowPortDefinition):
return port
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,
xml = ET.tostring(system, encoding="utf-8", xml_declaration=True)
report = validate_system_xml_document(xml)
if not report.valid:
errors = [
f"{issue.code}: {issue.message}"
for issue in report.issues
if issue.severity == "error"
]
raise ValueError(
"Generated System XML does not satisfy the v3 contract: "
+ "; ".join(errors)
)
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(
port_index: dict[tuple[str, str], ReactFlowPortDefinition],
port_index: dict[tuple[str, str], "PortDefinition"],
component_id: str,
port_name: str | None,
connection_id: str,
) -> ReactFlowPortDefinition:
) -> "PortDefinition":
if port_name is None or (component_id, port_name) not in port_index:
raise ValueError(
f"Connection {connection_id} references missing endpoint "
@@ -1161,15 +1172,15 @@ def require_connection_port(
def validate_compatible_ports(
first: ReactFlowPortDefinition,
second: ReactFlowPortDefinition,
first: "PortDefinition",
second: "PortDefinition",
connection_id: str,
) -> None:
if first.kind != second.kind:
raise ValueError(f"Connection {connection_id} mixes physical and signal ports.")
if first.domain != second.domain:
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",
"output",
}:
@@ -1182,6 +1193,89 @@ def compile_reactflow_network(
project: ReactFlowProjectPayload,
*,
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":
from app.simulation.components.amesim.gases import (
AMESIM_BUILTIN_AIR_GAS_INDEX,
@@ -1198,32 +1292,32 @@ def compile_reactflow_network(
else:
amesim_gas_registry = amesim_gas_registry.copy()
network = SimulationNetwork(name=project.name)
network = SimulationNetwork(name=model.name)
resolved_nodes = []
specs_by_component_id = {}
gas_indices_by_component_id = {}
component_ids: set[str] = set()
# Phase 1: normalize every node and consume compile-time medium definitions.
# A definition node represents one complete property-method instance and is
# intentionally not added to the equation network.
for node in project.nodes:
spec = get_component_model_spec(node.data.modelType)
parameter_values = {
parameter.name: canonical_amesim_parameter_value(
project,
node,
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:
for node in model.components:
if node.id in component_ids:
raise ValueError(f"Duplicate component id: {node.id}.")
component_ids.add(node.id)
spec = get_component_model_spec(node.model_type)
if node.model_version is None or not node.model_version.strip():
raise ValueError(
f"Component '{node.id}' contains unsupported parameters: "
+ ", ".join(sorted(unknown_parameters))
+ "."
f"COMPONENT_MODEL_VERSION_MISSING: Component '{node.id}' must "
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(
spec.component_class,
AmesimGasMediumDefinitionComponent,
@@ -1237,10 +1331,6 @@ def compile_reactflow_network(
definition_component,
AmesimGasMediumDefinitionComponent,
)
validate_component_port_interface(
node,
definition_component.port_definitions,
)
try:
amesim_gas_registry.register(
definition_component.gas_definition()
@@ -1263,22 +1353,24 @@ def compile_reactflow_network(
# Phase 2: resolve one gas property model for each pneumatic circuit before
# any dynamic component initializes its thermodynamic state.
pneumatic_connections = []
for edge in project.edges:
source_spec = specs_by_component_id.get(edge.source)
target_spec = specs_by_component_id.get(edge.target)
for edge in model.connections:
source_spec = specs_by_component_id.get(edge.endpoint_a_component)
target_spec = specs_by_component_id.get(edge.endpoint_b_component)
if source_spec is None or target_spec is None:
continue
source_ports = {port.name: port for port in source_spec.ports}
target_ports = {port.name: port for port in target_spec.ports}
source_port = source_ports.get(edge.sourceHandle or "")
target_port = target_ports.get(edge.targetHandle or "")
source_port = source_ports.get(edge.endpoint_a_port)
target_port = target_ports.get(edge.endpoint_b_port)
if (
source_port is not None
and target_port is not None
and source_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(
gas_indices_by_component_id,
@@ -1291,21 +1383,14 @@ def compile_reactflow_network(
media_by_component_id[node.id],
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)
for edge in project.edges:
for edge in model.connections:
network.connect(
edge.source,
edge.sourceHandle or "",
edge.target,
edge.targetHandle or "",
edge.endpoint_a_component,
edge.endpoint_a_port,
edge.endpoint_b_component,
edge.endpoint_b_port,
connection_id=edge.id,
)
return network
@@ -1315,14 +1400,7 @@ def validate_component_port_interface(
node: ReactFlowNodePayload,
component_ports: tuple["PortDefinition", ...],
) -> None:
payload_ports = [
normalize_port_definition(
port,
index=index,
component_type=node.data.componentType,
)
for index, port in enumerate(node.data.ports)
]
payload_ports = node.data.ports
expected_by_name = {port.name: port for port in component_ports}
payload_by_name = {port.name: port for port in 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
validate_reactflow_execution_contract(project)
nodes_by_type: dict[str, list[ReactFlowNodePayload]] = {}
for node in project.nodes:
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.system import TestMqlRunConfig
validate_reactflow_execution_contract(project)
run_config = TestMqlRunConfig(
t_start=project.simulation.t_start,
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.")
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):
return pipe_config_type(
length=parameter_float(node, "length", 5.0),