完善 XML 通用仿真与结果查看
This commit is contained in:
1 parent
104d41d294
commit
f1256a121d
36 files changed
+6666
-196
No files matched your search
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import sqrt
|
||||
|
||||
from PythonModels.core.network import SimulationNetwork
|
||||
from PythonModels.core.ports import PortState, VariableRole
|
||||
|
||||
|
||||
class AlgebraicSolveError(RuntimeError):
|
||||
def __init__(self, message: str, diagnostics: "AlgebraicSolveDiagnostics") -> None:
|
||||
super().__init__(message)
|
||||
self.diagnostics = diagnostics
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlgebraicUnknown:
|
||||
component: str
|
||||
port: str
|
||||
variable: str
|
||||
role: VariableRole
|
||||
state: PortState
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return f"{self.component}.{self.port}.{self.variable}"
|
||||
|
||||
def read(self) -> float:
|
||||
return float(getattr(self.state, self.variable))
|
||||
|
||||
def write(self, value: float) -> None:
|
||||
setattr(self.state, self.variable, float(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlgebraicSolveDiagnostics:
|
||||
success: bool
|
||||
message: str
|
||||
evaluations: int
|
||||
pressure_scale: float
|
||||
flow_scale: float
|
||||
max_scaled_residual: float
|
||||
max_raw_residual: float
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"success": self.success,
|
||||
"message": self.message,
|
||||
"evaluations": self.evaluations,
|
||||
"pressureScale": self.pressure_scale,
|
||||
"flowScale": self.flow_scale,
|
||||
"maxScaledResidual": self.max_scaled_residual,
|
||||
"maxRawResidual": self.max_raw_residual,
|
||||
}
|
||||
|
||||
|
||||
class PressureFlowSolver:
|
||||
"""Solve the acausal pressure-flow subsystem for a compiled network."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
network: SimulationNetwork,
|
||||
*,
|
||||
residual_tolerance: float = 1e-7,
|
||||
max_evaluations: int = 500,
|
||||
) -> None:
|
||||
self.network = network
|
||||
self.residual_tolerance = residual_tolerance
|
||||
self.max_evaluations = max_evaluations
|
||||
self.unknowns = self._build_unknowns()
|
||||
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
|
||||
|
||||
def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]:
|
||||
unknowns: list[AlgebraicUnknown] = []
|
||||
for component in self.network.components.values():
|
||||
for definition in component.port_definitions:
|
||||
if definition.kind != "physical":
|
||||
continue
|
||||
state = component.get_port(definition.name)
|
||||
for variable in definition.variables:
|
||||
if variable.role not in {"effort", "flow"}:
|
||||
continue
|
||||
unknowns.append(
|
||||
AlgebraicUnknown(
|
||||
component=component.name,
|
||||
port=definition.name,
|
||||
variable=variable.name,
|
||||
role=variable.role,
|
||||
state=state,
|
||||
)
|
||||
)
|
||||
return tuple(unknowns)
|
||||
|
||||
def _seed_equal_pressures(self) -> None:
|
||||
for _ in range(max(2, len(self.network.connections))):
|
||||
changed = False
|
||||
for connection in self.network.connections:
|
||||
if connection.kind != "physical":
|
||||
continue
|
||||
first = self.network.components[
|
||||
connection.endpoint_a.component
|
||||
].get_port(connection.endpoint_a.port)
|
||||
second = self.network.components[
|
||||
connection.endpoint_b.component
|
||||
].get_port(connection.endpoint_b.port)
|
||||
if first.p > 0.0 and second.p <= 0.0:
|
||||
second.p = first.p
|
||||
changed = True
|
||||
elif second.p > 0.0 and first.p <= 0.0:
|
||||
first.p = second.p
|
||||
changed = True
|
||||
|
||||
for component in self.network.components.values():
|
||||
equal_pressure_equations = [
|
||||
equation
|
||||
for equation in component.pressure_flow_equation_residuals()
|
||||
if equation.relation == "equal" and equation.role == "effort"
|
||||
]
|
||||
for equation in equal_pressure_equations:
|
||||
states = []
|
||||
for variable in equation.variables:
|
||||
_, port_name, variable_name = variable.rsplit(".", 2)
|
||||
if variable_name == "p":
|
||||
states.append(component.get_port(port_name))
|
||||
if len(states) != 2:
|
||||
continue
|
||||
first, second = states
|
||||
if first.p > 0.0 and second.p <= 0.0:
|
||||
second.p = first.p
|
||||
changed = True
|
||||
elif second.p > 0.0 and first.p <= 0.0:
|
||||
first.p = second.p
|
||||
changed = True
|
||||
if not changed:
|
||||
break
|
||||
|
||||
def _scales(self) -> tuple[float, float]:
|
||||
pressure_scale = max(
|
||||
[
|
||||
abs(unknown.read())
|
||||
for unknown in self.unknowns
|
||||
if unknown.role == "effort" and unknown.read() > 0.0
|
||||
]
|
||||
+ [1e5]
|
||||
)
|
||||
estimated_flows = [
|
||||
abs(float(getattr(component, "K_eff"))) * sqrt(pressure_scale)
|
||||
for component in self.network.components.values()
|
||||
if hasattr(component, "K_eff")
|
||||
]
|
||||
flow_scale = max(
|
||||
estimated_flows
|
||||
+ [
|
||||
abs(unknown.read())
|
||||
for unknown in self.unknowns
|
||||
if unknown.role == "flow"
|
||||
]
|
||||
+ [1e-3]
|
||||
)
|
||||
return pressure_scale, flow_scale
|
||||
|
||||
def solve(self) -> AlgebraicSolveDiagnostics:
|
||||
try:
|
||||
import numpy as np
|
||||
from scipy.optimize import least_squares
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"Topology-driven simulation requires SciPy; install requirements.txt."
|
||||
) from exc
|
||||
|
||||
self._seed_equal_pressures()
|
||||
pressure_scale, flow_scale = self._scales()
|
||||
positive_pressures = [
|
||||
unknown.read()
|
||||
for unknown in self.unknowns
|
||||
if unknown.role == "effort" and unknown.read() > 0.0
|
||||
]
|
||||
fallback_pressure = (
|
||||
sum(positive_pressures) / len(positive_pressures)
|
||||
if positive_pressures
|
||||
else pressure_scale
|
||||
)
|
||||
|
||||
def variable_scale(unknown: AlgebraicUnknown) -> float:
|
||||
return pressure_scale if unknown.role == "effort" else flow_scale
|
||||
|
||||
x0 = np.asarray(
|
||||
[
|
||||
(
|
||||
unknown.read()
|
||||
if unknown.role != "effort" or unknown.read() > 0.0
|
||||
else fallback_pressure
|
||||
)
|
||||
/ variable_scale(unknown)
|
||||
for unknown in self.unknowns
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
lower = np.asarray(
|
||||
[
|
||||
1.0 / pressure_scale if unknown.role == "effort" else -np.inf
|
||||
for unknown in self.unknowns
|
||||
]
|
||||
)
|
||||
upper = np.full(len(self.unknowns), np.inf)
|
||||
|
||||
def assign(values) -> None:
|
||||
for unknown, value in zip(self.unknowns, values):
|
||||
unknown.write(float(value) * variable_scale(unknown))
|
||||
|
||||
def scaled_residuals(values):
|
||||
assign(values)
|
||||
equations = self.network.pressure_flow_equation_residuals()
|
||||
return np.asarray(
|
||||
[
|
||||
equation.value
|
||||
/ (pressure_scale if equation.role == "effort" else flow_scale)
|
||||
for equation in equations
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
|
||||
result = least_squares(
|
||||
scaled_residuals,
|
||||
x0,
|
||||
bounds=(lower, upper),
|
||||
x_scale="jac",
|
||||
ftol=1e-10,
|
||||
xtol=1e-10,
|
||||
gtol=1e-10,
|
||||
max_nfev=self.max_evaluations,
|
||||
)
|
||||
assign(result.x)
|
||||
equations = self.network.pressure_flow_equation_residuals()
|
||||
scaled = [
|
||||
abs(
|
||||
equation.value
|
||||
/ (pressure_scale if equation.role == "effort" else flow_scale)
|
||||
)
|
||||
for equation in equations
|
||||
]
|
||||
success = bool(result.success) and max(scaled, default=0.0) <= self.residual_tolerance
|
||||
diagnostics = AlgebraicSolveDiagnostics(
|
||||
success=success,
|
||||
message=str(result.message),
|
||||
evaluations=int(result.nfev),
|
||||
pressure_scale=pressure_scale,
|
||||
flow_scale=flow_scale,
|
||||
max_scaled_residual=max(scaled, default=0.0),
|
||||
max_raw_residual=max((abs(item.value) for item in equations), default=0.0),
|
||||
)
|
||||
self.last_diagnostics = diagnostics
|
||||
if not success:
|
||||
raise AlgebraicSolveError(
|
||||
"Pressure-flow equations did not converge to the requested tolerance.",
|
||||
diagnostics,
|
||||
)
|
||||
return diagnostics
|
||||
@@ -1,11 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from PythonModels.core.equations import EquationResidual
|
||||
from PythonModels.core.ports import PortDefinition, PortState
|
||||
|
||||
|
||||
class Component(ABC):
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.model_type = self.__class__.__name__.lower()
|
||||
self._ports: dict[str, PortState] = {}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, PortState]:
|
||||
return dict(self._ports)
|
||||
|
||||
@property
|
||||
def port_definitions(self) -> tuple[PortDefinition, ...]:
|
||||
return tuple(
|
||||
port.definition
|
||||
for port in self._ports.values()
|
||||
if port.definition is not None
|
||||
)
|
||||
|
||||
def register_port(self, port: PortState) -> PortState:
|
||||
definition = port.definition
|
||||
if definition is None:
|
||||
raise ValueError(f"Component {self.name} cannot register an undefined port.")
|
||||
if definition.name in self._ports:
|
||||
raise ValueError(f"Duplicate port {self.name}.{definition.name}.")
|
||||
self._ports[definition.name] = port
|
||||
return port
|
||||
|
||||
def get_port(self, name: str) -> PortState:
|
||||
try:
|
||||
return self._ports[name]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Component {self.name} has no port named {name}.") from exc
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
"""Return algebraic residuals after the network assigns port states."""
|
||||
|
||||
return ()
|
||||
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
"""Update connector outflow properties from current flow directions."""
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class DynamicComponent(Component):
|
||||
@@ -43,6 +87,15 @@ class DynamicComponent(Component):
|
||||
def set_state_vector(self, values: list[float]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def refresh_thermodynamic_ports(self) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
def state_derivative_from_ports(
|
||||
self,
|
||||
connected_h: Mapping[str, float],
|
||||
) -> list[float]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class AlgebraicComponent(Component):
|
||||
"""Stateless element described by algebraic constraints only."""
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from PythonModels.core.ports import VariableRole
|
||||
|
||||
|
||||
EquationOwner = Literal["connection", "component"]
|
||||
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EquationResidual:
|
||||
"""One executable scalar equation in the pressure-flow subsystem."""
|
||||
|
||||
id: str
|
||||
owner: EquationOwner
|
||||
owner_id: str
|
||||
relation: EquationRelation
|
||||
variables: tuple[str, ...]
|
||||
value: float
|
||||
role: VariableRole | None = None
|
||||
|
||||
def as_definition_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"owner": self.owner,
|
||||
"ownerId": self.owner_id,
|
||||
"relation": self.relation,
|
||||
"variables": list(self.variables),
|
||||
"role": self.role,
|
||||
}
|
||||
|
||||
def as_interface_dict(self) -> dict[str, object]:
|
||||
return {**self.as_definition_dict(), "residual": self.value}
|
||||
+241
-17
@@ -3,14 +3,67 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.core.base import Component, DynamicComponent
|
||||
from PythonModels.core.equations import EquationResidual
|
||||
from PythonModels.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:
|
||||
source_component: str
|
||||
source_port: str
|
||||
target_component: str
|
||||
target_port: str
|
||||
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:
|
||||
@@ -28,19 +81,159 @@ class SimulationNetwork:
|
||||
|
||||
def connect(
|
||||
self,
|
||||
source_component: str,
|
||||
source_port: str,
|
||||
target_component: str,
|
||||
target_port: str,
|
||||
) -> None:
|
||||
self.connections.append(
|
||||
Connection(
|
||||
source_component=source_component,
|
||||
source_port=source_port,
|
||||
target_component=target_component,
|
||||
target_port=target_port,
|
||||
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 [
|
||||
@@ -70,9 +263,40 @@ class SimulationNetwork:
|
||||
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.source_component}.{conn.source_port}"
|
||||
f" -> {conn.target_component}.{conn.target_port}"
|
||||
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,
|
||||
"ports": [
|
||||
definition.as_interface_dict()
|
||||
for definition in component.port_definitions
|
||||
],
|
||||
}
|
||||
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
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,6 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
|
||||
PortKind = Literal["physical", "signal"]
|
||||
PortNominalRole = Literal["inlet", "outlet", "bidirectional", "input", "output"]
|
||||
ActualFlowDirection = Literal["in", "out", "stagnant"]
|
||||
VariableRole = Literal["effort", "flow", "stream", "signal"]
|
||||
ConnectionRule = Literal["equal", "sumToZero", "streamMix", "directed"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortVariableDefinition:
|
||||
name: str
|
||||
role: VariableRole
|
||||
connection_rule: ConnectionRule
|
||||
|
||||
def as_interface_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"role": self.role,
|
||||
"connectionRule": self.connection_rule,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortDefinition:
|
||||
"""Stable connector contract shared by components, XML, and the compiler."""
|
||||
|
||||
name: str
|
||||
kind: PortKind
|
||||
domain: str
|
||||
nominal_role: PortNominalRole
|
||||
positive_flow_direction: Literal["intoComponent"] | None = None
|
||||
variables: tuple[PortVariableDefinition, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def pneumatic(
|
||||
cls,
|
||||
name: str,
|
||||
*,
|
||||
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
|
||||
) -> PortDefinition:
|
||||
return cls(
|
||||
name=name,
|
||||
kind="physical",
|
||||
domain="pneumatic",
|
||||
nominal_role=nominal_role,
|
||||
positive_flow_direction="intoComponent",
|
||||
variables=(
|
||||
PortVariableDefinition("p", "effort", "equal"),
|
||||
PortVariableDefinition("m_flow", "flow", "sumToZero"),
|
||||
PortVariableDefinition("h_outflow", "stream", "streamMix"),
|
||||
),
|
||||
)
|
||||
|
||||
def as_interface_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"kind": self.kind,
|
||||
"domain": self.domain,
|
||||
"nominalRole": self.nominal_role,
|
||||
"positiveFlowDirection": self.positive_flow_direction,
|
||||
"variables": [variable.as_interface_dict() for variable in self.variables],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -10,4 +74,29 @@ class PortState:
|
||||
p: float = 0.0
|
||||
m_flow: float = 0.0
|
||||
h_outflow: float = 0.0
|
||||
definition: PortDefinition | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
@classmethod
|
||||
def pneumatic(
|
||||
cls,
|
||||
name: str,
|
||||
*,
|
||||
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
|
||||
) -> PortState:
|
||||
return cls(definition=PortDefinition.pneumatic(name, nominal_role=nominal_role))
|
||||
|
||||
@property
|
||||
def inflow_rate(self) -> float:
|
||||
return max(self.m_flow, 0.0)
|
||||
|
||||
@property
|
||||
def outflow_rate(self) -> float:
|
||||
return max(-self.m_flow, 0.0)
|
||||
|
||||
def actual_direction(self, tolerance: float = 1e-12) -> ActualFlowDirection:
|
||||
if self.m_flow > tolerance:
|
||||
return "in"
|
||||
if self.m_flow < -tolerance:
|
||||
return "out"
|
||||
return "stagnant"
|
||||
|
||||
@@ -98,5 +98,6 @@ def integrate_ode(
|
||||
method=config.method,
|
||||
rtol=config.rtol,
|
||||
atol=config.atol,
|
||||
max_step=config.max_step,
|
||||
t_eval=t_eval,
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.core.base import DynamicComponent
|
||||
from PythonModels.core.network import Endpoint, SimulationNetwork
|
||||
|
||||
|
||||
class StreamSolveError(RuntimeError):
|
||||
def __init__(self, message: str, diagnostics: "StreamSolveDiagnostics") -> None:
|
||||
super().__init__(message)
|
||||
self.diagnostics = diagnostics
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamSolveDiagnostics:
|
||||
converged: bool
|
||||
iterations: int
|
||||
max_delta: float
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"converged": self.converged,
|
||||
"iterations": self.iterations,
|
||||
"maxDelta": self.max_delta,
|
||||
}
|
||||
|
||||
|
||||
class StreamResolver:
|
||||
"""Resolve outflow enthalpy propagation after pressure and flow are known."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
network: SimulationNetwork,
|
||||
*,
|
||||
relative_tolerance: float = 1e-9,
|
||||
max_iterations: int = 100,
|
||||
) -> None:
|
||||
self.network = network
|
||||
self.relative_tolerance = relative_tolerance
|
||||
self.max_iterations = max_iterations
|
||||
self._connected_endpoint = self._build_connection_map()
|
||||
self.last_diagnostics: StreamSolveDiagnostics | None = None
|
||||
|
||||
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
|
||||
result: dict[Endpoint, Endpoint] = {}
|
||||
for connection in self.network.connections:
|
||||
if connection.kind != "physical":
|
||||
continue
|
||||
first, second = connection.endpoints
|
||||
result[first] = second
|
||||
result[second] = first
|
||||
return result
|
||||
|
||||
def connected_enthalpies(self) -> dict[str, dict[str, float]]:
|
||||
values: dict[str, dict[str, float]] = {
|
||||
component.name: {} for component in self.network.components.values()
|
||||
}
|
||||
for endpoint, connected in self._connected_endpoint.items():
|
||||
connected_port = self.network.components[connected.component].get_port(
|
||||
connected.port
|
||||
)
|
||||
values[endpoint.component][endpoint.port] = connected_port.h_outflow
|
||||
return values
|
||||
|
||||
def solve(self) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
|
||||
dynamic_components = [
|
||||
component
|
||||
for component in self.network.components.values()
|
||||
if isinstance(component, DynamicComponent)
|
||||
]
|
||||
for component in dynamic_components:
|
||||
component.refresh_thermodynamic_ports()
|
||||
|
||||
max_delta = 0.0
|
||||
for iteration in range(1, self.max_iterations + 1):
|
||||
previous = {
|
||||
(component.name, port_name): port.h_outflow
|
||||
for component in self.network.components.values()
|
||||
for port_name, port in component.ports.items()
|
||||
}
|
||||
connected = self.connected_enthalpies()
|
||||
for component in self.network.components.values():
|
||||
if isinstance(component, DynamicComponent):
|
||||
component.refresh_thermodynamic_ports()
|
||||
else:
|
||||
component.update_stream_outflows(connected[component.name])
|
||||
|
||||
deltas = [
|
||||
abs(port.h_outflow - previous[(component.name, port_name)])
|
||||
for component in self.network.components.values()
|
||||
for port_name, port in component.ports.items()
|
||||
]
|
||||
magnitudes = [
|
||||
abs(port.h_outflow)
|
||||
for component in self.network.components.values()
|
||||
for port in component.ports.values()
|
||||
]
|
||||
max_delta = max(deltas, default=0.0)
|
||||
scale = max(magnitudes + [1.0])
|
||||
if max_delta <= self.relative_tolerance * scale:
|
||||
diagnostics = StreamSolveDiagnostics(
|
||||
converged=True,
|
||||
iterations=iteration,
|
||||
max_delta=max_delta,
|
||||
)
|
||||
self.last_diagnostics = diagnostics
|
||||
return diagnostics, self.connected_enthalpies()
|
||||
|
||||
diagnostics = StreamSolveDiagnostics(
|
||||
converged=False,
|
||||
iterations=self.max_iterations,
|
||||
max_delta=max_delta,
|
||||
)
|
||||
self.last_diagnostics = diagnostics
|
||||
raise StreamSolveError(
|
||||
"Stream enthalpy propagation did not converge.",
|
||||
diagnostics,
|
||||
)
|
||||
Reference in new issue
Block a user