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

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),
+1 -1
View File
@@ -186,7 +186,7 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
`testmodel_tank_temperature.svg`
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
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."""
MODEL_TYPE = "amesim_forc"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.2.0"
PORTS = (
PortDefinition.signal("res", nominal_role="input"),
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 = (
ResultVariableDefinition("force", "输出力", "force", "N", "signal", 10),
)
@@ -102,12 +119,12 @@ class AmesimForc(AlgebraicComponent):
order=20,
)
def __init__(self, name: str) -> None:
def __init__(self, name: str, *, direction: float = 1.0) -> None:
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.port_2 = self.register_declared_port("port_2")
self._orientation_sign = 1.0
@classmethod
def create(
@@ -117,20 +134,11 @@ class AmesimForc(AlgebraicComponent):
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> "AmesimForc":
return cls(name=name)
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
return cls(name=name, direction=parameters["direction"])
@property
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, ...]:
return (
@@ -141,7 +149,7 @@ class AmesimForc(AlgebraicComponent):
relation="constitutive",
variables=(f"{self.name}.port_2.f", f"{self.name}.res.signal"),
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(
"useFriction",
1.0,
2.0,
label="启用摩擦",
quantity="dimensionless",
unit="",
@@ -383,7 +391,7 @@ class AmesimMecmas21(DynamicComponent):
),
ParameterDefinition(
"stoptype",
4.0,
1.0,
label="限位类型",
quantity="dimensionless",
unit="",
@@ -1031,14 +1039,33 @@ class AmesimLmechn1(AlgebraicComponent):
"""AMESim LMECHN1 first public dynamic linear mechanical node."""
MODEL_TYPE = "amesim_lmechn1"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.2.0"
PORTS = tuple(
PortDefinition.mechanical_translational(f"port_{index}")
for index in range(1, 10)
for index in range(1, 22)
)
PARAMETERS = (
ParameterDefinition("v1", 8.0, label="右侧端口数", quantity="dimensionless", unit="", minimum=1.0, maximum=8.0),
ParameterDefinition("sum", 1.0, label="节点求和模式", quantity="dimensionless", unit="", minimum=0.0),
ParameterDefinition(
"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 = (
ResultVariableDefinition("tforce", "节点合力", "force", "N", "derived", 10),
@@ -1049,13 +1076,16 @@ class AmesimLmechn1(AlgebraicComponent):
category_id="mechanical",
symbol="amesim_lmechn1",
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,
)
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)
self.set_parameter_values({"v1": v1, "sum": sum})
self.v1 = int(v1)
@@ -1078,21 +1108,30 @@ class AmesimLmechn1(AlgebraicComponent):
@property
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
def total_force(self) -> float:
# AMESim's ``tforce`` is the force transmitted by the summed branch
# ports (1..v1). Port 9 is the balancing/common port and is excluded
# from that reported value.
# ports (1..v1). The final active port is the balancing/common port and
# is excluded from that reported value.
return sum(self.get_port(port_name).f for port_name in self.active_ports[:-1])
@property
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, ...]:
reference = self.port_9
reference_name = self.reference_port_name
reference = self.get_port(reference_name)
residuals: list[EquationResidual] = []
for port_name in self.active_ports[:-1]:
port = self.get_port(port_name)
@@ -1102,7 +1141,7 @@ class AmesimLmechn1(AlgebraicComponent):
owner="component",
owner_id=self.name,
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",
value=port.x - reference.x,
)
@@ -1113,7 +1152,7 @@ class AmesimLmechn1(AlgebraicComponent):
owner="component",
owner_id=self.name,
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",
value=port.v - reference.v,
)
@@ -1129,6 +1168,39 @@ class AmesimLmechn1(AlgebraicComponent):
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)
def component_result_values(self) -> Mapping[str, float]:
@@ -1,12 +1,3 @@
"""Temporary component library used to validate the model authoring contract."""
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
)
@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:
definition = port.definition
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."""
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)
class GenericSimulationResult:
success: bool
@@ -106,10 +114,9 @@ def simulation_preparation_issues(
) -> tuple[SimulationPreparationIssue, ...]:
issues: list[SimulationPreparationIssue] = []
physical_endpoints = {
Endpoint(component.name, definition.name)
Endpoint(component.name, port_name)
for component in network.components.values()
for definition in component.port_definitions
if definition.kind == "physical"
for port_name in component.required_connection_ports
}
connected_endpoints = {
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:
if connection.kind != "physical":
continue
first, second = connection.endpoints
adjacency[first.component].add(second.component)
adjacency[second.component].add(first.component)
@@ -224,20 +241,95 @@ def simulation_sample_times(
*,
max_points: int = 10001,
) -> 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):
raise ValueError("Simulation sample step must be finite and greater than zero.")
duration = config.t_stop - config.t_start
raise SimulationSampleTimeError(
"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:
raise ValueError("Simulation stop time must be greater than start time.")
interval_count = int(floor(duration / step + 1e-12))
times = [config.t_start + index * step for index in range(interval_count + 1)]
if times[-1] < config.t_stop - 1e-12:
times.append(config.t_stop)
else:
times[-1] = config.t_stop
if len(times) > max_points:
raise ValueError(
f"Simulation requests {len(times)} samples; the limit is {max_points}."
raise SimulationSampleTimeError(
"SIMULATION_TIME_RANGE_INVALID",
"Simulation stop time must be greater than start time.",
)
# Bound the grid before dividing by a potentially tiny step or allocating
# the result list. This avoids both float-to-int overflow and an OOM-sized
# ``range``/list when input comes from an external System XML document.
maximum_interval_count = max_points - 1
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
+14 -2
View File
@@ -91,8 +91,10 @@ class SimulationNetwork:
) -> Connection:
endpoint_a = Endpoint(endpoint_a_component, endpoint_a_port)
endpoint_b = Endpoint(endpoint_b_component, endpoint_b_port)
if endpoint_a == endpoint_b:
raise ValueError(f"Cannot connect endpoint {endpoint_a} to itself.")
if endpoint_a.component == endpoint_b.component:
raise ValueError(
f"Cannot connect component {endpoint_a.component} to itself."
)
first_port = self._port_for(endpoint_a)
second_port = self._port_for(endpoint_b)
@@ -131,6 +133,16 @@ class SimulationNetwork:
+ ", ".join(occupied)
+ ". 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:
endpoint_a, endpoint_b = endpoint_b, endpoint_a
+291 -677
View File
File diff suppressed because it is too large. Load diff