完善建模交互、组件图标与系统协议
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),
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user