316 lines
12 KiB
Python
316 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from app.simulation.core.base import Component, DynamicComponent
|
|
from app.simulation.core.equations import EquationResidual
|
|
from app.simulation.core.metadata import ResultVariableMetadata
|
|
from app.simulation.core.ports import PortState
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Endpoint:
|
|
component: str
|
|
port: str
|
|
|
|
@property
|
|
def key(self) -> tuple[str, str]:
|
|
return self.component, self.port
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.component}.{self.port}"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Connection:
|
|
id: str
|
|
kind: str
|
|
domain: str
|
|
endpoint_a: Endpoint
|
|
endpoint_b: Endpoint
|
|
|
|
@property
|
|
def endpoints(self) -> tuple[Endpoint, Endpoint]:
|
|
return self.endpoint_a, self.endpoint_b
|
|
|
|
@property
|
|
def undirected_key(self) -> tuple[tuple[str, str], tuple[str, str]]:
|
|
first, second = sorted((self.endpoint_a.key, self.endpoint_b.key))
|
|
return first, second
|
|
|
|
# Compatibility accessors for existing reports. They do not imply physical flow.
|
|
@property
|
|
def source_component(self) -> str:
|
|
return self.endpoint_a.component
|
|
|
|
@property
|
|
def source_port(self) -> str:
|
|
return self.endpoint_a.port
|
|
|
|
@property
|
|
def target_component(self) -> str:
|
|
return self.endpoint_b.component
|
|
|
|
@property
|
|
def target_port(self) -> str:
|
|
return self.endpoint_b.port
|
|
|
|
def as_interface_dict(self) -> dict[str, object]:
|
|
return {
|
|
"id": self.id,
|
|
"kind": self.kind,
|
|
"domain": self.domain,
|
|
"endpoints": [
|
|
{"component": endpoint.component, "port": endpoint.port}
|
|
for endpoint in self.endpoints
|
|
],
|
|
}
|
|
|
|
|
|
class SimulationNetwork:
|
|
"""Container for components, topology, and state-vector bookkeeping."""
|
|
|
|
def __init__(self, name: str) -> None:
|
|
self.name = name
|
|
self.components: dict[str, Component] = {}
|
|
self.connections: list[Connection] = []
|
|
|
|
def add_component(self, component: Component) -> None:
|
|
if component.name in self.components:
|
|
raise ValueError(f"Duplicate component name: {component.name}")
|
|
self.components[component.name] = component
|
|
|
|
def connect(
|
|
self,
|
|
endpoint_a_component: str,
|
|
endpoint_a_port: str,
|
|
endpoint_b_component: str,
|
|
endpoint_b_port: str,
|
|
*,
|
|
connection_id: str | None = None,
|
|
) -> 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.")
|
|
|
|
first_port = self._port_for(endpoint_a)
|
|
second_port = self._port_for(endpoint_b)
|
|
first_definition = first_port.definition
|
|
second_definition = second_port.definition
|
|
if first_definition is None or second_definition is None:
|
|
raise ValueError("Connected ports must expose interface definitions.")
|
|
if first_definition.kind != second_definition.kind:
|
|
raise ValueError(f"Connection mixes physical and signal ports: {endpoint_a}, {endpoint_b}.")
|
|
if first_definition.domain != second_definition.domain:
|
|
raise ValueError(f"Connection domains do not match: {endpoint_a}, {endpoint_b}.")
|
|
if first_definition.variables != second_definition.variables:
|
|
raise ValueError(
|
|
f"Connection variable contracts do not match: {endpoint_a}, {endpoint_b}."
|
|
)
|
|
if first_definition.kind == "signal" and {
|
|
first_definition.nominal_role,
|
|
second_definition.nominal_role,
|
|
} != {"input", "output"}:
|
|
raise ValueError("A signal connection must contain one output and one input.")
|
|
|
|
occupied_endpoints = {
|
|
endpoint
|
|
for item in self.connections
|
|
for endpoint in item.endpoints
|
|
}
|
|
if first_definition.kind == "physical":
|
|
occupied = [
|
|
str(endpoint)
|
|
for endpoint in (endpoint_a, endpoint_b)
|
|
if endpoint in occupied_endpoints
|
|
]
|
|
if occupied:
|
|
raise ValueError(
|
|
"Physical ports accept one connection; already connected: "
|
|
+ ", ".join(occupied)
|
|
+ ". Use a junction component for branching."
|
|
)
|
|
|
|
if first_definition.kind == "physical" and endpoint_b.key < endpoint_a.key:
|
|
endpoint_a, endpoint_b = endpoint_b, endpoint_a
|
|
|
|
connection = Connection(
|
|
id=connection_id or f"connection_{len(self.connections) + 1}",
|
|
kind=first_definition.kind,
|
|
domain=first_definition.domain,
|
|
endpoint_a=endpoint_a,
|
|
endpoint_b=endpoint_b,
|
|
)
|
|
if any(item.undirected_key == connection.undirected_key for item in self.connections):
|
|
raise ValueError(f"Duplicate connection between {endpoint_a} and {endpoint_b}.")
|
|
if any(item.id == connection.id for item in self.connections):
|
|
raise ValueError(f"Duplicate connection id: {connection.id}.")
|
|
self.connections.append(connection)
|
|
return connection
|
|
|
|
def _port_for(self, endpoint: Endpoint) -> PortState:
|
|
try:
|
|
component = self.components[endpoint.component]
|
|
except KeyError as exc:
|
|
raise ValueError(f"Unknown component: {endpoint.component}.") from exc
|
|
return component.get_port(endpoint.port)
|
|
|
|
def connection_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
|
"""Evaluate connector equations that have a direct scalar residual.
|
|
|
|
Stream variables are resolved by the stream-mixing layer and therefore do
|
|
not incorrectly appear here as an equality between outflow properties.
|
|
"""
|
|
|
|
residuals: list[EquationResidual] = []
|
|
for connection in self.connections:
|
|
if connection.kind != "physical":
|
|
continue
|
|
|
|
first_port = self._port_for(connection.endpoint_a)
|
|
second_port = self._port_for(connection.endpoint_b)
|
|
definition = first_port.definition
|
|
if definition is None:
|
|
raise ValueError(
|
|
f"Connected port {connection.endpoint_a} has no interface definition."
|
|
)
|
|
|
|
for variable in definition.variables:
|
|
if variable.connection_rule == "equal":
|
|
value = float(getattr(first_port, variable.name)) - float(
|
|
getattr(second_port, variable.name)
|
|
)
|
|
elif variable.connection_rule == "sumToZero":
|
|
value = float(getattr(first_port, variable.name)) + float(
|
|
getattr(second_port, variable.name)
|
|
)
|
|
else:
|
|
continue
|
|
residuals.append(
|
|
EquationResidual(
|
|
id=f"{connection.id}:{variable.name}",
|
|
owner="connection",
|
|
owner_id=connection.id,
|
|
relation=variable.connection_rule,
|
|
variables=(
|
|
f"{connection.endpoint_a}.{variable.name}",
|
|
f"{connection.endpoint_b}.{variable.name}",
|
|
),
|
|
role=variable.role,
|
|
value=value,
|
|
)
|
|
)
|
|
return tuple(residuals)
|
|
|
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
|
"""Evaluate the complete algebraic pressure-flow equation subsystem."""
|
|
|
|
component_residuals = tuple(
|
|
residual
|
|
for component in self.components.values()
|
|
for residual in component.pressure_flow_equation_residuals()
|
|
)
|
|
return component_residuals + self.connection_equation_residuals()
|
|
|
|
def pressure_flow_unknowns(self) -> tuple[str, ...]:
|
|
return tuple(
|
|
f"{component.name}.{definition.name}.{variable.name}"
|
|
for component in self.components.values()
|
|
for definition in component.port_definitions
|
|
if definition.kind == "physical"
|
|
for variable in definition.variables
|
|
if variable.role in {"effort", "flow"}
|
|
)
|
|
|
|
def pressure_flow_structure_dict(self) -> dict[str, object]:
|
|
unknowns = self.pressure_flow_unknowns()
|
|
equations = self.pressure_flow_equation_residuals()
|
|
return {
|
|
"unknownCount": len(unknowns),
|
|
"equationCount": len(equations),
|
|
"isSquare": len(unknowns) == len(equations),
|
|
"unknowns": list(unknowns),
|
|
"equations": [
|
|
equation.as_definition_dict() for equation in equations
|
|
],
|
|
}
|
|
|
|
def dynamic_components(self) -> list[DynamicComponent]:
|
|
return [
|
|
component
|
|
for component in self.components.values()
|
|
if isinstance(component, DynamicComponent)
|
|
]
|
|
|
|
def initial_state_vector(self) -> list[float]:
|
|
values: list[float] = []
|
|
for component in self.dynamic_components():
|
|
values.extend(component.get_state_vector())
|
|
return values
|
|
|
|
def apply_state_vector(self, values: list[float]) -> None:
|
|
cursor = 0
|
|
for component in self.dynamic_components():
|
|
next_cursor = cursor + component.state_size
|
|
component.set_state_vector(values[cursor:next_cursor])
|
|
cursor = next_cursor
|
|
if cursor != len(values):
|
|
raise ValueError("State vector length does not match dynamic components.")
|
|
|
|
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
|
|
return tuple(
|
|
variable
|
|
for component in self.components.values()
|
|
for variable in component.result_variable_metadata()
|
|
)
|
|
|
|
def summary(self) -> str:
|
|
lines = [f"Network: {self.name}", "Components:"]
|
|
for name, component in self.components.items():
|
|
lines.append(f" - {name}: {component.__class__.__name__}")
|
|
lines.append("Connections:")
|
|
for conn in self.connections:
|
|
connector = "<->" if conn.kind == "physical" else "->"
|
|
lines.append(
|
|
f" - {conn.endpoint_a} {connector} {conn.endpoint_b}"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
def as_interface_dict(self) -> dict[str, object]:
|
|
connected_endpoints = {
|
|
endpoint.key
|
|
for connection in self.connections
|
|
for endpoint in connection.endpoints
|
|
}
|
|
return {
|
|
"name": self.name,
|
|
"components": [
|
|
{
|
|
"id": component.name,
|
|
"type": component.model_type,
|
|
"parameters": component.parameter_interface_dicts(),
|
|
"ports": [
|
|
definition.as_interface_dict()
|
|
for definition in component.port_definitions
|
|
],
|
|
"resultVariables": [
|
|
variable.as_dict()
|
|
for variable in component.result_variable_metadata()
|
|
],
|
|
}
|
|
for component in self.components.values()
|
|
],
|
|
"connections": [
|
|
connection.as_interface_dict() for connection in self.connections
|
|
],
|
|
"pressureFlowSystem": self.pressure_flow_structure_dict(),
|
|
"unconnectedPorts": [
|
|
{"component": component.name, "port": definition.name}
|
|
for component in self.components.values()
|
|
for definition in component.port_definitions
|
|
if (component.name, definition.name) not in connected_endpoints
|
|
],
|
|
}
|
|
|