对齐AMESim管阻孔口与储气耦合

This commit is contained in:
huojiarong committed 2026-08-10 13:07:03 +00:00
1 parent 7671418582
commit 6abcc220de
14 files changed
+817 -65

No files matched your search

+136 -37
View File
@@ -51,13 +51,12 @@ _PNVO001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(
class AmesimPnor001(AlgebraicComponent): class AmesimPnor001(AlgebraicComponent):
"""AMESim PNOR001 constant-flow-coefficient pneumatic orifice. """AMESim PNOR001 constant-flow-coefficient pneumatic orifice.
This public component preserves the PNOR001 catalog/XML contract and uses a This public component preserves the PNOR001 catalog/XML contract and uses
finite bidirectional compressible-orifice approximation. The Siemens real-gas pressure-ratio flow with AMESim-style near-equal-pressure smoothing.
`pn2rcqfix_` details remain a later calibration target.
""" """
MODEL_TYPE = "amesim_pnor001" MODEL_TYPE = "amesim_pnor001"
MODEL_VERSION = "0.2.0" MODEL_VERSION = "0.3.0"
PORTS = ( PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -188,6 +187,7 @@ class AmesimPnor001(AlgebraicComponent):
self.port_1.h_outflow = initial_h self.port_1.h_outflow = initial_h
self.port_2 = self.register_declared_port("port_2") self.port_2 = self.register_declared_port("port_2")
self.port_2.h_outflow = initial_h self.port_2.h_outflow = initial_h
self._connected_h: dict[str, float] = {}
@staticmethod @staticmethod
def _integer_parameter(name: str, value: float) -> int: def _integer_parameter(name: str, value: float) -> int:
@@ -243,16 +243,113 @@ class AmesimPnor001(AlgebraicComponent):
def _upstream_temperature(self, port_name: str) -> float: def _upstream_temperature(self, port_name: str) -> float:
port = self.get_port(port_name) port = self.get_port(port_name)
inlet_h = self._connected_h.get(port_name, port.h_outflow)
return max( return max(
self.medium.temperature_from_pressure_enthalpy( self.medium.temperature_from_pressure_enthalpy(
max(port.p, 1.0), max(port.p, 1.0),
port.h_outflow, inlet_h,
), ),
1.0, 1.0,
) )
@staticmethod
def _subsonic_mass_flow_parameter(
*,
pressure_ratio: float,
gamma_s: float,
density: float,
upstream_temperature: float,
upstream_pressure: float,
) -> float:
expansion = (
pressure_ratio ** (2.0 * gamma_s)
- pressure_ratio ** (1.0 + gamma_s)
)
return sqrt(
max(
2.0
/ (1.0 - gamma_s)
* density
* upstream_temperature
/ upstream_pressure
* expansion,
0.0,
)
)
def _one_way_flow_characteristics(
self,
*,
upstream_pressure: float,
downstream_pressure: float,
upstream_temperature: float,
) -> tuple[float, float]:
p_up = max(upstream_pressure, 1.0)
p_down = max(min(downstream_pressure, p_up), 0.0)
T_up = max(upstream_temperature, 1.0)
gamma_s = self.medium.isentropic_density_pressure_factor(
p_up,
T_up,
p_down,
)
gamma_s = min(max(gamma_s, 1.0e-9), 1.0 - 1.0e-9)
density = max(self.medium.density(p_up, T_up), 1.0e-12)
pressure_ratio = max(p_down / p_up, 0.0)
critical_ratio = (2.0 * gamma_s / (gamma_s + 1.0)) ** (
1.0 / (1.0 - gamma_s)
)
if pressure_ratio <= critical_ratio:
effective_pressure_ratio = critical_ratio
mass_flow_parameter = (
sqrt(2.0 / (1.0 + gamma_s) * density * T_up / p_up)
* (2.0 * gamma_s / (gamma_s + 1.0))
** (gamma_s / (1.0 - gamma_s))
)
gas_velocity = sqrt(2.0 / (1.0 + gamma_s) * p_up / density)
else:
effective_pressure_ratio = pressure_ratio
mass_flow_parameter = self._subsonic_mass_flow_parameter(
pressure_ratio=pressure_ratio,
gamma_s=gamma_s,
density=density,
upstream_temperature=T_up,
upstream_pressure=p_up,
)
gas_velocity = sqrt(
max(
2.0
/ (1.0 - gamma_s)
* p_up
/ density
* (1.0 - pressure_ratio ** (1.0 - gamma_s)),
0.0,
)
)
reference = self._subsonic_mass_flow_parameter(
pressure_ratio=_PN_PRESSURE_RATIO_ACCURACY,
gamma_s=gamma_s,
density=density,
upstream_temperature=T_up,
upstream_pressure=p_up,
)
if mass_flow_parameter > 0.0 and reference > 0.0:
argument = (
_PN_LAMINAR_SMOOTHING_GAIN
* abs(mass_flow_parameter / reference)
* log(effective_pressure_ratio)
/ log(_PN_PRESSURE_RATIO_ACCURACY)
)
smoothing_factor = tanh(max(argument, 0.0))
mass_flow_parameter *= smoothing_factor
gas_velocity *= smoothing_factor
return mass_flow_parameter, gas_velocity
def mass_flow(self, p_1: float, p_2: float) -> float: def mass_flow(self, p_1: float, p_2: float) -> float:
if p_1 == p_2 or self.effective_area == 0.0: if (
isclose(p_1, p_2, rel_tol=0.0, abs_tol=1.0e-8)
or self.effective_area == 0.0
):
return 0.0 return 0.0
if p_1 > p_2: if p_1 > p_2:
return self._one_way_mass_flow( return self._one_way_mass_flow(
@@ -274,43 +371,41 @@ class AmesimPnor001(AlgebraicComponent):
upstream_temperature: float, upstream_temperature: float,
) -> float: ) -> float:
p_up = max(upstream_pressure, 1.0) p_up = max(upstream_pressure, 1.0)
p_down = max(min(downstream_pressure, p_up), 0.0)
T_up = max(upstream_temperature, 1.0) T_up = max(upstream_temperature, 1.0)
gamma = max(self.medium.gamma, 1.000001) mass_flow_parameter, _ = self._one_way_flow_characteristics(
pressure_ratio = max(p_down / p_up, 0.0) upstream_pressure=p_up,
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0)) downstream_pressure=downstream_pressure,
if pressure_ratio <= critical_ratio: upstream_temperature=T_up,
flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * ( )
2.0 / (gamma + 1.0) return (
) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0))) self.effective_cq
else: * self.effective_area
expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** ( * p_up
(gamma + 1.0) / gamma * mass_flow_parameter
) / sqrt(T_up)
flow_factor = sqrt( )
max(
2.0
* gamma
* expansion
/ (self.medium.R_gas * T_up * (gamma - 1.0)),
0.0,
)
)
return self.effective_cq * self.effective_area * p_up * flow_factor
def component_result_values(self) -> Mapping[str, float]: def component_result_values(self) -> Mapping[str, float]:
p_1 = max(self.port_1.p, 1.0) p_1 = max(self.port_1.p, 1.0)
p_2 = max(self.port_2.p, 1.0) p_2 = max(self.port_2.p, 1.0)
m_flow = abs(self.mass_flow(self.port_1.p, self.port_2.p)) if p_1 >= p_2:
upstream_pressure = max(p_1, p_2) upstream_port_name = "port_1"
upstream_temperature = self._upstream_temperature( upstream_pressure = p_1
"port_1" if p_1 >= p_2 else "port_2" downstream_pressure = p_2
flow_direction = 1.0
else:
upstream_port_name = "port_2"
upstream_pressure = p_2
downstream_pressure = p_1
flow_direction = -1.0
mass_flow_parameter, gas_velocity = self._one_way_flow_characteristics(
upstream_pressure=upstream_pressure,
downstream_pressure=downstream_pressure,
upstream_temperature=self._upstream_temperature(upstream_port_name),
) )
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
area = max(self.effective_area, 1.0e-18)
return { return {
"cm": m_flow / (self.effective_cq * area * upstream_pressure), "cm": mass_flow_parameter,
"gasvel": m_flow / (density * area), "gasvel": flow_direction * gas_velocity,
} }
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
@@ -344,6 +439,7 @@ class AmesimPnor001(AlgebraicComponent):
) )
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self._connected_h = dict(connected_h)
self.port_1.h_outflow = connected_h["port_2"] self.port_1.h_outflow = connected_h["port_2"]
self.port_2.h_outflow = connected_h["port_1"] self.port_2.h_outflow = connected_h["port_1"]
@@ -593,7 +689,10 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
) )
def mass_flow(self, p_2: float, p_3: float) -> float: def mass_flow(self, p_2: float, p_3: float) -> float:
if p_2 == p_3 or self.effective_area == 0.0: if (
isclose(p_2, p_3, rel_tol=1.0e-7, abs_tol=1.0e-9)
or self.effective_area == 0.0
):
return 0.0 return 0.0
if p_2 > p_3: if p_2 > p_3:
return self._one_way_mass_flow( return self._one_way_mass_flow(
+123 -13
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from math import isclose, log10, pi, sqrt from math import isclose, log, log10, pi, sqrt, tanh
from app.simulation.components.amesim.gases import ( from app.simulation.components.amesim.gases import (
AMESIM_GAS_INDEX_PARAMETER, AMESIM_GAS_INDEX_PARAMETER,
@@ -246,7 +246,7 @@ class AmesimPnl00r(AlgebraicComponent):
while self.darcy_pressure_drop(upper, density=density, temperature=temperature) < pressure_drop: while self.darcy_pressure_drop(upper, density=density, temperature=temperature) < pressure_drop:
upper *= 10.0 upper *= 10.0
if upper > 1.0e3: if upper > 1.0e3:
raise ValueError("unable to bracket PNL00R resistance flow") return 1.0e3
lower = 0.0 lower = 0.0
for _ in range(48): for _ in range(48):
middle = 0.5 * (lower + upper) middle = 0.5 * (lower + upper)
@@ -257,7 +257,7 @@ class AmesimPnl00r(AlgebraicComponent):
return 0.5 * (lower + upper) return 0.5 * (lower + upper)
def mass_flow(self, p_1: float, p_2: float) -> float: def mass_flow(self, p_1: float, p_2: float) -> float:
if p_1 == p_2: if isclose(p_1, p_2, rel_tol=1.0e-7, abs_tol=1.0e-9):
return 0.0 return 0.0
pressure_difference = p_1 - p_2 pressure_difference = p_1 - p_2
upstream_pressure = max(p_1, p_2, 1.0) upstream_pressure = max(p_1, p_2, 1.0)
@@ -645,7 +645,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
) < pressure_drop: ) < pressure_drop:
upper *= 10.0 upper *= 10.0
if upper > 1.0e3: if upper > 1.0e3:
raise ValueError("unable to bracket PNL0001 resistance flow") return 1.0e3
lower = 0.0 lower = 0.0
for _ in range(48): for _ in range(48):
middle = 0.5 * (lower + upper) middle = 0.5 * (lower + upper)
@@ -659,16 +659,126 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
upper = middle upper = middle
return 0.5 * (lower + upper) return 0.5 * (lower + upper)
def _one_way_pn2pipefr_mass_flow(
self,
*,
upstream_pressure: float,
downstream_pressure: float,
upstream_temperature: float,
resistance_length: float,
) -> float:
"""AMESim pn2pipefr-style compressible friction flow."""
p_up = max(float(upstream_pressure), 1.0)
p_down = max(min(float(downstream_pressure), p_up), 0.0)
T_up = max(float(upstream_temperature), 1.0)
if resistance_length <= 0.0:
raise ValueError("Pipe resistance length must be positive.")
gamma_s = self.medium.isentropic_density_pressure_factor(
p_up,
T_up,
p_down,
)
gamma_s = min(max(gamma_s, 1.0e-9), 1.0 - 1.0e-9)
density = max(self.medium.density(p_up, T_up), 1.0e-12)
pressure_ratio = max(p_down / p_up, 0.0)
critical_ratio = (2.0 * gamma_s / (gamma_s + 1.0)) ** (
1.0 / (1.0 - gamma_s)
)
def mass_flow_parameter(ratio: float) -> float:
if ratio <= critical_ratio:
value = (
sqrt(2.0 / (1.0 + gamma_s) * density * T_up / p_up)
* (2.0 * gamma_s / (gamma_s + 1.0))
** (gamma_s / (1.0 - gamma_s))
)
effective_ratio = critical_ratio
else:
expansion = ratio ** (2.0 * gamma_s) - ratio ** (1.0 + gamma_s)
value = sqrt(
max(
2.0
/ (1.0 - gamma_s)
* density
* T_up
/ p_up
* expansion,
0.0,
)
)
effective_ratio = ratio
accuracy = 0.9999
reference_expansion = (
accuracy ** (2.0 * gamma_s)
- accuracy ** (1.0 + gamma_s)
)
reference = sqrt(
max(
2.0
/ (1.0 - gamma_s)
* density
* T_up
/ p_up
* reference_expansion,
0.0,
)
)
if value > 0.0 and reference > 0.0:
smoothing_argument = (
12.0
* abs(value / reference)
* log(effective_ratio)
/ log(accuracy)
)
value *= tanh(max(smoothing_argument, 0.0))
return value
def target_flow(mass_flow: float) -> float:
reynolds = self.reynolds_number(mass_flow, T_up)
friction = self.friction_factor(reynolds)
flow_coefficient = sqrt(
self.diam / (resistance_length * friction)
)
return (
flow_coefficient
* self.area
* p_up
* mass_flow_parameter(pressure_ratio)
/ sqrt(T_up)
)
flow_coefficient = sqrt(self.diam / (resistance_length * 0.02))
magnitude = (
flow_coefficient
* self.area
* p_up
* mass_flow_parameter(pressure_ratio)
/ sqrt(T_up)
)
for _iteration in range(16):
next_magnitude = target_flow(magnitude)
if abs(next_magnitude - magnitude) <= max(
1.0e-12,
abs(magnitude) * 1.0e-9,
):
return next_magnitude
magnitude = 0.5 * (magnitude + next_magnitude)
return magnitude
def mass_flow(self, p_1: float, p_2: float, temperature: float) -> float: def mass_flow(self, p_1: float, p_2: float, temperature: float) -> float:
if p_1 == p_2: if isclose(p_1, p_2, rel_tol=0.0, abs_tol=1.0e-8):
return 0.0 return 0.0
pressure_difference = p_1 - p_2 pressure_difference = p_1 - p_2
upstream_pressure = max(p_1, p_2, 1.0) upstream_temperature = max(float(temperature), 1.0)
density = max(self.medium.density(upstream_pressure, temperature), 1.0e-12) resistance_length = getattr(self, "resistance_length", self.le)
magnitude = self._mass_flow_for_pressure_drop( magnitude = self._one_way_pn2pipefr_mass_flow(
abs(pressure_difference), upstream_pressure=max(p_1, p_2),
density=density, downstream_pressure=min(p_1, p_2),
temperature=temperature, upstream_temperature=upstream_temperature,
resistance_length=resistance_length,
) )
return magnitude if pressure_difference > 0.0 else -magnitude return magnitude if pressure_difference > 0.0 else -magnitude
@@ -1115,7 +1225,7 @@ class AmesimPnl0003(DynamicComponent):
while self.darcy_pressure_drop(upper, density=density, temperature=temperature) < pressure_drop: while self.darcy_pressure_drop(upper, density=density, temperature=temperature) < pressure_drop:
upper *= 10.0 upper *= 10.0
if upper > 1.0e3: if upper > 1.0e3:
raise ValueError("unable to bracket PNL0003 resistance flow") return 1.0e3
lower = 0.0 lower = 0.0
for _ in range(48): for _ in range(48):
middle = 0.5 * (lower + upper) middle = 0.5 * (lower + upper)
@@ -1129,7 +1239,7 @@ class AmesimPnl0003(DynamicComponent):
port_1 = self._properties(self.state_1) port_1 = self._properties(self.state_1)
port_2 = self._properties(self.state_2) port_2 = self._properties(self.state_2)
pressure_difference = port_1.p - port_2.p pressure_difference = port_1.p - port_2.p
if pressure_difference == 0.0: if isclose(port_1.p, port_2.p, rel_tol=1.0e-7, abs_tol=1.0e-9):
return 0.0 return 0.0
upstream = port_1 if pressure_difference > 0.0 else port_2 upstream = port_1 if pressure_difference > 0.0 else port_2
magnitude = self._mass_flow_for_pressure_drop( magnitude = self._mass_flow_for_pressure_drop(
@@ -4,6 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import ClassVar from typing import ClassVar
from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.core.medium import ( from app.simulation.core.medium import (
GasMedium, GasMedium,
IdealGasMedium, IdealGasMedium,
@@ -214,7 +215,9 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
if m <= 0.0: if m <= 0.0:
raise ValueError("Mass must stay positive when recovering temperature.") raise RecoverableTrialStateError(
"Mass must stay positive when recovering temperature."
)
return self.temperature_from_internal_energy(U / m) return self.temperature_from_internal_energy(U / m)
def properties_from_mU( def properties_from_mU(
@@ -224,7 +227,9 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
V: float, V: float,
) -> ThermodynamicProperties: ) -> ThermodynamicProperties:
if m <= 0.0: if m <= 0.0:
raise ValueError("Mass must stay positive when recovering temperature.") raise RecoverableTrialStateError(
"Mass must stay positive when recovering temperature."
)
if V <= 0.0: if V <= 0.0:
raise ValueError("Volume must stay positive.") raise ValueError("Volume must stay positive.")
density = m / V density = m / V
+5 -1
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Protocol from typing import Protocol
from app.simulation.core.errors import RecoverableTrialStateError
@dataclass(frozen=True) @dataclass(frozen=True)
class ThermodynamicProperties: class ThermodynamicProperties:
@@ -195,7 +197,9 @@ class IdealGasMedium:
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
if m <= 0.0: if m <= 0.0:
raise ValueError("Mass must stay positive when recovering temperature.") raise RecoverableTrialStateError(
"Mass must stay positive when recovering temperature."
)
return self.temperature_from_internal_energy(U / m) return self.temperature_from_internal_energy(U / m)
def pressure(self, m: float, T: float, V: float) -> float: def pressure(self, m: float, T: float, V: float) -> float:
+2 -2
View File
@@ -299,12 +299,12 @@ class PengRobinsonFluid:
@staticmethod @staticmethod
def _validate_temperature(temperature: float) -> None: def _validate_temperature(temperature: float) -> None:
if temperature <= 0.0: if temperature <= 0.0:
raise ValueError("Temperature must be positive.") raise RecoverableTrialStateError("Temperature must be positive.")
@classmethod @classmethod
def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None: def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None:
if pressure <= 0.0: if pressure <= 0.0:
raise ValueError("Pressure must be positive.") raise RecoverableTrialStateError("Pressure must be positive.")
cls._validate_temperature(temperature) cls._validate_temperature(temperature)
HELIUM_PR = PengRobinsonFluid( HELIUM_PR = PengRobinsonFluid(
+113 -1
View File
@@ -5,6 +5,13 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from math import expm1, isfinite, log, sqrt from math import expm1, isfinite, log, sqrt
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.orifices import AmesimPnor001
from app.simulation.components.amesim.flow.pipes import (
AmesimPnl00r,
AmesimPnl0001,
AmesimPnl0002,
)
from app.simulation.core.ports import PortState, VariableRole from app.simulation.core.ports import PortState, VariableRole
from app.simulation.systems.network import SimulationNetwork from app.simulation.systems.network import SimulationNetwork
@@ -716,7 +723,7 @@ class PressureFlowSolver:
"""Execute the precompiled explicit flow/force causalization plan.""" """Execute the precompiled explicit flow/force causalization plan."""
for unknown in self.unknowns: for unknown in self.unknowns:
if unknown.variable == "f": if unknown.variable in {"f", "m_flow"}:
unknown.write(0.0) unknown.write(0.0)
seeded_ids: set[str] = set() seeded_ids: set[str] = set()
@@ -728,6 +735,109 @@ class PressureFlowSolver:
seeded_ids.add(assignment.unknown.id) seeded_ids.add(assignment.unknown.id)
return seeded_ids return seeded_ids
def _seed_closed_resistance_pressures(self) -> None:
"""Seed a sealed resistance end at its zero-flow pressure.
A PNPL01 fixes flow, not pressure. Starting a dead-ended Darcy branch
with the plug-side pressure at the medium reference can otherwise put
the nonlinear solver on the singular square-root part of the inverse
flow law. At zero flow, these AMESim pipe resistances have exactly zero
pressure drop, which gives a deterministic and physically exact seed.
"""
connected: dict[tuple[str, str], tuple[str, str]] = {}
for connection in self.network.connections:
if connection.kind != "physical" or connection.domain != "pneumatic":
continue
first, second = connection.endpoints
connected[first.key] = second.key
connected[second.key] = first.key
for component in self.network.components.values():
if not isinstance(component, (AmesimPnl00r, AmesimPnl0001)):
continue
for port_name in component.ports:
neighbor_key = connected.get((component.name, port_name))
if neighbor_key is None:
continue
neighbor = self.network.components[neighbor_key[0]]
if not isinstance(neighbor, AmesimPnpl01):
continue
if isinstance(component, AmesimPnl0002):
pressure = component.properties().p
elif isinstance(component, AmesimPnl0001):
if port_name != "port_1":
continue
pressure = component.properties().p
else:
other_port_name = "port_2" if port_name == "port_1" else "port_1"
pressure = component.get_port(other_port_name).p
component.get_port(port_name).p = pressure
neighbor.get_port(neighbor_key[1]).p = pressure
def _seed_pnor_pnl0001_series_pressures(self) -> None:
"""Causalize the pressure between a PNOR001 and PNL0001 R port."""
for connection in self.network.connections:
first_endpoint, second_endpoint = connection.endpoints
first = self.network.components[first_endpoint.component]
second = self.network.components[second_endpoint.component]
if isinstance(first, AmesimPnor001) and isinstance(second, AmesimPnl0001):
orifice, orifice_port = first, first_endpoint.port
pipe, pipe_port = second, second_endpoint.port
elif isinstance(second, AmesimPnor001) and isinstance(first, AmesimPnl0001):
orifice, orifice_port = second, second_endpoint.port
pipe, pipe_port = first, first_endpoint.port
else:
continue
if isinstance(pipe, AmesimPnl0002) or pipe_port != "port_1":
continue
orifice_other = "port_2" if orifice_port == "port_1" else "port_1"
pressure_a = orifice.get_port(orifice_other).p
pressure_b = pipe.properties().p
lower = min(pressure_a, pressure_b)
upper = max(pressure_a, pressure_b)
def mismatch(intermediate_pressure: float) -> float:
if orifice_port == "port_2":
orifice_flow_into_connection = -orifice.mass_flow(
pressure_a,
intermediate_pressure,
)
else:
orifice_flow_into_connection = orifice.mass_flow(
intermediate_pressure,
pressure_a,
)
pipe_flow_into_connection = pipe.mass_flow(
intermediate_pressure,
pressure_b,
pipe.properties().T,
)
return orifice_flow_into_connection + pipe_flow_into_connection
lower_value = mismatch(lower)
upper_value = mismatch(upper)
if lower_value == 0.0:
pressure = lower
elif upper_value == 0.0:
pressure = upper
elif (lower_value < 0.0) == (upper_value < 0.0):
continue
else:
for _iteration in range(64):
middle = 0.5 * (lower + upper)
middle_value = mismatch(middle)
if (middle_value < 0.0) == (lower_value < 0.0):
lower = middle
lower_value = middle_value
else:
upper = middle
pressure = 0.5 * (lower + upper)
orifice.get_port(orifice_port).p = pressure
pipe.get_port(pipe_port).p = pressure
def _scales(self) -> dict[str, float]: def _scales(self) -> dict[str, float]:
pressure_scale = max( pressure_scale = max(
[ [
@@ -783,6 +893,8 @@ class PressureFlowSolver:
clear_causal_contact() clear_causal_contact()
self._seed_equal_efforts() self._seed_equal_efforts()
self._seed_closed_resistance_pressures()
self._seed_pnor_pnl0001_series_pressures()
self._solve_explicit_flow_unknowns() self._solve_explicit_flow_unknowns()
contact_bindings = self._seed_unilateral_contacts() contact_bindings = self._seed_unilateral_contacts()
if contact_bindings: if contact_bindings:
+256
View File
@@ -0,0 +1,256 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable, Sequence
from app.simulation.components.amesim.flow.pipes import (
AmesimPnl0001,
AmesimPnl0002,
AmesimPnl0003,
)
from app.simulation.solvers.mechanical import MechanicalConstraintGroup
from app.simulation.systems.network import Endpoint, SimulationNetwork
if TYPE_CHECKING:
from app.simulation.core.base import DynamicComponent
from app.simulation.solvers.mechanical import MechanicalStateReducer
@dataclass(frozen=True)
class PneumaticStoragePartition:
"""One fixed-volume ``[mass, internal energy]`` pressure-state partition."""
component: DynamicComponent
state_offset: int
volume: float
@property
def key(self) -> tuple[str, int]:
return self.component.name, self.state_offset
@dataclass(frozen=True)
class IdealPneumaticStorageGroup:
partitions: tuple[PneumaticStoragePartition, ...]
@property
def names(self) -> tuple[str, ...]:
return tuple(partition.component.name for partition in self.partitions)
def pneumatic_storage_partition(
network: SimulationNetwork,
endpoint: Endpoint,
) -> PneumaticStoragePartition | None:
"""Map an AMESim pressure-state port to its fixed gas-volume state slice.
PNL0001 exposes its C side at ``port_2``. PNL0003 exposes one compliance
at each end. PNL0002 has resistances at both external ports, so its center
compliance is intentionally not returned here.
"""
component = network.components[endpoint.component]
if isinstance(component, AmesimPnl0003):
if endpoint.port == "port_1":
return PneumaticStoragePartition(
component=component,
state_offset=0,
volume=component.compliance_volume,
)
if endpoint.port == "port_2":
return PneumaticStoragePartition(
component=component,
state_offset=2,
volume=component.compliance_volume,
)
return None
if isinstance(component, AmesimPnl0001) and not isinstance(
component, AmesimPnl0002
):
if endpoint.port == "port_2":
return PneumaticStoragePartition(
component=component,
state_offset=0,
volume=component.volume,
)
return None
def ideal_storage_group_is_reducible(
network: SimulationNetwork,
storage_endpoints: Iterable[Endpoint],
) -> bool:
partitions = [
pneumatic_storage_partition(network, endpoint)
for endpoint in storage_endpoints
]
if not partitions or any(partition is None for partition in partitions):
return False
unique = {partition.key: partition for partition in partitions if partition}
if len(unique) < 2:
return False
media = {id(partition.component.medium) for partition in unique.values()}
return len(media) == 1 and all(partition.volume > 0.0 for partition in unique.values())
def _pressure_storage_endpoint_groups(
network: SimulationNetwork,
) -> tuple[tuple[Endpoint, ...], ...]:
pneumatic_endpoints = {
Endpoint(component.name, definition.name)
for component in network.components.values()
for definition in component.port_definitions
if definition.kind == "physical" and definition.domain == "pneumatic"
}
parent = {endpoint: endpoint for endpoint in pneumatic_endpoints}
def find(endpoint: Endpoint) -> Endpoint:
root = endpoint
while parent[root] != root:
root = parent[root]
while parent[endpoint] != endpoint:
next_endpoint = parent[endpoint]
parent[endpoint] = root
endpoint = next_endpoint
return root
def union(first: Endpoint, second: Endpoint) -> None:
first_root = find(first)
second_root = find(second)
if first_root != second_root:
parent[second_root] = first_root
for connection in network.connections:
first, second = connection.endpoints
if first in pneumatic_endpoints and second in pneumatic_endpoints:
union(first, second)
storage_endpoints: set[Endpoint] = set()
for component in network.components.values():
for equation in component.pressure_flow_equation_residuals():
pressure_endpoints = [
Endpoint(component.name, variable.rsplit(".", 2)[1])
for variable in equation.variables
if variable.startswith(f"{component.name}.") and variable.endswith(".p")
]
if equation.relation == "equal":
for endpoint in pressure_endpoints[1:]:
union(pressure_endpoints[0], endpoint)
elif equation.relation == "state":
storage_endpoints.update(pressure_endpoints)
by_root: dict[Endpoint, list[Endpoint]] = {}
for endpoint in storage_endpoints:
by_root.setdefault(find(endpoint), []).append(endpoint)
return tuple(tuple(endpoints) for endpoints in by_root.values())
class IdealPneumaticStorageReducer:
"""Project supported ideal C-C connections onto one thermodynamic state.
AMESim permits compatible pipe compliances to share an ideal pneumatic
junction. The public ODE solver keeps the original result states but
projects their mass and energy densities together and distributes the
group's total derivative by physical volume. This removes the redundant
pressure constraint without adding a fictitious resistance.
"""
def __init__(
self,
network: SimulationNetwork,
mechanical_state_reducer: MechanicalStateReducer,
) -> None:
self.network = network
self.mechanical_state_reducer = mechanical_state_reducer
self.groups = self._build_groups()
self._component_offsets = self._build_component_offsets()
def _build_groups(self) -> tuple[IdealPneumaticStorageGroup, ...]:
groups: list[IdealPneumaticStorageGroup] = []
for endpoints in _pressure_storage_endpoint_groups(self.network):
partitions = [
pneumatic_storage_partition(self.network, endpoint)
for endpoint in endpoints
]
unique = {
partition.key: partition
for partition in partitions
if partition is not None
}
if len(unique) > 1 and len(unique) == len(
{endpoint.component for endpoint in endpoints}
):
group = IdealPneumaticStorageGroup(tuple(unique.values()))
if ideal_storage_group_is_reducible(self.network, endpoints):
groups.append(group)
return tuple(groups)
def _build_component_offsets(self) -> dict[str, int]:
offsets: dict[str, int] = {}
cursor = 0
for entry in self.mechanical_state_reducer.state_entries:
if isinstance(entry, MechanicalConstraintGroup):
cursor += 2
else:
offsets[entry.name] = cursor
cursor += entry.state_size
return offsets
def _global_offset(self, partition: PneumaticStoragePartition) -> int:
return self._component_offsets[partition.component.name] + partition.state_offset
def synchronize_state_vector(
self,
values: Sequence[float],
*,
validate: bool = False,
) -> list[float]:
projected = [float(value) for value in values]
for group in self.groups:
volumes = [partition.volume for partition in group.partitions]
offsets = [self._global_offset(partition) for partition in group.partitions]
mass_densities = [
projected[offset] / volume
for offset, volume in zip(offsets, volumes)
]
energy_densities = [
projected[offset + 1] / volume
for offset, volume in zip(offsets, volumes)
]
if validate:
mass_scale = max([abs(value) for value in mass_densities] + [1.0])
energy_scale = max([abs(value) for value in energy_densities] + [1.0])
if (
max(mass_densities) - min(mass_densities) > 1.0e-9 * mass_scale
or max(energy_densities) - min(energy_densities)
> 1.0e-9 * energy_scale
):
raise ValueError(
"Ideally coupled AMESim pipe compliances require consistent "
"initial pressure and temperature: " + ", ".join(group.names)
)
total_volume = sum(volumes)
mass_density = sum(projected[offset] for offset in offsets) / total_volume
energy_density = (
sum(projected[offset + 1] for offset in offsets) / total_volume
)
for offset, volume in zip(offsets, volumes):
projected[offset] = mass_density * volume
projected[offset + 1] = energy_density * volume
return projected
def coupled_derivatives(self, values: Sequence[float]) -> list[float]:
derivatives = [float(value) for value in values]
for group in self.groups:
volumes = [partition.volume for partition in group.partitions]
offsets = [self._global_offset(partition) for partition in group.partitions]
total_volume = sum(volumes)
total_mass_derivative = sum(derivatives[offset] for offset in offsets)
total_energy_derivative = sum(
derivatives[offset + 1] for offset in offsets
)
for offset, volume in zip(offsets, volumes):
fraction = volume / total_volume
derivatives[offset] = total_mass_derivative * fraction
derivatives[offset + 1] = total_energy_derivative * fraction
return derivatives
+29 -8
View File
@@ -9,6 +9,10 @@ from app.simulation.core.base import DynamicComponent
from app.simulation.core.metadata import ResultVariableMetadata from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.solvers.algebraic import PressureFlowSolver from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.solvers.mechanical import MechanicalStateReducer from app.simulation.solvers.mechanical import MechanicalStateReducer
from app.simulation.solvers.pneumatic_storage import (
IdealPneumaticStorageReducer,
ideal_storage_group_is_reducible,
)
from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
from app.simulation.solvers.signal import SignalResolver from app.simulation.solvers.signal import SignalResolver
@@ -182,13 +186,19 @@ def simulation_preparation_issues(
for endpoint in pressure_ports: for endpoint in pressure_ports:
storage_ports[endpoint] = component.name storage_ports[endpoint] = component.name
storages_by_group: dict[Endpoint, set[str]] = {} storages_by_group: dict[Endpoint, dict[Endpoint, str]] = {}
for endpoint, component_name in storage_ports.items(): for endpoint, component_name in storage_ports.items():
storages_by_group.setdefault(effort_groups.find(endpoint), set()).add( storages_by_group.setdefault(effort_groups.find(endpoint), {})[
component_name endpoint
) ] = component_name
for storage_names in storages_by_group.values(): for storage_endpoints in storages_by_group.values():
storage_names = set(storage_endpoints.values())
if len(storage_names) > 1: if len(storage_names) > 1:
if ideal_storage_group_is_reducible(
network,
storage_endpoints,
):
continue
issues.append( issues.append(
SimulationPreparationIssue( SimulationPreparationIssue(
"IDEAL_STORAGE_COUPLING_UNSUPPORTED", "IDEAL_STORAGE_COUPLING_UNSUPPORTED",
@@ -238,6 +248,10 @@ class GenericFluidSystem:
network, network,
self.dynamic_components, self.dynamic_components,
) )
self.pneumatic_storage_reducer = IdealPneumaticStorageReducer(
network,
self.mechanical_state_reducer,
)
self.pressure_flow_solver = PressureFlowSolver(network) self.pressure_flow_solver = PressureFlowSolver(network)
self.pneumatic_volume_resolver = PneumaticVolumeResolver(network) self.pneumatic_volume_resolver = PneumaticVolumeResolver(network)
self.signal_resolver = SignalResolver(network) self.signal_resolver = SignalResolver(network)
@@ -250,10 +264,15 @@ class GenericFluidSystem:
self.pneumatic_volume_propagation_count = 0 self.pneumatic_volume_propagation_count = 0
def initial_state_vector(self) -> list[float]: def initial_state_vector(self) -> list[float]:
return self.mechanical_state_reducer.initial_state_vector() return self.pneumatic_storage_reducer.synchronize_state_vector(
self.mechanical_state_reducer.initial_state_vector(),
validate=True,
)
def apply_state_vector(self, values: list[float]) -> None: def apply_state_vector(self, values: list[float]) -> None:
self.mechanical_state_reducer.apply_state_vector(values) self.mechanical_state_reducer.apply_state_vector(
self.pneumatic_storage_reducer.synchronize_state_vector(values)
)
def _close_current_state(self, time: float) -> dict[str, dict[str, float]]: def _close_current_state(self, time: float) -> dict[str, dict[str, float]]:
signal = self.signal_resolver.solve(time) signal = self.signal_resolver.solve(time)
@@ -298,7 +317,9 @@ class GenericFluidSystem:
def rhs(self, _time: float, state_vector: list[float]) -> list[float]: def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
self.apply_state_vector(state_vector) self.apply_state_vector(state_vector)
connected_h = self._close_current_state(_time) connected_h = self._close_current_state(_time)
return self.mechanical_state_reducer.state_derivatives(connected_h) return self.pneumatic_storage_reducer.coupled_derivatives(
self.mechanical_state_reducer.state_derivatives(connected_h)
)
def _append_current_state(self, series: dict[str, list[float]]) -> None: def _append_current_state(self, series: dict[str, list[float]]) -> None:
for component in self.network.components.values(): for component in self.network.components.values():
+11
View File
@@ -10,6 +10,7 @@ from app.simulation.components.amesim.media.mediums import (
AMESIM_HELIUM_PROPERTY_MODELS, AMESIM_HELIUM_PROPERTY_MODELS,
AmesimHeliumPengRobinsonMedium, AmesimHeliumPengRobinsonMedium,
) )
from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.core.medium import IdealGasMedium from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.peng_robinson import HELIUM_PR from app.simulation.core.peng_robinson import HELIUM_PR
from app.simulation.registry import COMPONENT_MODEL_REGISTRY from app.simulation.registry import COMPONENT_MODEL_REGISTRY
@@ -109,6 +110,16 @@ class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase):
delta=2.0e-8, delta=2.0e-8,
) )
def test_negative_mass_is_a_recoverable_integrator_trial_state(self) -> None:
media = (IdealGasMedium(), AmesimHeliumPengRobinsonMedium())
for medium in media:
with self.subTest(medium=medium.name):
with self.assertRaises(RecoverableTrialStateError):
medium.temperature_from_mass_internal_energy(-1.0, 1.0)
with self.assertRaises(RecoverableTrialStateError):
medium.properties_from_mU(-1.0, 1.0, 1.0)
def test_amesim_2404_real_gas_isentropic_factor_reference(self) -> None: def test_amesim_2404_real_gas_isentropic_factor_reference(self) -> None:
medium = AmesimHeliumPengRobinsonMedium() medium = AmesimHeliumPengRobinsonMedium()
+14
View File
@@ -88,6 +88,20 @@ class AmesimPnl0001ComponentTests(unittest.TestCase):
self.assertLess(reverse, 0.0) self.assertLess(reverse, 0.0)
self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.02) self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.02)
def test_high_pressure_near_equal_flow_is_continuous_outside_roundoff(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium, p0=15.3e6)
small = pipe.mass_flow(15.3e6 + 0.1, 15.3e6, 293.15)
larger = pipe.mass_flow(15.3e6 + 1.0, 15.3e6, 293.15)
self.assertGreater(small, 0.0)
self.assertGreater(larger, small)
def test_pressure_roundoff_does_not_create_a_fictitious_flow(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium, p0=15.3e6)
self.assertEqual(pipe.mass_flow(15.3e6, 15.3e6 + 2.0e-9, 293.15), 0.0)
def test_pressure_flow_residuals_do_not_force_storage_mass_balance(self) -> None: def test_pressure_flow_residuals_do_not_force_storage_mass_balance(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium) pipe = AmesimPnl0001("pnl_1", self.medium)
pipe.port_1.p = 101000.0 pipe.port_1.p = 101000.0
+11
View File
@@ -62,6 +62,17 @@ class AmesimPnor001ComponentTests(unittest.TestCase):
self.assertLess(reverse, 0.0) self.assertLess(reverse, 0.0)
self.assertAlmostEqual(forward, -reverse) self.assertAlmostEqual(forward, -reverse)
def test_high_pressure_near_equal_flow_is_continuous(self) -> None:
orifice = AmesimPnor001("pnor_1", self.medium)
orifice.port_1.p = 15.3e6
orifice.port_2.p = 15.3e6
small = orifice.mass_flow(15.3e6 + 0.1, 15.3e6)
larger = orifice.mass_flow(15.3e6 + 1.0, 15.3e6)
self.assertGreater(small, 0.0)
self.assertGreater(larger, small)
def test_cv_and_kv_modes_produce_positive_equivalent_area(self) -> None: def test_cv_and_kv_modes_produce_positive_equivalent_area(self) -> None:
cv_orifice = AmesimPnor001( cv_orifice = AmesimPnor001(
"pnor_cv", "pnor_cv",
+4 -1
View File
@@ -421,7 +421,10 @@ class ComponentCatalogTests(unittest.TestCase):
with self.subTest(model_type=model_type): with self.subTest(model_type=model_type):
component = self.components[model_type] component = self.components[model_type]
model_parameters = parameters(model_type) model_parameters = parameters(model_type)
self.assertEqual(component["modelVersion"], "0.2.0") self.assertEqual(
component["modelVersion"],
"0.3.0" if model_type == "amesim_pnor001" else "0.2.0",
)
self.assertEqual(model_parameters["flowset"]["editor"], "choice") self.assertEqual(model_parameters["flowset"]["editor"], "choice")
self.assertEqual(model_parameters["flowset"]["options"], flow_options) self.assertEqual(model_parameters["flowset"]["options"], flow_options)
self.assertEqual( self.assertEqual(
@@ -0,0 +1,58 @@
from __future__ import annotations
import unittest
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.pipes import AmesimPnl0001, AmesimPnl0003
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import GenericFluidSystem, simulation_preparation_issues
from app.simulation.systems.network import SimulationNetwork
class IdealPneumaticStorageCouplingTests(unittest.TestCase):
def build_network(self) -> SimulationNetwork:
medium = IdealGasMedium()
network = SimulationNetwork("ideal-pipe-compliance-coupling")
for component in (
AmesimPnpl01("closed_1"),
AmesimPnl0001("pnl0001", medium, p0=15.3e6, T0=293.15),
AmesimPnl0003(
"pnl0003",
medium,
p1_0=15.3e6,
T1_0=293.15,
p2_0=15.3e6,
T2_0=293.15,
),
AmesimPnpl01("closed_2"),
):
network.add_component(component)
network.connect("closed_1", "port_1", "pnl0001", "port_1")
network.connect("pnl0001", "port_2", "pnl0003", "port_1")
network.connect("pnl0003", "port_2", "closed_2", "port_1")
return network
def test_supported_amesim_pipe_compliances_are_not_rejected(self) -> None:
issues = simulation_preparation_issues(self.build_network())
self.assertNotIn(
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
{issue.code for issue in issues},
)
def test_coupled_pipe_compliances_run_without_fictitious_resistance(self) -> None:
system = GenericFluidSystem(self.build_network())
result = system.simulate(
SolveIVPConfig(t_stop=0.001, method="BDF", max_step=0.0002),
sample_step=0.001,
)
self.assertTrue(result.success)
self.assertAlmostEqual(result.final["pnl0001.p"], 15.3e6, delta=1.0e-4)
self.assertAlmostEqual(result.final["pnl0003.p1"], 15.3e6, delta=1.0e-4)
if __name__ == "__main__":
unittest.main()
@@ -6,11 +6,15 @@ from unittest.mock import patch
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.orifices import ( from app.simulation.components.amesim.flow.orifices import (
AmesimPnor001,
AmesimPnvo001SignalOpening, AmesimPnvo001SignalOpening,
) )
from app.simulation.components.amesim.flow.pipes import AmesimPnl00r
from app.simulation.components.amesim.flow.pipes import AmesimPnl0001
from app.simulation.components.amesim.media.mediums import ( from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium, AmesimHeliumPengRobinsonMedium,
) )
from app.simulation.components.amesim.storage.chambers import AmesimPnch023
from app.simulation.components.experimental.storage.cylinder import Cylinder from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium from app.simulation.core.medium import IdealGasMedium
@@ -21,6 +25,50 @@ from app.simulation.systems.network import SimulationNetwork
class PressureFlowSolverInitializationTests(unittest.TestCase): class PressureFlowSolverInitializationTests(unittest.TestCase):
def test_pnor_pnl0001_series_pressure_is_seeded_by_flow_balance(self) -> None:
medium = IdealGasMedium()
chamber = AmesimPnch023("source", medium, p0=15.3e6)
source_plug = AmesimPnpl01("source_closed")
orifice = AmesimPnor001("orifice", medium)
pipe = AmesimPnl0001("pipe", medium, p0=14.0e6)
storage_plug = AmesimPnpl01("storage_closed")
network = SimulationNetwork("pnor-pnl0001-series")
for component in (chamber, source_plug, orifice, pipe, storage_plug):
network.add_component(component)
network.connect("source_closed", "port_1", "source", "port_1")
network.connect("source", "port_2", "orifice", "port_1")
network.connect("orifice", "port_2", "pipe", "port_1")
network.connect("pipe", "port_2", "storage_closed", "port_1")
chamber.refresh_thermodynamic_ports()
pipe.refresh_thermodynamic_ports()
result = PressureFlowSolver(network).solve()
self.assertTrue(result.success)
self.assertGreater(orifice.port_2.p, pipe.port_2.p)
self.assertLess(orifice.port_2.p, chamber.port_2.p)
self.assertAlmostEqual(orifice.port_2.p, pipe.port_1.p)
self.assertAlmostEqual(-orifice.port_2.m_flow, pipe.port_1.m_flow)
def test_dead_ended_pnl00r_is_seeded_at_zero_flow_pressure(self) -> None:
medium = IdealGasMedium()
pipe = AmesimPnl00r("resistance", medium)
plug = AmesimPnpl01("closed")
pipe.port_1.p = 15.3e6
pipe.port_2.p = 100_000.0
network = SimulationNetwork("dead-ended-resistance")
network.add_component(pipe)
network.add_component(plug)
network.connect("resistance", "port_2", "closed", "port_1")
result = PressureFlowSolver(network).solve()
self.assertTrue(result.success)
self.assertEqual(result.evaluations, 0)
self.assertAlmostEqual(pipe.port_2.p, pipe.port_1.p)
self.assertEqual(pipe.port_1.m_flow, 0.0)
self.assertEqual(pipe.port_2.m_flow, 0.0)
@staticmethod @staticmethod
def _near_equal_pressure_network() -> tuple[ def _near_equal_pressure_network() -> tuple[
SimulationNetwork, SimulationNetwork,