同步仿真框架并接入AMESim气动组件
This commit is contained in:
1 parent
420bafeb4e
commit
db4bdb4b70
109 files changed
+26920
-420
No files matched your search
@@ -0,0 +1,2 @@
|
||||
"""System assembly modules."""
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from math import floor, isfinite
|
||||
from typing import Literal
|
||||
|
||||
from app.simulation.core.base import DynamicComponent
|
||||
from app.simulation.core.metadata import ResultVariableMetadata
|
||||
from app.simulation.solvers.algebraic import PressureFlowSolver
|
||||
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
|
||||
from app.simulation.solvers.stream import StreamResolver
|
||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||
|
||||
|
||||
SimulationProgressCallback = Callable[[float, str], None]
|
||||
SimulationCancellationCheck = Callable[[], bool]
|
||||
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationPreparationIssue:
|
||||
code: str
|
||||
message: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"code": self.code, "message": self.message}
|
||||
|
||||
|
||||
class SimulationPreparationError(ValueError):
|
||||
def __init__(self, issues: tuple[SimulationPreparationIssue, ...]) -> None:
|
||||
super().__init__("The compiled model is not ready for simulation.")
|
||||
self.issues = issues
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenericSimulationResult:
|
||||
success: bool
|
||||
status: SimulationRunStatus
|
||||
message: str
|
||||
simulated_until: float
|
||||
requested_stop_time: float
|
||||
variables: tuple[ResultVariableMetadata, ...]
|
||||
series: dict[str, list[float]]
|
||||
final: dict[str, float]
|
||||
diagnostics: dict[str, object]
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"success": self.success,
|
||||
"status": self.status,
|
||||
"partial": self.status != "completed",
|
||||
"message": self.message,
|
||||
"simulatedUntil": self.simulated_until,
|
||||
"requestedStopTime": self.requested_stop_time,
|
||||
"variables": [variable.as_dict() for variable in self.variables],
|
||||
"series": self.series,
|
||||
"final": self.final,
|
||||
"diagnostics": self.diagnostics,
|
||||
}
|
||||
|
||||
|
||||
class _UnionFind:
|
||||
def __init__(self, items: set[Endpoint]) -> None:
|
||||
self.parent = {item: item for item in items}
|
||||
|
||||
def find(self, item: Endpoint) -> Endpoint:
|
||||
parent = self.parent[item]
|
||||
if parent != item:
|
||||
self.parent[item] = self.find(parent)
|
||||
return self.parent[item]
|
||||
|
||||
def union(self, first: Endpoint, second: Endpoint) -> None:
|
||||
first_root = self.find(first)
|
||||
second_root = self.find(second)
|
||||
if first_root != second_root:
|
||||
self.parent[second_root] = first_root
|
||||
|
||||
|
||||
def _equation_port(component_name: str, variable: str) -> Endpoint | None:
|
||||
parts = variable.rsplit(".", 2)
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
prefix, port_name, variable_name = parts
|
||||
if prefix != component_name or variable_name != "p":
|
||||
return None
|
||||
return Endpoint(component_name, port_name)
|
||||
|
||||
|
||||
def simulation_preparation_issues(
|
||||
network: SimulationNetwork,
|
||||
) -> tuple[SimulationPreparationIssue, ...]:
|
||||
issues: list[SimulationPreparationIssue] = []
|
||||
physical_endpoints = {
|
||||
Endpoint(component.name, definition.name)
|
||||
for component in network.components.values()
|
||||
for definition in component.port_definitions
|
||||
if definition.kind == "physical"
|
||||
}
|
||||
connected_endpoints = {
|
||||
endpoint
|
||||
for connection in network.connections
|
||||
if connection.kind == "physical"
|
||||
for endpoint in connection.endpoints
|
||||
}
|
||||
for endpoint in sorted(physical_endpoints - connected_endpoints, key=str):
|
||||
issues.append(
|
||||
SimulationPreparationIssue(
|
||||
"PORT_UNCONNECTED",
|
||||
f"Physical port {endpoint} must be connected before simulation.",
|
||||
)
|
||||
)
|
||||
|
||||
if any(
|
||||
definition.kind == "signal"
|
||||
for component in network.components.values()
|
||||
for definition in component.port_definitions
|
||||
):
|
||||
issues.append(
|
||||
SimulationPreparationIssue(
|
||||
"SIGNAL_PORT_UNSUPPORTED",
|
||||
"Signal-port simulation is not implemented in the current MVP solver.",
|
||||
)
|
||||
)
|
||||
|
||||
structure = network.pressure_flow_structure_dict()
|
||||
if not structure["isSquare"]:
|
||||
issues.append(
|
||||
SimulationPreparationIssue(
|
||||
"PRESSURE_FLOW_SYSTEM_NOT_SQUARE",
|
||||
"Pressure-flow equation count does not match the unknown count: "
|
||||
f"{structure['equationCount']} equations for {structure['unknownCount']} unknowns.",
|
||||
)
|
||||
)
|
||||
|
||||
dynamic_names = {
|
||||
component.name
|
||||
for component in network.components.values()
|
||||
if isinstance(component, DynamicComponent)
|
||||
}
|
||||
if not dynamic_names:
|
||||
issues.append(
|
||||
SimulationPreparationIssue(
|
||||
"DYNAMIC_STATE_MISSING",
|
||||
"Each simulated network requires at least one storage component.",
|
||||
)
|
||||
)
|
||||
|
||||
adjacency = {name: set() for name in network.components}
|
||||
for connection in network.connections:
|
||||
first, second = connection.endpoints
|
||||
adjacency[first.component].add(second.component)
|
||||
adjacency[second.component].add(first.component)
|
||||
remaining = set(adjacency)
|
||||
while remaining:
|
||||
start = remaining.pop()
|
||||
group = {start}
|
||||
stack = [start]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
for neighbour in adjacency[current] - group:
|
||||
group.add(neighbour)
|
||||
remaining.discard(neighbour)
|
||||
stack.append(neighbour)
|
||||
if not (group & dynamic_names):
|
||||
issues.append(
|
||||
SimulationPreparationIssue(
|
||||
"ALGEBRAIC_ISLAND_HAS_NO_STORAGE",
|
||||
"A connected physical network has no pressure/enthalpy storage anchor: "
|
||||
+ ", ".join(sorted(group))
|
||||
+ ".",
|
||||
)
|
||||
)
|
||||
|
||||
if physical_endpoints:
|
||||
effort_groups = _UnionFind(physical_endpoints)
|
||||
for connection in network.connections:
|
||||
if connection.kind == "physical":
|
||||
effort_groups.union(*connection.endpoints)
|
||||
storage_ports: dict[Endpoint, str] = {}
|
||||
for component in network.components.values():
|
||||
for equation in component.pressure_flow_equation_residuals():
|
||||
pressure_ports = [
|
||||
endpoint
|
||||
for variable in equation.variables
|
||||
if (endpoint := _equation_port(component.name, variable)) is not None
|
||||
]
|
||||
if equation.relation == "equal" and len(pressure_ports) == 2:
|
||||
effort_groups.union(pressure_ports[0], pressure_ports[1])
|
||||
if equation.relation == "state":
|
||||
for endpoint in pressure_ports:
|
||||
storage_ports[endpoint] = component.name
|
||||
|
||||
storages_by_group: dict[Endpoint, set[str]] = {}
|
||||
for endpoint, component_name in storage_ports.items():
|
||||
storages_by_group.setdefault(effort_groups.find(endpoint), set()).add(
|
||||
component_name
|
||||
)
|
||||
for storage_names in storages_by_group.values():
|
||||
if len(storage_names) > 1:
|
||||
issues.append(
|
||||
SimulationPreparationIssue(
|
||||
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
|
||||
"Storage components are connected without a resistance: "
|
||||
+ ", ".join(sorted(storage_names))
|
||||
+ ". Insert an orifice or pipe between them.",
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def simulation_sample_times(
|
||||
config: SolveIVPConfig,
|
||||
step: float,
|
||||
*,
|
||||
max_points: int = 10001,
|
||||
) -> list[float]:
|
||||
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
|
||||
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}."
|
||||
)
|
||||
return times
|
||||
|
||||
|
||||
class GenericFluidSystem:
|
||||
"""Topology-driven, semi-explicit fluid simulation for registered components."""
|
||||
|
||||
def __init__(self, network: SimulationNetwork) -> None:
|
||||
issues = simulation_preparation_issues(network)
|
||||
if issues:
|
||||
raise SimulationPreparationError(issues)
|
||||
self.network = network
|
||||
self.dynamic_components = network.dynamic_components()
|
||||
self.pressure_flow_solver = PressureFlowSolver(network)
|
||||
self.stream_resolver = StreamResolver(network)
|
||||
self.algebraic_solve_count = 0
|
||||
self.max_algebraic_residual = 0.0
|
||||
self.max_algebraic_evaluations = 0
|
||||
self.max_stream_iterations = 0
|
||||
|
||||
def initial_state_vector(self) -> list[float]:
|
||||
return self.network.initial_state_vector()
|
||||
|
||||
def apply_state_vector(self, values: list[float]) -> None:
|
||||
self.network.apply_state_vector(values)
|
||||
|
||||
def _close_current_state(self) -> dict[str, dict[str, float]]:
|
||||
for component in self.dynamic_components:
|
||||
component.refresh_thermodynamic_ports()
|
||||
algebraic = self.pressure_flow_solver.solve()
|
||||
stream, connected_h = self.stream_resolver.solve()
|
||||
self.algebraic_solve_count += 1
|
||||
self.max_algebraic_residual = max(
|
||||
self.max_algebraic_residual,
|
||||
algebraic.max_scaled_residual,
|
||||
)
|
||||
self.max_algebraic_evaluations = max(
|
||||
self.max_algebraic_evaluations,
|
||||
algebraic.evaluations,
|
||||
)
|
||||
self.max_stream_iterations = max(
|
||||
self.max_stream_iterations,
|
||||
stream.iterations,
|
||||
)
|
||||
return connected_h
|
||||
|
||||
def consistent_initial_state_vector(self) -> list[float]:
|
||||
state = self.initial_state_vector()
|
||||
self.apply_state_vector(state)
|
||||
self._close_current_state()
|
||||
return state
|
||||
|
||||
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
|
||||
self.apply_state_vector(state_vector)
|
||||
connected_h = self._close_current_state()
|
||||
derivatives: list[float] = []
|
||||
for component in self.dynamic_components:
|
||||
derivatives.extend(
|
||||
component.state_derivative_from_ports(connected_h[component.name])
|
||||
)
|
||||
return derivatives
|
||||
|
||||
def _append_current_state(self, series: dict[str, list[float]]) -> None:
|
||||
for component in self.network.components.values():
|
||||
for relative_key, value in component.result_values().items():
|
||||
series.setdefault(
|
||||
f"{component.name}.{relative_key}", []
|
||||
).append(value)
|
||||
|
||||
def simulate(
|
||||
self,
|
||||
config: SolveIVPConfig,
|
||||
*,
|
||||
sample_step: float,
|
||||
progress_callback: SimulationProgressCallback | None = None,
|
||||
cancel_check: SimulationCancellationCheck | None = None,
|
||||
) -> GenericSimulationResult:
|
||||
last_reported_progress = -1.0
|
||||
last_reported_phase = ""
|
||||
|
||||
def report_progress(
|
||||
progress: float,
|
||||
phase: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
nonlocal last_reported_phase, last_reported_progress
|
||||
if progress_callback is None:
|
||||
return
|
||||
bounded_progress = min(1.0, max(0.0, progress))
|
||||
if (
|
||||
force
|
||||
or phase != last_reported_phase
|
||||
or bounded_progress - last_reported_progress >= 0.0025
|
||||
):
|
||||
last_reported_phase = phase
|
||||
last_reported_progress = max(
|
||||
last_reported_progress,
|
||||
bounded_progress,
|
||||
)
|
||||
progress_callback(last_reported_progress, phase)
|
||||
|
||||
report_progress(0.0, "initializing", force=True)
|
||||
t_eval = simulation_sample_times(config, sample_step)
|
||||
initial_state = self.consistent_initial_state_vector()
|
||||
report_progress(0.0, "integrating", force=True)
|
||||
duration = config.t_stop - config.t_start
|
||||
furthest_solver_time = config.t_start
|
||||
|
||||
def report_solver_time(time: float) -> None:
|
||||
nonlocal furthest_solver_time
|
||||
furthest_solver_time = max(furthest_solver_time, float(time))
|
||||
time_fraction = (
|
||||
(furthest_solver_time - config.t_start) / duration
|
||||
if duration > 0.0
|
||||
else 1.0
|
||||
)
|
||||
report_progress(time_fraction, "integrating")
|
||||
|
||||
def monitored_rhs(time: float, state_vector: list[float]) -> list[float]:
|
||||
if cancel_check is None:
|
||||
report_solver_time(time)
|
||||
return self.rhs(time, state_vector)
|
||||
|
||||
solution = integrate_ode(
|
||||
rhs=monitored_rhs,
|
||||
initial_state=initial_state,
|
||||
config=config,
|
||||
t_eval=t_eval,
|
||||
cancel_check=cancel_check,
|
||||
accepted_step_callback=(
|
||||
report_solver_time if cancel_check is not None else None
|
||||
),
|
||||
)
|
||||
if isinstance(solution, ODESolution):
|
||||
run_status: SimulationRunStatus = solution.status
|
||||
integration_error = solution.error
|
||||
else:
|
||||
run_status = "completed" if bool(solution.success) else "failed"
|
||||
integration_error = None
|
||||
result_message = str(solution.message)
|
||||
postprocess_progress = (
|
||||
1.0
|
||||
if run_status == "completed"
|
||||
else max(0.0, last_reported_progress)
|
||||
)
|
||||
report_progress(postprocess_progress, "postprocessing", force=True)
|
||||
times = [float(value) for value in solution.t]
|
||||
series: dict[str, list[float]] = {"time": []}
|
||||
postprocessing_error: Exception | None = None
|
||||
for time_index in range(len(times)):
|
||||
if (
|
||||
run_status == "completed"
|
||||
and cancel_check is not None
|
||||
and cancel_check()
|
||||
):
|
||||
run_status = "cancelled"
|
||||
result_message = "Simulation was stopped while preparing partial results."
|
||||
break
|
||||
state = [
|
||||
float(solution.y[state_index][time_index])
|
||||
for state_index in range(len(solution.y))
|
||||
]
|
||||
try:
|
||||
self.apply_state_vector(state)
|
||||
self._close_current_state()
|
||||
self._append_current_state(series)
|
||||
series["time"].append(times[time_index])
|
||||
except Exception as exc:
|
||||
run_status = "failed"
|
||||
result_message = str(exc)
|
||||
postprocessing_error = exc
|
||||
break
|
||||
if len(series["time"]) < 2:
|
||||
if postprocessing_error is not None:
|
||||
raise postprocessing_error
|
||||
if integration_error is not None:
|
||||
raise integration_error
|
||||
|
||||
final = {
|
||||
key: values[-1]
|
||||
for key, values in series.items()
|
||||
if key != "time" and values
|
||||
}
|
||||
diagnostics = {
|
||||
"pressureFlow": {
|
||||
"solveCount": self.algebraic_solve_count,
|
||||
"maxScaledResidual": self.max_algebraic_residual,
|
||||
"maxEvaluationsPerSolve": self.max_algebraic_evaluations,
|
||||
"last": (
|
||||
self.pressure_flow_solver.last_diagnostics.as_dict()
|
||||
if self.pressure_flow_solver.last_diagnostics is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"stream": {
|
||||
"maxIterationsPerSolve": self.max_stream_iterations,
|
||||
"last": (
|
||||
self.stream_resolver.last_diagnostics.as_dict()
|
||||
if self.stream_resolver.last_diagnostics is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"stateCount": len(initial_state),
|
||||
"sampleCount": len(series["time"]),
|
||||
}
|
||||
variables = tuple(
|
||||
variable
|
||||
for variable in self.network.result_variable_metadata()
|
||||
if variable.key in series
|
||||
)
|
||||
report_progress(
|
||||
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
|
||||
"complete" if run_status == "completed" else run_status,
|
||||
force=True,
|
||||
)
|
||||
return GenericSimulationResult(
|
||||
success=run_status == "completed" and bool(solution.success),
|
||||
status=run_status,
|
||||
message=result_message,
|
||||
simulated_until=(
|
||||
float(series["time"][-1])
|
||||
if series["time"]
|
||||
else float(config.t_start)
|
||||
),
|
||||
requested_stop_time=float(config.t_stop),
|
||||
variables=variables,
|
||||
series=series,
|
||||
final=final,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
@@ -0,0 +1,315 @@
|
||||
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
|
||||
],
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user