from __future__ import annotations import unittest from math import log10, sqrt from unittest.mock import patch from app.simulation.components.amesim.flow.pipes import ( AmesimPnl0001, AmesimPnl0002, AmesimPnl00r, ) from app.simulation.core.medium import IdealGasMedium from app.simulation.registry import COMPONENT_MODEL_REGISTRY class _ScaledReynoldsPnl0001(AmesimPnl0001): def __init__(self, *args, reynolds_scale: float, **kwargs) -> None: super().__init__(*args, **kwargs) self.reynolds_scale = float(reynolds_scale) self.reynolds_calls: list[tuple[float, float]] = [] def reynolds_number(self, mass_flow: float, temperature: float) -> float: self.reynolds_calls.append((mass_flow, temperature)) return self.reynolds_scale * super().reynolds_number( mass_flow, temperature, ) class _DelegatingFrictionPnl0001(AmesimPnl0001): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.friction_reynolds: list[float] = [] def friction_factor(self, reynolds_number: float) -> float: self.friction_reynolds.append(reynolds_number) return super().friction_factor(reynolds_number) class AmesimPnl0001ComponentTests(unittest.TestCase): def setUp(self) -> None: self.medium = IdealGasMedium() def test_default_create_preserves_amesim_parameter_contract(self) -> None: pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0001"].create( "pnl_1", self.medium, {}, ) self.assertIsInstance(pipe, AmesimPnl0001) self.assertEqual(set(pipe.ports), {"port_1", "port_2"}) self.assertEqual( set(pipe.parameter_values), { "diam", "le", "rr", "k", "kth", "extemp", "gi", "mode", "p0", "T0", }, ) self.assertEqual(len(pipe.get_state_vector()), 2) self.assertAlmostEqual(pipe.volume, 7.853981633974483e-5) def test_rejects_fractional_integer_parameters(self) -> None: with self.assertRaisesRegex(ValueError, "gi must be an integer"): COMPONENT_MODEL_REGISTRY["amesim_pnl0001"].create( "pnl_1", self.medium, {"gi": 1.5}, ) with self.assertRaisesRegex(ValueError, "must be one of"): COMPONENT_MODEL_REGISTRY["amesim_pnl0001"].create( "pnl_1", self.medium, {"mode": 1.5}, ) def test_initial_state_uses_pipe_volume_pressure_and_temperature(self) -> None: pipe = AmesimPnl0001( "pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0, p0=15.3e6, T0=293.15, ) props = pipe.properties() self.assertAlmostEqual(pipe.volume, 1.539380400258999e-4) self.assertAlmostEqual(props.p, 15.3e6, delta=1.0e-5) self.assertAlmostEqual(props.T, 293.15) self.assertAlmostEqual(pipe.port_2.p, props.p) def test_resistance_flow_follows_pressure_gradient(self) -> None: pipe = AmesimPnl0001( "pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0, p0=15.3e6, T0=293.15, ) props = pipe.properties() forward = pipe.mass_flow(15.31e6, props.p, props.T) reverse = pipe.mass_flow(15.29e6, props.p, props.T) self.assertGreater(forward, 0.0) self.assertLess(reverse, 0.0) 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_overridden_reynolds_number_is_called_on_every_iteration(self) -> None: slow_reynolds = _ScaledReynoldsPnl0001( "slow_reynolds", self.medium, reynolds_scale=0.25, ) fast_reynolds = _ScaledReynoldsPnl0001( "fast_reynolds", self.medium, reynolds_scale=4.0, ) options = { "upstream_pressure": 2.0e6, "downstream_pressure": 1.0e6, "upstream_temperature": 300.0, "resistance_length": 1.0, "max_iterations": 4, } slow_flow = slow_reynolds._one_way_pn2pipefr_mass_flow(**options) fast_flow = fast_reynolds._one_way_pn2pipefr_mass_flow(**options) self.assertEqual(len(slow_reynolds.reynolds_calls), 4) self.assertEqual(len(fast_reynolds.reynolds_calls), 4) self.assertTrue( all( temperature == 300.0 for _mass_flow, temperature in slow_reynolds.reynolds_calls ) ) self.assertLess(slow_flow, fast_flow) def test_zero_iterations_does_not_evaluate_dynamic_viscosity(self) -> None: pipe = AmesimPnl0001("pnl_1", self.medium) original = IdealGasMedium.dynamic_viscosity with ( patch.object( IdealGasMedium, "dynamic_viscosity", autospec=True, side_effect=original, ) as dynamic_viscosity, patch( "app.simulation.components.amesim.flow.pipes.log10", side_effect=log10, ) as logarithm, ): flow = pipe._one_way_pn2pipefr_mass_flow( upstream_pressure=2.0e6, downstream_pressure=1.0e6, upstream_temperature=300.0, resistance_length=1.0, max_iterations=0, ) self.assertGreater(flow, 0.0) dynamic_viscosity.assert_not_called() logarithm.assert_not_called() def test_builtin_iterations_evaluate_dynamic_viscosity_once(self) -> None: pipe = AmesimPnl0001("pnl_1", self.medium) original = IdealGasMedium.dynamic_viscosity with patch.object( IdealGasMedium, "dynamic_viscosity", autospec=True, side_effect=original, ) as dynamic_viscosity: flow = pipe._one_way_pn2pipefr_mass_flow( upstream_pressure=2.0e6, downstream_pressure=1.0e6, upstream_temperature=300.0, resistance_length=1.0, max_iterations=4, ) self.assertGreater(flow, 0.0) dynamic_viscosity.assert_called_once_with(self.medium, 300.0) def test_builtin_fixed_point_evaluates_fully_rough_term_once(self) -> None: options = { "upstream_pressure": 2.0e6, "downstream_pressure": 1.0e6, "upstream_temperature": 300.0, "resistance_length": 1.0, "max_iterations": 4, } pipes = ( AmesimPnl00r("pnl_00r", self.medium, rr=1.0e-5), AmesimPnl0001("pnl_0001", self.medium, rr=1.0e-5), AmesimPnl0002("pnl_0002", self.medium, rr=1.0e-5), ) for pipe in pipes: with self.subTest(model=type(pipe).__name__): with patch( "app.simulation.components.amesim.flow.pipes.log10", side_effect=log10, ) as logarithm: flow = AmesimPnl0001._one_way_pn2pipefr_mass_flow( pipe, **options, ) self.assertGreater(flow, 0.0) fully_rough_argument = pipe.rr / 3.7 self.assertEqual( sum( call.args == (fully_rough_argument,) for call in logarithm.call_args_list ), 1, ) self.assertEqual(logarithm.call_count, 5) def test_overridden_friction_factor_keeps_virtual_iteration_path(self) -> None: pipe = _DelegatingFrictionPnl0001( "custom_friction", self.medium, rr=1.0e-5, ) with patch( "app.simulation.components.amesim.flow.pipes.log10", side_effect=log10, ) as logarithm: flow = pipe._one_way_pn2pipefr_mass_flow( upstream_pressure=2.0e6, downstream_pressure=1.0e6, upstream_temperature=300.0, resistance_length=1.0, max_iterations=4, ) self.assertGreater(flow, 0.0) self.assertEqual(len(pipe.friction_reynolds), 4) self.assertEqual(logarithm.call_count, 8) def test_precomputed_fully_rough_path_is_bitwise_identical_by_regime( self, ) -> None: for index, downstream_pressure in enumerate( (1_999_999.999, 1_999_990.0, 1_999_900.0, 1.0e6) ): with self.subTest(downstream_pressure=downstream_pressure): built_in = AmesimPnl0001( f"built_in_{index}", self.medium, rr=1.0e-5, ) virtual = _DelegatingFrictionPnl0001( f"virtual_{index}", self.medium, rr=1.0e-5, ) options = { "upstream_pressure": 2.0e6, "downstream_pressure": downstream_pressure, "upstream_temperature": 300.0, "resistance_length": 1.0, "max_iterations": 4, } actual = built_in._one_way_pn2pipefr_mass_flow(**options) expected = virtual._one_way_pn2pipefr_mass_flow(**options) self.assertEqual(actual.hex(), expected.hex()) def test_pressure_flow_residuals_do_not_force_storage_mass_balance(self) -> None: pipe = AmesimPnl0001("pnl_1", self.medium) pipe.port_1.p = 101000.0 pipe.port_2.m_flow = -1.0e-4 props = pipe.properties() pipe.port_1.m_flow = pipe.mass_flow(pipe.port_1.p, pipe.port_2.p, props.T) residuals = { residual.id.rsplit(":", 1)[1]: residual for residual in pipe.pressure_flow_equation_residuals() } self.assertEqual( set(residuals), {"port_2_pressure_state", "port_1_pressure_flow_relation"}, ) self.assertAlmostEqual(residuals["port_2_pressure_state"].value, 0.0) self.assertAlmostEqual(residuals["port_1_pressure_flow_relation"].value, 0.0) def test_connection_derivative_preserves_two_port_accumulation(self) -> None: pipe = AmesimPnl0001("pnl_1", self.medium, kth=2.0, extemp=310.0) props = pipe.properties() pipe.port_1.m_flow = 0.2 pipe.port_2.m_flow = -0.1 derivative = pipe.state_derivative_from_ports( { "port_1": props.h + 1000.0, "port_2": props.h - 1000.0, } ) self.assertAlmostEqual(derivative[0], 0.1) self.assertGreater(derivative[1], 0.0) def test_results_include_thermodynamic_and_pipe_diagnostics(self) -> None: pipe = AmesimPnl0001("pnl_1", self.medium) pipe.port_1.p = 101000.0 values = pipe.component_result_values() self.assertEqual( set(values), {"m", "U", "p", "T", "rho", "u", "h", "re", "cm", "v", "ff"}, ) def test_result_cm_excludes_pipe_flow_coefficient(self) -> None: pipe = AmesimPnl0001( "pnl_1", self.medium, diam=0.014, le=0.3, rr=0.045 / 14.0, p0=2.5e6, T0=290.0, ) pipe.port_1.p = 1.6e6 props = pipe.properties() flow = pipe.mass_flow(pipe.port_1.p, props.p, props.T) reynolds = pipe.reynolds_number(flow, props.T) friction = pipe.friction_factor(reynolds) raw_cq_cm = ( abs(flow) * sqrt(props.T) / (pipe.area * max(pipe.port_1.p, props.p)) ) flow_coefficient = sqrt(pipe.diam / (pipe.le * friction)) actual = pipe.component_result_values()["cm"] self.assertAlmostEqual(actual, raw_cq_cm / flow_coefficient) self.assertNotAlmostEqual(actual, raw_cq_cm) if __name__ == "__main__": unittest.main()