完善建模交互、组件图标与系统协议
This commit is contained in:
1 parent
456c29b3b6
commit
6572defaa4
66 files changed
+10067
-4163
No files matched your search
+351
-300
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user