完善 XML 通用仿真与结果查看

This commit is contained in:
ljz committed 2026-07-21 13:42:55 +08:00
1 parent 104d41d294
commit f1256a121d
36 files changed
+6666 -196

No files matched your search

+3 -2
View File
@@ -165,12 +165,13 @@
`testmodel_tank_temperature.svg`
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
当前没有实现:
- 通用 DAE 初始化器
- `Modelica.Media.Air.SimpleAir` 的严格复刻
- 面向任意拓扑的通用 connector/stream 求解器
- 一般高指数 DAE、事件和严格 Modelica `inStream/actualStream` 求解器
## 当前怎么运行
@@ -288,7 +289,7 @@ print(result.used_modelica_reference)
- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。
- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。
- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。
- 当前 `Tee` 的 stream 语义只覆盖了当前 `Testmodel` 需要的最小集合,还不是通用的 `inStream/actualStream` 框架。
- 通用 XML 求解链路已经支持按实际流向传播和三通混合 stream 焓,但仍是正则化 MVP,不是严格的 Modelica `inStream/actualStream` 框架。
- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。
- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。
- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。
+39 -1
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
from collections.abc import Mapping
from PythonModels.core.base import DynamicComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState
@@ -23,7 +26,9 @@ class Cylinder(DynamicComponent):
m0 = p0 * V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_b = PortState()
self.port_b = self.register_port(
PortState.pneumatic("port_b", nominal_role="outlet")
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
@@ -37,6 +42,39 @@ class Cylinder(DynamicComponent):
self.port_b.h_outflow = props.h
return props
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
return self.properties()
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
properties = self.properties()
derivative = self.derivatives_from_connection(
connected_h=connected_h["port_b"],
port_m_flow=self.port_b.m_flow,
internal_h=properties.h,
)
return derivative.as_vector()
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.V,
).p
return (
EquationResidual(
id=f"{self.name}:port_b_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_b.p", f"{self.name}.state"),
role="effort",
value=self.port_b.p - pressure,
),
)
def derivatives_from_connection(
self,
*,
+42 -2
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
from collections.abc import Mapping
from math import sqrt
from PythonModels.core.base import AlgebraicComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.ports import PortState
@@ -13,8 +15,12 @@ class Orifice(AlgebraicComponent):
super().__init__(name=name)
self.opening = opening
self.K = K
self.port_a = PortState()
self.port_b = PortState()
self.port_a = self.register_port(
PortState.pneumatic("port_a", nominal_role="inlet")
)
self.port_b = self.register_port(
PortState.pneumatic("port_b", nominal_role="outlet")
)
@property
def K_eff(self) -> float:
@@ -26,3 +32,37 @@ class Orifice(AlgebraicComponent):
return 0.0
return self.K_eff * sqrt(abs(dp)) * (1.0 if dp > 0.0 else -1.0)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=(
f"{self.name}.port_a.m_flow",
f"{self.name}.port_b.m_flow",
),
role="flow",
value=self.port_a.m_flow + self.port_b.m_flow,
),
EquationResidual(
id=f"{self.name}:pressure_flow_relation",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.port_a.p",
f"{self.name}.port_b.p",
f"{self.name}.port_a.m_flow",
),
role="flow",
value=self.port_a.m_flow
- self.mass_flow(self.port_a.p, self.port_b.p),
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_a.h_outflow = connected_h["port_b"]
self.port_b.h_outflow = connected_h["port_a"]
+62 -2
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
from collections.abc import Mapping
from PythonModels.core.base import DynamicComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState
@@ -29,8 +32,12 @@ class Pipe(DynamicComponent):
m0 = p0 * self.V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_a = PortState()
self.port_b = PortState()
self.port_a = self.register_port(
PortState.pneumatic("port_a", nominal_role="inlet")
)
self.port_b = self.register_port(
PortState.pneumatic("port_b", nominal_role="outlet")
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
@@ -45,11 +52,64 @@ class Pipe(DynamicComponent):
self.port_b.h_outflow = props.h
return props
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
return self.properties()
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
properties = self.properties()
derivative = self.derivatives_from_connections(
port_a_m_flow=self.port_a.m_flow,
connected_h_a=connected_h["port_a"],
port_b_m_flow=self.port_b.m_flow,
connected_h_b=connected_h["port_b"],
internal_h=properties.h,
)
return derivative.as_vector()
def inlet_pressure(self, m_flow_a: float, rho: float, core_pressure: float) -> float:
resistance = self.lambda_darcy * (self.L / self.D)
dynamic_term = m_flow_a * abs(m_flow_a) / (2.0 * rho * self.area * self.area)
return core_pressure + resistance * dynamic_term
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
properties = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.V,
)
expected_inlet_pressure = self.inlet_pressure(
self.port_a.m_flow,
max(properties.rho, 1e-12),
properties.p,
)
return (
EquationResidual(
id=f"{self.name}:darcy_pressure_loss",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.port_a.p",
f"{self.name}.port_a.m_flow",
f"{self.name}.state",
),
role="effort",
value=self.port_a.p - expected_inlet_pressure,
),
EquationResidual(
id=f"{self.name}:port_b_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_b.p", f"{self.name}.state"),
role="effort",
value=self.port_b.p - properties.p,
),
)
def port_a_inlet_enthalpy(
self,
*,
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
from collections.abc import Mapping
from math import pi
from PythonModels.core.base import AlgebraicComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.medium import IdealGasMedium
from PythonModels.core.ports import PortState
class ResistivePipe(AlgebraicComponent):
"""Quasi-steady Darcy resistance used by topology-driven simulation."""
def __init__(
self,
name: str,
medium: IdealGasMedium,
L: float = 5.0,
D: float = 0.02,
lambda_darcy: float = 0.02,
p0: float = 1e5,
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.medium = medium
self.L = L
self.D = D
self.lambda_darcy = lambda_darcy
self.p0 = p0
self.T0 = T0
self.area = pi * D * D / 4.0
initial_h = medium.specific_enthalpy(T0)
self.port_a = PortState.pneumatic("port_a", nominal_role="inlet")
self.port_a.p = p0
self.port_a.h_outflow = initial_h
self.register_port(self.port_a)
self.port_b = PortState.pneumatic("port_b", nominal_role="outlet")
self.port_b.p = p0
self.port_b.h_outflow = initial_h
self.register_port(self.port_b)
def pressure_drop(self, m_flow_a: float, p_a: float, p_b: float) -> float:
average_pressure = max(0.5 * (p_a + p_b), 1.0)
density = max(self.medium.density(average_pressure, self.T0), 1e-12)
resistance = self.lambda_darcy * (self.L / self.D)
return (
resistance
* m_flow_a
* abs(m_flow_a)
/ (2.0 * density * self.area * self.area)
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=(
f"{self.name}.port_a.m_flow",
f"{self.name}.port_b.m_flow",
),
role="flow",
value=self.port_a.m_flow + self.port_b.m_flow,
),
EquationResidual(
id=f"{self.name}:darcy_pressure_loss",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.port_a.p",
f"{self.name}.port_b.p",
f"{self.name}.port_a.m_flow",
),
role="effort",
value=(
self.port_a.p
- self.port_b.p
- self.pressure_drop(
self.port_a.m_flow,
self.port_a.p,
self.port_b.p,
)
),
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_a.h_outflow = connected_h["port_b"]
self.port_b.h_outflow = connected_h["port_a"]
+39 -1
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
from collections.abc import Mapping
from PythonModels.core.base import DynamicComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState
@@ -23,7 +26,9 @@ class Tank(DynamicComponent):
m0 = p0 * V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_a = PortState()
self.port_a = self.register_port(
PortState.pneumatic("port_a", nominal_role="inlet")
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
@@ -37,6 +42,39 @@ class Tank(DynamicComponent):
self.port_a.h_outflow = props.h
return props
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
return self.properties()
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
properties = self.properties()
derivative = self.derivatives_from_connection(
connected_h=connected_h["port_a"],
port_m_flow=self.port_a.m_flow,
internal_h=properties.h,
)
return derivative.as_vector()
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.V,
).p
return (
EquationResidual(
id=f"{self.name}:port_a_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_a.p", f"{self.name}.state"),
role="effort",
value=self.port_a.p - pressure,
),
)
def derivatives_from_connection(
self,
*,
+68 -3
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
from collections.abc import Mapping
from PythonModels.core.base import AlgebraicComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.ports import PortState
@@ -9,9 +12,71 @@ class Tee(AlgebraicComponent):
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.port_in = PortState()
self.port_out1 = PortState()
self.port_out2 = PortState()
self.port_in = self.register_port(
PortState.pneumatic("port_in", nominal_role="bidirectional")
)
self.port_out1 = self.register_port(
PortState.pneumatic("port_out1", nominal_role="bidirectional")
)
self.port_out2 = self.register_port(
PortState.pneumatic("port_out2", nominal_role="bidirectional")
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:common_pressure_out1",
owner="component",
owner_id=self.name,
relation="equal",
variables=(f"{self.name}.port_in.p", f"{self.name}.port_out1.p"),
role="effort",
value=self.port_in.p - self.port_out1.p,
),
EquationResidual(
id=f"{self.name}:common_pressure_out2",
owner="component",
owner_id=self.name,
relation="equal",
variables=(f"{self.name}.port_in.p", f"{self.name}.port_out2.p"),
role="effort",
value=self.port_in.p - self.port_out2.p,
),
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=(
f"{self.name}.port_in.m_flow",
f"{self.name}.port_out1.m_flow",
f"{self.name}.port_out2.m_flow",
),
role="flow",
value=(
self.port_in.m_flow
+ self.port_out1.m_flow
+ self.port_out2.m_flow
),
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
incoming = [
(port.m_flow, connected_h[name])
for name, port in self.ports.items()
if port.m_flow > 1e-12
]
total_flow = sum(m_flow for m_flow, _ in incoming)
if total_flow > 1e-12:
mixed_h = sum(
m_flow * enthalpy for m_flow, enthalpy in incoming
) / total_flow
else:
values = list(connected_h.values())
mixed_h = sum(values) / len(values) if values else 0.0
for port in self.ports.values():
port.h_outflow = mixed_h
def mixed_inlet_enthalpy(
self,
+258
View File
@@ -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
+53
View File
@@ -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."""
+36
View File
@@ -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
View File
@@ -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
],
}
+90 -1
View File
@@ -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"
+1
View File
@@ -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,
)
+119
View File
@@ -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,
)
+208
View File
@@ -0,0 +1,208 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from math import isfinite
from PythonModels.components.cylinder import Cylinder
from PythonModels.components.orifice import Orifice
from PythonModels.components.resistive_pipe import ResistivePipe
from PythonModels.components.tank import Tank
from PythonModels.components.tee import Tee
from PythonModels.core.base import Component
from PythonModels.core.medium import IdealGasMedium
from PythonModels.core.ports import PortDefinition
@dataclass(frozen=True)
class ParameterSpec:
name: str
default: float
minimum: float | None = None
maximum: float | None = None
minimum_exclusive: bool = False
def validation_message(self, value: float) -> str | None:
if not isfinite(value):
return "must be finite"
if self.minimum is not None:
if self.minimum_exclusive and value <= self.minimum:
return f"must be greater than {self.minimum:g}"
if not self.minimum_exclusive and value < self.minimum:
return f"must be at least {self.minimum:g}"
if self.maximum is not None and value > self.maximum:
return f"must be at most {self.maximum:g}"
return None
ComponentFactory = Callable[
[str, IdealGasMedium, Mapping[str, float]],
Component,
]
@dataclass(frozen=True)
class ComponentModelSpec:
model_type: str
ports: tuple[PortDefinition, ...]
parameters: tuple[ParameterSpec, ...]
factory: ComponentFactory
@property
def parameter_by_name(self) -> dict[str, ParameterSpec]:
return {parameter.name: parameter for parameter in self.parameters}
def create(
self,
name: str,
medium: IdealGasMedium,
values: Mapping[str, float],
) -> Component:
resolved = {
parameter.name: values.get(parameter.name, parameter.default)
for parameter in self.parameters
}
for parameter in self.parameters:
message = parameter.validation_message(resolved[parameter.name])
if message is not None:
raise ValueError(
f"Parameter '{parameter.name}' on component '{name}' {message}."
)
unknown = sorted(set(values) - set(self.parameter_by_name))
if unknown:
raise ValueError(
f"Component '{name}' contains unsupported parameters: "
+ ", ".join(unknown)
+ "."
)
component = self.factory(name, medium, resolved)
component.model_type = self.model_type
return component
def _cylinder_factory(
name: str,
medium: IdealGasMedium,
values: Mapping[str, float],
) -> Component:
return Cylinder(
name=name,
medium=medium,
V=values["volume"],
p0=values["p0"],
T0=values["T0"],
)
def _tank_factory(
name: str,
medium: IdealGasMedium,
values: Mapping[str, float],
) -> Component:
return Tank(
name=name,
medium=medium,
V=values["volume"],
p0=values["p0"],
T0=values["T0"],
)
def _pipe_factory(
name: str,
medium: IdealGasMedium,
values: Mapping[str, float],
) -> Component:
return ResistivePipe(
name=name,
medium=medium,
L=values["length"],
D=values["diameter"],
lambda_darcy=values["lambda_darcy"],
p0=values["p0"],
T0=values["T0"],
)
def _orifice_factory(
name: str,
medium: IdealGasMedium,
values: Mapping[str, float],
) -> Component:
return Orifice(name=name, opening=values["opening"], K=values["K"])
def _tee_factory(
name: str,
medium: IdealGasMedium,
values: Mapping[str, float],
) -> Component:
return Tee(name=name)
COMPONENT_MODEL_REGISTRY: dict[str, ComponentModelSpec] = {
"cylinder": ComponentModelSpec(
model_type="cylinder",
ports=(PortDefinition.pneumatic("port_b", nominal_role="outlet"),),
parameters=(
ParameterSpec("volume", 0.01, minimum=0.0, minimum_exclusive=True),
ParameterSpec("p0", 35e6, minimum=0.0, minimum_exclusive=True),
ParameterSpec("T0", 300.0, minimum=0.0, minimum_exclusive=True),
),
factory=_cylinder_factory,
),
"tank": ComponentModelSpec(
model_type="tank",
ports=(PortDefinition.pneumatic("port_a", nominal_role="inlet"),),
parameters=(
ParameterSpec("volume", 0.1, minimum=0.0, minimum_exclusive=True),
ParameterSpec("p0", 1e5, minimum=0.0, minimum_exclusive=True),
ParameterSpec("T0", 300.0, minimum=0.0, minimum_exclusive=True),
),
factory=_tank_factory,
),
"pipe": ComponentModelSpec(
model_type="pipe",
ports=(
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
),
parameters=(
ParameterSpec("length", 5.0, minimum=0.0, minimum_exclusive=True),
ParameterSpec("diameter", 0.02, minimum=0.0, minimum_exclusive=True),
ParameterSpec("lambda_darcy", 0.02, minimum=0.0),
ParameterSpec("p0", 1e5, minimum=0.0, minimum_exclusive=True),
ParameterSpec("T0", 300.0, minimum=0.0, minimum_exclusive=True),
),
factory=_pipe_factory,
),
"orifice": ComponentModelSpec(
model_type="orifice",
ports=(
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
),
parameters=(
ParameterSpec("K", 1e-5, minimum=0.0),
ParameterSpec("opening", 1.0, minimum=0.0, maximum=1.0),
),
factory=_orifice_factory,
),
"tee": ComponentModelSpec(
model_type="tee",
ports=(
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out2", nominal_role="bidirectional"),
),
parameters=(),
factory=_tee_factory,
),
}
def get_component_model_spec(model_type: str) -> ComponentModelSpec:
try:
return COMPONENT_MODEL_REGISTRY[model_type]
except KeyError as exc:
raise ValueError(f"Unsupported model type: {model_type}.") from exc
+358
View File
@@ -0,0 +1,358 @@
from __future__ import annotations
from dataclasses import dataclass
from math import floor, isfinite
from PythonModels.core.algebraic import PressureFlowSolver
from PythonModels.core.base import DynamicComponent
from PythonModels.core.network import Endpoint, SimulationNetwork
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
from PythonModels.core.stream import StreamResolver
@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
message: str
series: dict[str, list[float]]
final: dict[str, float]
diagnostics: dict[str, object]
def as_dict(self) -> dict[str, object]:
return {
"success": self.success,
"message": self.message,
"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.dynamic_components:
properties = component.refresh_thermodynamic_ports()
state = component.get_state_vector()
if len(state) >= 2:
series.setdefault(f"{component.name}.m", []).append(float(state[0]))
series.setdefault(f"{component.name}.U", []).append(float(state[1]))
for name in ("p", "T", "rho", "u", "h"):
if hasattr(properties, name):
series.setdefault(f"{component.name}.{name}", []).append(
float(getattr(properties, name))
)
for component in self.network.components.values():
for port_name, port in component.ports.items():
prefix = f"{component.name}.{port_name}"
series.setdefault(f"{prefix}.p", []).append(float(port.p))
series.setdefault(f"{prefix}.m_flow", []).append(float(port.m_flow))
series.setdefault(f"{prefix}.h_outflow", []).append(
float(port.h_outflow)
)
def simulate(
self,
config: SolveIVPConfig,
*,
sample_step: float,
) -> GenericSimulationResult:
t_eval = simulation_sample_times(config, sample_step)
initial_state = self.consistent_initial_state_vector()
solution = integrate_ode(
rhs=self.rhs,
initial_state=initial_state,
config=config,
t_eval=t_eval,
)
times = [float(value) for value in solution.t]
series: dict[str, list[float]] = {"time": times}
for time_index in range(len(times)):
state = [
float(solution.y[state_index][time_index])
for state_index in range(len(solution.y))
]
self.apply_state_vector(state)
self._close_current_state()
self._append_current_state(series)
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(times),
}
return GenericSimulationResult(
success=bool(solution.success),
message=str(solution.message),
series=series,
final=final,
diagnostics=diagnostics,
)
+1 -1
View File
@@ -586,7 +586,7 @@ class TestModelClosure:
self.components.upstream_tee.port_in.p = cylinder.p
self.components.upstream_tee.port_out1.p = cylinder.p
self.components.upstream_tee.port_out2.p = cylinder.p
self.components.upstream_tee.port_in.m_flow = cylinder_m_flow
self.components.upstream_tee.port_in.m_flow = -cylinder_m_flow
self.components.upstream_tee.port_in.h_outflow = tee_upstream_h
self.components.upstream_tee.port_out1.h_outflow = cylinder.h
self.components.upstream_tee.port_out2.h_outflow = cylinder.h