优化仿真求解性能并修复流量闭合问题(初版)

This commit is contained in:
ljz committed 2026-08-16 17:46:05 +08:00
1 parent 57b459bc72
commit 5332a788f3
55 files changed
+8973 -549

No files matched your search

+325
View File
@@ -0,0 +1,325 @@
<?xml version="1.0" encoding="UTF-8"?>
<System name="demo-system" schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="10" sampleStep="0.02" maxStep="0.02" method="RK45"/>
<Components>
<Component id="amesim_pnch023_1" type="amesim_pnch023" modelVersion="0.1.0">
<Parameter name="gi" value="1"/>
<Parameter name="cvol" value="0.057"/>
<Parameter name="kth" value="0"/>
<Parameter name="sth" value="0.1"/>
<Parameter name="extemp" value="293.15"/>
<Parameter name="p0" value="15300000"/>
<Parameter name="T0" value="293.15"/>
</Component>
<Component id="amesim_pnvo001_1" type="amesim_pnvo001" modelVersion="0.2.0">
<Parameter name="gi" value="1"/>
<Parameter name="cq" value="0.45"/>
<Parameter name="area0" value="0.0000785"/>
<Parameter name="Cv" value="0.5"/>
<Parameter name="Kv" value="0.4"/>
<Parameter name="flowset" value="1"/>
<Parameter name="opening0" value="0"/>
</Component>
<Component id="amesim_step0_1" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="0"/>
<Parameter name="final" value="1"/>
<Parameter name="time" value="0.04"/>
</Component>
<Component id="amesim_pnch012_1" type="amesim_pnch012" modelVersion="0.1.0">
<Parameter name="gi" value="1"/>
<Parameter name="cvol0" value="0.015"/>
<Parameter name="kth" value="1500"/>
<Parameter name="sth" value="0.7"/>
<Parameter name="extemp" value="293.15"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="293.15"/>
<Parameter name="vol1" value="0"/>
<Parameter name="vol2" value="0"/>
<Parameter name="vol3" value="0"/>
<Parameter name="vol4" value="0"/>
<Parameter name="dvol1" value="0"/>
<Parameter name="dvol2" value="0"/>
<Parameter name="dvol3" value="0"/>
<Parameter name="dvol4" value="0"/>
</Component>
<Component id="amesim_pnpl01_2" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_pnpl01_3" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_pnpl01_4" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_ud00_1" type="amesim_ud00" modelVersion="0.2.0">
<Parameter name="tstart" value="0"/>
<Parameter name="start1" value="100000000000000000"/>
<Parameter name="end1" value="100000000000000000"/>
<Parameter name="t1" value="0.8"/>
<Parameter name="start2" value="49000"/>
<Parameter name="end2" value="49000"/>
<Parameter name="t2" value="10"/>
<Parameter name="start3" value="1"/>
<Parameter name="end3" value="1"/>
<Parameter name="t3" value="0"/>
<Parameter name="start4" value="1"/>
<Parameter name="end4" value="1"/>
<Parameter name="t4" value="0"/>
<Parameter name="start5" value="1"/>
<Parameter name="end5" value="1"/>
<Parameter name="t5" value="0"/>
<Parameter name="start6" value="1"/>
<Parameter name="end6" value="1"/>
<Parameter name="t6" value="0"/>
<Parameter name="start7" value="1"/>
<Parameter name="end7" value="1"/>
<Parameter name="t7" value="0"/>
<Parameter name="start8" value="1"/>
<Parameter name="end8" value="1"/>
<Parameter name="t8" value="0"/>
<Parameter name="nstages" value="2"/>
<Parameter name="iscyclic" value="0"/>
</Component>
<Component id="amesim_forc_1" type="amesim_forc" modelVersion="0.2.0">
<Parameter name="direction" value="1"/>
</Component>
<Component id="amesim_pnrp17_1" type="amesim_pnrp17" modelVersion="0.1.0">
<Parameter name="gi" value="1"/>
<Parameter name="dp" value="0.2"/>
<Parameter name="dr" value="0.001"/>
<Parameter name="x0" value="0"/>
</Component>
<Component id="amesim_helium_medium_1" type="amesim_helium_medium" modelVersion="0.1.0">
<Parameter name="gi" value="1"/>
<Parameter name="property_model" value="0"/>
</Component>
<Component id="amesim_pnch012_2" type="amesim_pnch012" modelVersion="0.1.0">
<Parameter name="gi" value="1"/>
<Parameter name="cvol0" value="0.015"/>
<Parameter name="kth" value="1500"/>
<Parameter name="sth" value="0.7"/>
<Parameter name="extemp" value="293.15"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="293.15"/>
<Parameter name="vol1" value="0"/>
<Parameter name="vol2" value="0"/>
<Parameter name="vol3" value="0"/>
<Parameter name="vol4" value="0"/>
<Parameter name="dvol1" value="0"/>
<Parameter name="dvol2" value="0"/>
<Parameter name="dvol3" value="0"/>
<Parameter name="dvol4" value="0"/>
</Component>
<Component id="amesim_pnpl01_6" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_pnpl01_7" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_pnpl01_8" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_pnpl01_9" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_mecmas21_2" type="amesim_mecmas21" modelVersion="0.2.0">
<Parameter name="mass" value="50"/>
<Parameter name="fstick" value="0"/>
<Parameter name="fcoul" value="0"/>
<Parameter name="rvisc" value="0"/>
<Parameter name="wind" value="0"/>
<Parameter name="dvel" value="0.000001"/>
<Parameter name="restdvel" value="0.000001"/>
<Parameter name="restcoeff" value="0.65"/>
<Parameter name="astrib" value="0.001"/>
<Parameter name="xmin" value="-1"/>
<Parameter name="Kbmin" value="1000000000"/>
<Parameter name="Dbmin" value="10000"/>
<Parameter name="Pdmin" value="0.0001"/>
<Parameter name="xmax" value="0.8"/>
<Parameter name="Kbmax" value="1000000000"/>
<Parameter name="Dbmax" value="10000"/>
<Parameter name="Pdmax" value="0.0001"/>
<Parameter name="theta" value="0"/>
<Parameter name="useFriction" value="1"/>
<Parameter name="stoptype" value="4"/>
<Parameter name="discContactOption" value="1"/>
<Parameter name="strib" value="1"/>
<Parameter name="frictionType" value="1"/>
<Parameter name="v0" value="0"/>
<Parameter name="x0" value="0"/>
</Component>
<Component id="amesim_f000_1" type="amesim_f000" modelVersion="0.1.0"/>
<Component id="amesim_f000_2" type="amesim_f000" modelVersion="0.1.0"/>
<Component id="amesim_lstp00a_1" type="amesim_lstp00a" modelVersion="0.2.0">
<Parameter name="na" value="10"/>
<Parameter name="gap0" value="0"/>
<Parameter name="kcont" value="100000000000"/>
<Parameter name="G" value="85700000000"/>
<Parameter name="sdiam" value="0.02"/>
<Parameter name="wdiam" value="0.002"/>
<Parameter name="rcont" value="100000000000"/>
<Parameter name="Pdis" value="1e-7"/>
<Parameter name="stiffmode" value="1"/>
<Parameter name="discContactOption" value="1"/>
</Component>
<Component id="amesim_forc_2" type="amesim_forc" modelVersion="0.2.0">
<Parameter name="direction" value="1"/>
</Component>
<Component id="amesim_ud00_2" type="amesim_ud00" modelVersion="0.2.0">
<Parameter name="tstart" value="0"/>
<Parameter name="start1" value="1000000000000"/>
<Parameter name="end1" value="1000000000000"/>
<Parameter name="t1" value="0.8"/>
<Parameter name="start2" value="0"/>
<Parameter name="end2" value="0"/>
<Parameter name="t2" value="10"/>
<Parameter name="start3" value="1"/>
<Parameter name="end3" value="1"/>
<Parameter name="t3" value="0"/>
<Parameter name="start4" value="1"/>
<Parameter name="end4" value="1"/>
<Parameter name="t4" value="0"/>
<Parameter name="start5" value="1"/>
<Parameter name="end5" value="1"/>
<Parameter name="t5" value="0"/>
<Parameter name="start6" value="1"/>
<Parameter name="end6" value="1"/>
<Parameter name="t6" value="0"/>
<Parameter name="start7" value="1"/>
<Parameter name="end7" value="1"/>
<Parameter name="t7" value="0"/>
<Parameter name="start8" value="1"/>
<Parameter name="end8" value="1"/>
<Parameter name="t8" value="0"/>
<Parameter name="nstages" value="2"/>
<Parameter name="iscyclic" value="0"/>
</Component>
<Component id="amesim_mecmas21_5" type="amesim_mecmas21" modelVersion="0.2.0">
<Parameter name="mass" value="170000"/>
<Parameter name="fstick" value="0"/>
<Parameter name="fcoul" value="0"/>
<Parameter name="rvisc" value="0"/>
<Parameter name="wind" value="0"/>
<Parameter name="dvel" value="0.000001"/>
<Parameter name="restdvel" value="0.000001"/>
<Parameter name="restcoeff" value="0.65"/>
<Parameter name="astrib" value="0.001"/>
<Parameter name="xmin" value="0"/>
<Parameter name="Kbmin" value="1000000000"/>
<Parameter name="Dbmin" value="10000"/>
<Parameter name="Pdmin" value="0.0001"/>
<Parameter name="xmax" value="0.37"/>
<Parameter name="Kbmax" value="1000000000"/>
<Parameter name="Dbmax" value="10000"/>
<Parameter name="Pdmax" value="0.0001"/>
<Parameter name="theta" value="0"/>
<Parameter name="useFriction" value="1"/>
<Parameter name="stoptype" value="1"/>
<Parameter name="discContactOption" value="1"/>
<Parameter name="strib" value="1"/>
<Parameter name="frictionType" value="1"/>
<Parameter name="v0" value="0"/>
<Parameter name="x0" value="0"/>
</Component>
<Component id="amesim_mecmas21_7" type="amesim_mecmas21" modelVersion="0.2.0">
<Parameter name="mass" value="90000"/>
<Parameter name="fstick" value="0"/>
<Parameter name="fcoul" value="0"/>
<Parameter name="rvisc" value="0"/>
<Parameter name="wind" value="0"/>
<Parameter name="dvel" value="0.000001"/>
<Parameter name="restdvel" value="0.000001"/>
<Parameter name="restcoeff" value="0.65"/>
<Parameter name="astrib" value="0.001"/>
<Parameter name="xmin" value="-0.72"/>
<Parameter name="Kbmin" value="1000000000"/>
<Parameter name="Dbmin" value="10000"/>
<Parameter name="Pdmin" value="0.0001"/>
<Parameter name="xmax" value="0"/>
<Parameter name="Kbmax" value="1000000000"/>
<Parameter name="Dbmax" value="10000"/>
<Parameter name="Pdmax" value="0.0001"/>
<Parameter name="theta" value="0"/>
<Parameter name="useFriction" value="1"/>
<Parameter name="stoptype" value="1"/>
<Parameter name="discContactOption" value="1"/>
<Parameter name="strib" value="1"/>
<Parameter name="frictionType" value="1"/>
<Parameter name="v0" value="0"/>
<Parameter name="x0" value="0"/>
</Component>
</Components>
<Connections>
<Connection id="edge-amesim_pnch023_1-port_2-amesim_pnvo001_1-port_2-1786524999267">
<Endpoint component="amesim_pnch023_1" port="port_2"/>
<Endpoint component="amesim_pnvo001_1" port="port_2"/>
</Connection>
<Connection id="edge-amesim_pnvo001_1-port_3-amesim_pnch012_1-port_1-1786525009973">
<Endpoint component="amesim_pnvo001_1" port="port_3"/>
<Endpoint component="amesim_pnch012_1" port="port_1"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_2-port_1-amesim_pnch012_1-port_2-1786525035706-0">
<Endpoint component="amesim_pnpl01_2" port="port_1"/>
<Endpoint component="amesim_pnch012_1" port="port_2"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_3-port_1-amesim_pnch012_1-port_3-1786525054054-0">
<Endpoint component="amesim_pnpl01_3" port="port_1"/>
<Endpoint component="amesim_pnch012_1" port="port_3"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_4-port_1-amesim_pnch012_1-port_4-1786525061349-0">
<Endpoint component="amesim_pnpl01_4" port="port_1"/>
<Endpoint component="amesim_pnch012_1" port="port_4"/>
</Connection>
<Connection id="edge-amesim_step0_1-out-amesim_pnvo001_1-res-1786525074494">
<Endpoint component="amesim_step0_1" port="out"/>
<Endpoint component="amesim_pnvo001_1" port="res"/>
</Connection>
<Connection id="edge-amesim_ud00_1-out-amesim_forc_1-res-1786525088412">
<Endpoint component="amesim_ud00_1" port="out"/>
<Endpoint component="amesim_forc_1" port="res"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_6-port_1-amesim_pnch023_1-port_1-1786525164428-0">
<Endpoint component="amesim_pnpl01_6" port="port_1"/>
<Endpoint component="amesim_pnch023_1" port="port_1"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_7-port_1-amesim_pnch012_2-port_1-1786525181969-0">
<Endpoint component="amesim_pnpl01_7" port="port_1"/>
<Endpoint component="amesim_pnch012_2" port="port_1"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_8-port_1-amesim_pnch012_2-port_4-1786525185493-0">
<Endpoint component="amesim_pnpl01_8" port="port_1"/>
<Endpoint component="amesim_pnch012_2" port="port_4"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_9-port_1-amesim_pnch012_2-port_2-1786525188482-0">
<Endpoint component="amesim_pnpl01_9" port="port_1"/>
<Endpoint component="amesim_pnch012_2" port="port_2"/>
</Connection>
<Connection id="edge-contact-amesim_f000_1-port_1-amesim_mecmas21_2-port_2-1786525202665-0">
<Endpoint component="amesim_f000_1" port="port_1"/>
<Endpoint component="amesim_mecmas21_2" port="port_2"/>
</Connection>
<Connection id="edge-amesim_mecmas21_2-port_1-amesim_pnrp17_1-port_2-1786525203778">
<Endpoint component="amesim_mecmas21_2" port="port_1"/>
<Endpoint component="amesim_pnrp17_1" port="port_2"/>
</Connection>
<Connection id="edge-amesim_pnrp17_1-port_5-amesim_lstp00a_1-port_1-1786525213548">
<Endpoint component="amesim_pnrp17_1" port="port_5"/>
<Endpoint component="amesim_lstp00a_1" port="port_1"/>
</Connection>
<Connection id="edge-amesim_ud00_2-out-amesim_forc_2-res-1786525226761">
<Endpoint component="amesim_ud00_2" port="out"/>
<Endpoint component="amesim_forc_2" port="res"/>
</Connection>
<Connection id="edge-contact-amesim_pnrp17_1-port_4-amesim_f000_2-port_1-1786525729832-0">
<Endpoint component="amesim_pnrp17_1" port="port_4"/>
<Endpoint component="amesim_f000_2" port="port_1"/>
</Connection>
<Connection id="edge-contact-amesim_pnrp17_1-port_1-amesim_pnch012_2-port_3-1786525729832-1">
<Endpoint component="amesim_pnrp17_1" port="port_1"/>
<Endpoint component="amesim_pnch012_2" port="port_3"/>
</Connection>
<Connection id="edge-amesim_forc_1-port_2-amesim_mecmas21_7-port_2-1786525915163">
<Endpoint component="amesim_forc_1" port="port_2"/>
<Endpoint component="amesim_mecmas21_7" port="port_2"/>
</Connection>
<Connection id="edge-amesim_mecmas21_7-port_1-amesim_pnrp17_1-port_3-1786525916652">
<Endpoint component="amesim_mecmas21_7" port="port_1"/>
<Endpoint component="amesim_pnrp17_1" port="port_3"/>
</Connection>
<Connection id="edge-amesim_mecmas21_5-port_1-amesim_forc_2-port_2-1786525922010">
<Endpoint component="amesim_mecmas21_5" port="port_1"/>
<Endpoint component="amesim_forc_2" port="port_2"/>
</Connection>
<Connection id="edge-amesim_lstp00a_1-port_2-amesim_mecmas21_5-port_2-1786525924224">
<Endpoint component="amesim_lstp00a_1" port="port_2"/>
<Endpoint component="amesim_mecmas21_5" port="port_2"/>
</Connection>
</Connections>
</System>
+21 -18
View File
@@ -13,6 +13,7 @@ from app.simulation.components.amesim.media.mediums import (
from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.peng_robinson import HELIUM_PR
from app.simulation.property_cache import property_cache_run
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
from tests.test_generic_system_xml_simulation import component_node
from tests.test_system_xml_protocol import physical_port
@@ -93,17 +94,18 @@ class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase):
delta=1.0e-8,
)
medium.temperature_from_pressure_enthalpy.cache_clear()
first = medium.temperature_from_pressure_enthalpy(
pressure,
transport_enthalpy,
)
after_first = medium.temperature_from_pressure_enthalpy.cache_info()
second = medium.temperature_from_pressure_enthalpy(
pressure,
transport_enthalpy,
)
after_second = medium.temperature_from_pressure_enthalpy.cache_info()
with property_cache_run() as cache:
assert cache is not None
first = medium.temperature_from_pressure_enthalpy(
pressure,
transport_enthalpy,
)
after_first = cache.info()
second = medium.temperature_from_pressure_enthalpy(
pressure,
transport_enthalpy,
)
after_second = cache.info()
self.assertEqual(second, first)
self.assertEqual(after_first.misses, 1)
self.assertEqual(after_second.hits, 1)
@@ -146,13 +148,14 @@ class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase):
temperature,
)
first = medium.properties_from_mU(mass, internal_energy, volume)
second = medium.properties_from_mU(mass, internal_energy, volume)
changed = medium.properties_from_mU(
mass,
internal_energy,
volume * 1.01,
)
with property_cache_run():
first = medium.properties_from_mU(mass, internal_energy, volume)
second = medium.properties_from_mU(mass, internal_energy, volume)
changed = medium.properties_from_mU(
mass,
internal_energy,
volume * 1.01,
)
self.assertIs(second, first)
self.assertIsNot(changed, first)
@@ -241,6 +241,85 @@ class AmesimPnl0003ComponentTests(unittest.TestCase):
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
def test_center_flow_is_continuous_across_former_relative_deadband(self) -> None:
base_pressure = 8.0e6
former_threshold = base_pressure * 1.0e-7
below = AmesimPnl0003(
"below",
self.medium,
p1_0=base_pressure + 0.99 * former_threshold,
p2_0=base_pressure,
).resistance_mass_flow()
above = AmesimPnl0003(
"above",
self.medium,
p1_0=base_pressure + 1.01 * former_threshold,
p2_0=base_pressure,
).resistance_mass_flow()
reverse = AmesimPnl0003(
"reverse",
self.medium,
p1_0=base_pressure,
p2_0=base_pressure + 0.99 * former_threshold,
).resistance_mass_flow()
equal = AmesimPnl0003(
"equal",
self.medium,
p1_0=base_pressure,
p2_0=base_pressure,
).resistance_mass_flow()
small = AmesimPnl0003(
"small",
self.medium,
p1_0=15.3e6 + 0.1,
p2_0=15.3e6,
).resistance_mass_flow()
self.assertGreater(below, 0.0)
self.assertGreater(above, below)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
self.assertLess(abs(above - below), abs(above) * 0.05)
self.assertEqual(equal, 0.0)
self.assertGreater(small, 0.0)
def test_center_flow_is_continuous_across_former_relative_deadband(self) -> None:
base_pressure = 8.0e6
former_threshold = base_pressure * 1.0e-7
below = AmesimPnl0003(
"below",
self.medium,
p1_0=base_pressure + 0.99 * former_threshold,
p2_0=base_pressure,
).resistance_mass_flow()
above = AmesimPnl0003(
"above",
self.medium,
p1_0=base_pressure + 1.01 * former_threshold,
p2_0=base_pressure,
).resistance_mass_flow()
reverse = AmesimPnl0003(
"reverse",
self.medium,
p1_0=base_pressure,
p2_0=base_pressure + 0.99 * former_threshold,
).resistance_mass_flow()
equal = AmesimPnl0003(
"equal",
self.medium,
p1_0=base_pressure,
p2_0=base_pressure,
).resistance_mass_flow()
self.assertGreater(below, 0.0)
self.assertGreater(above, below)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
self.assertLess(abs(above - below), abs(above) * 0.05)
self.assertEqual(equal, 0.0)
def test_pressure_flow_residuals_bind_both_port_pressures_to_states(self) -> None:
pipe = AmesimPnl0003("pnl_3", self.medium)
pipe.properties_1()
+33
View File
@@ -59,6 +59,39 @@ class AmesimPnl00rComponentTests(unittest.TestCase):
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(forward, -reverse, delta=abs(forward) * 0.02)
def test_mass_flow_is_continuous_across_former_relative_deadband(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0)
base_pressure = 8.0e6
former_threshold = base_pressure * 1.0e-7
below = pipe.mass_flow(base_pressure + 0.99 * former_threshold, base_pressure)
above = pipe.mass_flow(base_pressure + 1.01 * former_threshold, base_pressure)
reverse = pipe.mass_flow(base_pressure, base_pressure + 0.99 * former_threshold)
self.assertGreater(below, 0.0)
self.assertGreater(above, below)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
self.assertLess(abs(above - below), abs(above) * 0.05)
self.assertEqual(pipe.mass_flow(base_pressure, base_pressure), 0.0)
self.assertGreater(pipe.mass_flow(15.3e6 + 0.1, 15.3e6), 0.0)
def test_mass_flow_is_continuous_across_former_relative_deadband(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0)
base_pressure = 8.0e6
former_threshold = base_pressure * 1.0e-7
below = pipe.mass_flow(base_pressure + 0.99 * former_threshold, base_pressure)
above = pipe.mass_flow(base_pressure + 1.01 * former_threshold, base_pressure)
reverse = pipe.mass_flow(base_pressure, base_pressure + 0.99 * former_threshold)
self.assertGreater(below, 0.0)
self.assertGreater(above, below)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
self.assertLess(abs(above - below), abs(above) * 0.05)
self.assertEqual(pipe.mass_flow(base_pressure, base_pressure), 0.0)
def test_friction_factor_transitions_from_laminar_to_turbulent(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium, rr=1e-5)
@@ -64,6 +64,23 @@ class AmesimPnvo001FixedOpeningComponentTests(unittest.TestCase):
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(forward, -reverse)
def test_mass_flow_is_continuous_across_former_relative_deadband(self) -> None:
valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5)
base_pressure = 8.0e6
former_threshold = base_pressure * 1.0e-7
below = valve.mass_flow(base_pressure + 0.99 * former_threshold, base_pressure)
above = valve.mass_flow(base_pressure + 1.01 * former_threshold, base_pressure)
reverse = valve.mass_flow(base_pressure, base_pressure + 0.99 * former_threshold)
self.assertGreater(below, 0.0)
self.assertGreater(above, below)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
self.assertLess(abs(above - below), abs(above) * 0.05)
self.assertEqual(valve.mass_flow(base_pressure, base_pressure), 0.0)
self.assertGreater(valve.mass_flow(15.3e6 + 0.1, 15.3e6), 0.0)
def test_mass_flow_uses_connected_enthalpy_from_the_upstream_side(self) -> None:
valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5)
hot_h = self.medium.specific_enthalpy(600.0)
@@ -162,6 +162,8 @@ class ContactSolverCausalizationTests(unittest.TestCase):
self.assertTrue(diagnostics.success, diagnostics.message)
self.assertGreater(diagnostics.evaluations, 0)
self.assertEqual(diagnostics.jacobian_mode, "dense")
self.assertFalse(diagnostics.dense_fallback_used)
self.assertAlmostEqual(load.pneumatic.p, 41.0, delta=1.0e-3)
self.assertAlmostEqual(load.mechanical.f, -41.0, delta=1.0e-3)
self.assertAlmostEqual(contact.contact_force, 41.0, delta=1.0e-3)
+175
View File
@@ -12,6 +12,36 @@ from app.simulation.solvers.solver import (
)
def _counting_fixed_step_solver(
step_size: float,
dense_output_times: list[float],
):
import numpy as np
class FixedStepSolver:
def __init__(self, _fun, t0, y0, t_bound, **_kwargs):
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.t_bound = float(t_bound)
self.status = "running"
self.nfev = 0
self.njev = 0
self.nlu = 0
def step(self):
self.t = min(self.t + step_size, self.t_bound)
self.y = np.asarray([self.t], dtype=float)
if self.t >= self.t_bound:
self.status = "finished"
return None
def dense_output(self):
dense_output_times.append(self.t)
return lambda time: np.asarray([float(time)], dtype=float)
return FixedStepSolver
class IntegrateOdeTests(unittest.TestCase):
def test_generic_solver_keeps_canonical_default_tolerance(self) -> None:
self.assertEqual(SolveIVPConfig().atol, 1.0e-8)
@@ -159,6 +189,151 @@ class IntegrateOdeTests(unittest.TestCase):
self.assertEqual(result.status, "cancelled")
self.assertEqual(result.t, [0.0])
def test_stepwise_solvers_build_dense_output_only_when_crossing_a_sample(
self,
) -> None:
import scipy.integrate
for method in ("BDF", "Radau", "RK45"):
with self.subTest(method=method):
dense_output_times: list[float] = []
fixed_step_solver = _counting_fixed_step_solver(
0.2,
dense_output_times,
)
with patch.object(scipy.integrate, method, fixed_step_solver):
result = integrate_ode(
rhs=lambda _time, _state: [1.0],
initial_state=[0.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method=method,
max_step=1.0,
),
t_eval=[0.0, 0.75, 1.0],
cancel_check=lambda: False,
)
self.assertTrue(result.success, result.message)
self.assertEqual(result.t, [0.0, 0.75, 1.0])
self.assertEqual(result.y, [[0.0, 0.75, 1.0]])
self.assertEqual(dense_output_times, [0.8, 1.0])
self.assertEqual(
result.solver_segments[0].accepted_step_count,
5,
)
def test_stepwise_solvers_keep_dense_output_for_state_event_detection(
self,
) -> None:
import scipy.integrate
for method in ("BDF", "Radau", "RK45"):
with self.subTest(method=method):
dense_output_times: list[float] = []
inspected_steps = 0
fixed_step_solver = _counting_fixed_step_solver(
0.25,
dense_output_times,
)
def inspect_state_event(
previous_time,
_previous_state,
current_time,
_current_state,
dense_state,
):
nonlocal inspected_steps
inspected_steps += 1
midpoint = 0.5 * (previous_time + current_time)
self.assertAlmostEqual(dense_state(midpoint)[0], midpoint)
return None
with patch.object(scipy.integrate, method, fixed_step_solver):
result = integrate_ode(
rhs=lambda _time, _state: [1.0],
initial_state=[0.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method=method,
max_step=1.0,
),
t_eval=[0.0, 1.0],
state_transition_handler=inspect_state_event,
)
self.assertTrue(result.success, result.message)
self.assertEqual(len(dense_output_times), 4)
self.assertEqual(inspected_steps, 4)
def test_stepwise_dense_output_preserves_adjacent_float_samples(self) -> None:
import scipy.integrate
dense_output_times: list[float] = []
adjacent_time = math.nextafter(0.5, math.inf)
half_interval_solver = _counting_fixed_step_solver(
0.5,
dense_output_times,
)
with patch.object(scipy.integrate, "RK45", half_interval_solver):
result = integrate_ode(
rhs=lambda _time, _state: [1.0],
initial_state=[0.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method="RK45",
max_step=1.0,
),
t_eval=[0.0, 0.5, adjacent_time, 1.0],
cancel_check=lambda: False,
)
self.assertTrue(result.success, result.message)
self.assertEqual(result.t, [0.0, 0.5, adjacent_time, 1.0])
self.assertEqual(result.y[0], result.t)
self.assertEqual(dense_output_times, [0.5, 1.0])
def test_dense_output_pruning_preserves_cancelled_partial_samples(self) -> None:
import scipy.integrate
cancellation_requested = False
dense_output_times: list[float] = []
fixed_step_solver = _counting_fixed_step_solver(
0.2,
dense_output_times,
)
def request_cancel(time: float) -> None:
nonlocal cancellation_requested
cancellation_requested = time >= 0.4
with patch.object(scipy.integrate, "BDF", fixed_step_solver):
result = integrate_ode(
rhs=lambda _time, _state: [1.0],
initial_state=[0.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method="BDF",
max_step=1.0,
),
t_eval=[0.0, 0.3, 0.8, 1.0],
cancel_check=lambda: cancellation_requested,
accepted_step_callback=request_cancel,
)
self.assertFalse(result.success)
self.assertEqual(result.status, "cancelled")
self.assertEqual(result.t, [0.0, 0.3, 0.4])
self.assertEqual(result.y, [[0.0, 0.3, 0.4]])
self.assertEqual(dense_output_times, [0.4])
def test_segmented_bdf_uses_left_limit_and_restarts_at_event(self) -> None:
import scipy.integrate
+280
View File
@@ -0,0 +1,280 @@
from __future__ import annotations
from collections.abc import Mapping
from types import SimpleNamespace
import unittest
import numpy as np
from scipy.integrate._ivp.common import num_jac
from app.main import ReactFlowProjectPayload, compile_reactflow_network
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.base import DynamicComponent
from app.simulation.core.ports import PortDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.mechanical import MechanicalConstraintGroup
from app.simulation.systems.generic import GenericFluidSystem
from app.simulation.systems.network import Endpoint
from tests.test_amesim_pnrp17_xml import pnrp17_coupled_project
from tests.test_generic_system_xml_simulation import component_node, physical_edge
from tests.test_system_xml_protocol import physical_port
class _DynamicVolumeSource(DynamicComponent):
PORTS = (PortDefinition.pneumatic("port"),)
state_size = 1
def __init__(self, name: str) -> None:
super().__init__(name)
self.state = 0.25
self.port = self.register_declared_port("port")
def get_state_vector(self) -> list[float]:
return [self.state]
def set_state_vector(self, values: list[float]) -> None:
self.state = float(values[0])
def refresh_thermodynamic_ports(self):
return None
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
return [0.0]
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
return {"port": (self.state, 0.0)}
class _TrustedDynamicVolumeSource(_DynamicVolumeSource):
pass
_TrustedDynamicVolumeSource.__module__ = "app.simulation.components.synthetic"
class _UntrustedDynamicVolumeSource(_DynamicVolumeSource):
pass
def cross_domain_storage_project() -> ReactFlowProjectPayload:
"""PNL storage -> variable chamber -> pneumatic piston -> two masses."""
base = pnrp17_coupled_project()
nodes = [node.model_dump() for node in base.nodes]
nodes.append(
component_node(
"line_storage",
"amesim_pnl0001",
[
physical_port("port_1", "bidirectional", "left"),
physical_port("port_2", "bidirectional", "right"),
],
{
"diam": 0.01,
"le": 1.0,
"rr": 1.0e-5,
"k": 1.35,
"kth": 0.0,
"extemp": 300.0,
"gi": 0.0,
"mode": 2.0,
"p0": 200000.0,
"T0": 300.0,
},
)
)
edges = [
edge.model_dump()
for edge in base.edges
if edge.id != "edge-boundary-1"
]
edges.extend(
(
physical_edge(
"edge-chamber-line",
"chamber_1",
"port_1",
"line_storage",
"port_1",
),
physical_edge(
"edge-line-boundary",
"line_storage",
"port_2",
"boundary_1",
"port_1",
),
)
)
return ReactFlowProjectPayload(
projectSchemaVersion=base.projectSchemaVersion,
name="cross-domain-jacobian-sparsity",
nodes=nodes,
edges=edges,
simulation=base.simulation.model_dump(),
)
def state_slices(system: GenericFluidSystem) -> dict[str, slice]:
result: dict[str, slice] = {}
cursor = 0
for entry in system.mechanical_state_reducer.state_entries:
if isinstance(entry, MechanicalConstraintGroup):
entry_size = 2
names = tuple(component.name for component in entry.components)
else:
entry_size = entry.state_size
names = (entry.name,)
state_slice = slice(cursor, cursor + entry_size)
for name in names:
result[name] = state_slice
cursor += entry_size
return result
class GenericJacobianSparsityTests(unittest.TestCase):
def setUp(self) -> None:
self.system = GenericFluidSystem(
compile_reactflow_network(cross_domain_storage_project())
)
def test_external_volume_connects_mechanical_and_nearby_storage_states(self) -> None:
slices = state_slices(self.system)
pattern = self.system.jacobian_sparsity().toarray().astype(bool)
line_states = range(
slices["line_storage"].start,
slices["line_storage"].stop,
)
mechanical_states = [
state_index
for name in ("piston_mass", "cylinder_mass")
for state_index in range(slices[name].start, slices[name].stop)
]
self.assertTrue(
pattern[np.ix_(tuple(line_states), tuple(mechanical_states))].all()
)
self.assertTrue(
pattern[np.ix_(tuple(mechanical_states), tuple(line_states))].all()
)
def _apply_dynamic_volume_dependency_probe(
self,
source: _DynamicVolumeSource,
) -> list[set[int]]:
medium = IdealGasMedium()
receiver = Cylinder("receiver", medium, V=0.1, p0=200_000.0)
remote = Tank("remote", medium, V=0.1, p0=100_000.0)
system = GenericFluidSystem.__new__(GenericFluidSystem)
system.pneumatic_volume_resolver = SimpleNamespace(
_output_components=(source,),
_connected_endpoint={
Endpoint(source.name, "port"): SimpleNamespace(
connected_endpoint=Endpoint("receiver", "port_b")
)
},
)
dependencies = [
{0, 1},
{0, 1},
{1, 2},
]
system._add_pneumatic_volume_state_dependencies(
dependencies,
(source, receiver, remote),
{"source": 0, "receiver": 1, "remote": 2},
)
return dependencies
def test_dynamic_volume_source_ode_state_drives_remote_pneumatic_state(
self,
) -> None:
dependencies = self._apply_dynamic_volume_dependency_probe(
_TrustedDynamicVolumeSource("source")
)
self.assertIn(0, dependencies[2])
self.assertIn(2, dependencies[0])
def test_untrusted_volume_source_disables_ode_jacobian_sparsity(self) -> None:
dependencies = self._apply_dynamic_volume_dependency_probe(
_UntrustedDynamicVolumeSource("source")
)
self.assertEqual(dependencies, [{0, 1, 2}] * 3)
def test_dense_numerical_jacobian_has_no_significant_entry_outside_pattern(
self,
) -> None:
state = np.asarray(self.system.consistent_initial_state_vector(0.0))
slices = state_slices(self.system)
# Move one piston face away from the zero-volume reference so the
# chamber/line flow has a measurable local volume derivative. This is
# an operating-point probe only; the state remains well inside the
# chamber's positive total-volume domain.
state[slices["piston_mass"].stop - 1] = 1.0e-3
def evaluate_one(values: np.ndarray) -> np.ndarray:
return np.asarray(
self.system.rhs(0.0, [float(value) for value in values])
)
def evaluate(_time: float, values: np.ndarray) -> np.ndarray:
if values.ndim == 1:
return evaluate_one(values)
return np.column_stack(
[evaluate_one(values[:, index]) for index in range(values.shape[1])]
)
derivative = evaluate(0.0, state)
absolute_tolerance = np.asarray(
self.system.mechanical_state_reducer.absolute_tolerances(1.0e-8)
)
dense_jacobian, _factor = num_jac(
evaluate,
0.0,
state,
derivative,
absolute_tolerance / 1.0e-6,
None,
None,
)
pattern = self.system.jacobian_sparsity().toarray().astype(bool)
missed = np.abs(np.asarray(dense_jacobian)) * ~pattern
column_scale = np.maximum(
1.0,
np.max(np.abs(np.asarray(dense_jacobian)), axis=0),
)
self.assertFalse(
np.any(missed > 1.0e-6 * column_scale[np.newaxis, :]),
f"maximum omitted derivative was {float(np.max(missed))}",
)
line_rows = range(
slices["line_storage"].start,
slices["line_storage"].stop,
)
piston_columns = range(
slices["piston_mass"].start,
slices["piston_mass"].stop,
)
self.assertGreater(
float(
np.max(
np.abs(
np.asarray(dense_jacobian)[
np.ix_(tuple(line_rows), tuple(piston_columns))
]
)
)
),
1.0,
)
if __name__ == "__main__":
unittest.main()
+14 -3
View File
@@ -298,9 +298,20 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
)
]
self.assertLess(max(total_energy) - min(total_energy), 1e-6)
self.assertLess(
result.diagnostics["pressureFlow"]["maxScaledResidual"],
1e-7,
pressure_flow = result.diagnostics["pressureFlow"]
self.assertLess(pressure_flow["maxScaledResidual"], 1e-7)
self.assertEqual(
pressure_flow["solveCount"],
pressure_flow["seededSolveCount"]
+ pressure_flow["nonlinearSolveCount"],
)
self.assertAlmostEqual(
pressure_flow["fastPathHitRate"],
pressure_flow["seededSolveCount"] / pressure_flow["solveCount"],
)
self.assertGreaterEqual(
pressure_flow["residualEvaluationCount"],
pressure_flow["optimizerEvaluationCount"],
)
def test_cancelled_simulation_returns_accepted_partial_samples(self) -> None:
@@ -0,0 +1,74 @@
from __future__ import annotations
import hashlib
import math
from pathlib import Path
import unittest
from app.main import run_system_xml_simulation
FIXTURE_PATH = (
Path(__file__).resolve().parent
/ "fixtures"
/ "high_stiffness_explicit_rk45.xml"
)
ORIGINAL_XML_SHA256 = (
"27048a99da0a21922d75785b760c3b5d04be3349b8aef6fbfedfd811d87ef1d5"
)
def short_explicit_rk45_xml() -> bytes:
original = FIXTURE_PATH.read_bytes()
canonical = original.replace(b"\r\n", b"\n").rstrip(b"\n")
if hashlib.sha256(canonical).hexdigest() != ORIGINAL_XML_SHA256:
raise AssertionError("The high-stiffness RK45 fixture differs from the user XML.")
if original.count(b'tStop="10"') != 1:
raise AssertionError("Expected one original simulation stop time.")
if original.count(b'sampleStep="0.02"') != 1:
raise AssertionError("Expected one original simulation sample step.")
return original.replace(
b'tStop="10"',
b'tStop="0.005"',
1,
).replace(
b'sampleStep="0.02"',
b'sampleStep="0.001"',
1,
)
class HighStiffnessExplicitRk45RegressionTests(unittest.TestCase):
def test_short_user_model_crosses_the_early_stiff_limit_event(self) -> None:
result = run_system_xml_simulation(
short_explicit_rk45_xml(),
cancel_check=lambda: False,
)
self.assertTrue(result["success"], result["message"])
self.assertEqual(result["status"], "completed")
self.assertFalse(result["partial"])
self.assertEqual(result["simulatedUntil"], 0.005)
self.assertNotIn("provided bounds", result["message"])
integration = result["diagnostics"]["integration"]
totals = integration["totals"]
self.assertEqual(integration["method"], "RK45")
self.assertGreaterEqual(totals["stateTransitionCount"], 1)
self.assertGreaterEqual(totals["solverStartCount"], 2)
series = result["series"]
times = series["time"]
self.assertEqual(times[0], 0.0)
self.assertEqual(times[-1], 0.005)
self.assertTrue(
all(first < second for first, second in zip(times, times[1:]))
)
self.assertTrue(any(0.0 < time < 0.001 for time in times))
for values in series.values():
self.assertEqual(len(values), len(times))
self.assertTrue(all(math.isfinite(value) for value in values))
if __name__ == "__main__":
unittest.main()
+12
View File
@@ -6,6 +6,7 @@ from app.simulation.benchmark_performance import (
_duration_summary,
_load_factory_xml,
_named_value,
_parse_arguments,
_serialize_result_event,
)
@@ -53,6 +54,17 @@ class PerformanceBenchmarkToolTests(unittest.TestCase):
self.assertIn(b'"event":"result"', payload)
self.assertIn(b'"result":{"success":true', payload)
def test_cache_ab_flag_is_explicit(self) -> None:
arguments = _parse_arguments(
[
"--factory",
"sample=tests.test_performance_benchmark:sample_xml_factory",
"--disable-property-cache",
]
)
self.assertTrue(arguments.disable_property_cache)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,278 @@
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from app.main import compile_reactflow_network
from app.simulation.solvers.algebraic import (
CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE,
)
from app.simulation.systems.generic import GenericFluidSystem
from tests.test_amesim_mechanical_xml import zero_force_mass_project
from tests.test_amesim_pnvo001_signal_xml import (
high_pressure_helium_step_project,
)
from tests.test_generic_system_xml_simulation import chain_project
def _system(project) -> GenericFluidSystem:
return GenericFluidSystem(compile_reactflow_network(project))
class PressureFlowCausalExecutionTests(unittest.TestCase):
def test_strict_causal_rhs_matches_environment_disabled_legacy_bitwise(
self,
) -> None:
optimized = _system(high_pressure_helium_step_project())
with patch.dict(
os.environ,
{CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE: "0"},
):
legacy = _system(high_pressure_helium_step_project())
optimized_state = optimized.initial_state_vector()
legacy_state = legacy.initial_state_vector()
for time in (0.0, 0.041, 0.8):
optimized_derivative = optimized.rhs(time, optimized_state)
legacy_derivative = legacy.rhs(time, legacy_state)
self.assertEqual(optimized_derivative, legacy_derivative)
self.assertEqual(
tuple(
unknown.read()
for unknown in optimized.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in legacy.pressure_flow_solver.unknowns
),
)
global_diagnostics = (
optimized.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(global_diagnostics["eligible"])
self.assertGreater(global_diagnostics["fastSolveCount"], 0)
self.assertGreaterEqual(
global_diagnostics["fullResidualAuditCount"],
1,
)
secondary = (
optimized._thermofluid_closure_plan.secondary_block_solvers[0]
)
secondary_diagnostics = secondary.causal_execution_diagnostics()
self.assertTrue(secondary_diagnostics["eligible"])
self.assertGreater(secondary_diagnostics["fastSolveCount"], 0)
legacy_diagnostics = (
legacy.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertFalse(legacy_diagnostics["enabled"])
self.assertEqual(
legacy_diagnostics["disabledReason"],
"disabledByEnvironment",
)
self.assertEqual(legacy_diagnostics["fastSolveCount"], 0)
def test_multiple_effort_anchors_conservatively_keep_legacy_path(self) -> None:
system = _system(chain_project())
diagnostics = (
system.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertFalse(diagnostics["eligible"])
self.assertFalse(diagnostics["enabled"])
self.assertEqual(
diagnostics["fallbackReason"],
"effortGroupDoesNotHaveOneAnchor",
)
def test_runtime_flow_coverage_failure_fuses_to_verified_legacy_path(
self,
) -> None:
system = _system(zero_force_mass_project())
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
original = solver._solve_explicit_flow_unknowns
def hide_coverage(*args, **kwargs):
original(*args, **kwargs)
return set()
with patch.object(
solver,
"_solve_explicit_flow_unknowns",
side_effect=hide_coverage,
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.residual_verified_this_solve)
execution = solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"causalRuntimeGateFailed",
)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_periodic_audit_failure_disables_fast_path_before_fallback(self) -> None:
system = _system(zero_force_mass_project())
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
solver._causal_audit_interval = 0
original_values = solver._pressure_flow_equation_values
call_count = 0
def one_bad_audit_value():
nonlocal call_count
call_count += 1
values = original_values()
if call_count != 1:
return values
return (values[0] + 1.0, *values[1:])
with patch.object(
solver,
"_pressure_flow_equation_values",
side_effect=one_bad_audit_value,
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
execution = solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"causalResidualAuditFailed",
)
self.assertEqual(execution["auditFailureCount"], 1)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_requested_audit_interrupts_periodic_fast_sequence(self) -> None:
system = _system(zero_force_mass_project())
state = system.initial_state_vector()
solver = system.pressure_flow_solver
system.rhs(0.0, state)
system.rhs(0.0, state)
before = solver.causal_execution_diagnostics()
self.assertEqual(before["fullResidualAuditCount"], 1)
self.assertEqual(before["fastSolveCount"], 1)
solver.request_causal_audit()
system.rhs(0.0, state)
after = solver.causal_execution_diagnostics()
self.assertEqual(after["fullResidualAuditCount"], 2)
self.assertEqual(after["fastSolveCount"], 1)
def test_fast_solve_skips_the_full_residual_evaluator(self) -> None:
system = _system(zero_force_mass_project())
state = system.initial_state_vector()
solver = system.pressure_flow_solver
system.rhs(0.0, state)
with patch.object(
solver,
"_pressure_flow_equation_values",
wraps=solver._pressure_flow_equation_values,
) as evaluate_all:
system.rhs(0.0, state)
evaluate_all.assert_not_called()
self.assertTrue(solver.last_diagnostics.causal_fast_path_used)
self.assertFalse(
solver.last_diagnostics.residual_verified_this_solve
)
def test_nonfinite_explicit_assignment_fuses_and_verifies_same_solve(
self,
) -> None:
system = _system(zero_force_mass_project())
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
original = solver._evaluate_explicit_flow_stage
call_count = 0
def one_nonfinite_assignment(stage):
nonlocal call_count
call_count += 1
values = original(stage)
if call_count != 1:
return values
return (float("nan"), *values[1:])
with patch.object(
solver,
"_evaluate_explicit_flow_stage",
side_effect=one_nonfinite_assignment,
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.residual_verified_this_solve)
execution = solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"causalRuntimeGateFailed",
)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_nonpositive_pressure_fuses_and_verifies_same_solve(self) -> None:
system = _system(high_pressure_helium_step_project())
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
original = solver._solve_explicit_flow_unknowns
pressure = next(
unknown for unknown in solver.unknowns if unknown.variable == "p"
)
def make_pressure_invalid(*args, **kwargs):
seeded = original(*args, **kwargs)
pressure.write(-1.0)
return seeded
with patch.object(
solver,
"_solve_explicit_flow_unknowns",
side_effect=make_pressure_invalid,
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.residual_verified_this_solve)
self.assertGreater(pressure.read(), 0.0)
execution = solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"causalRuntimeGateFailed",
)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_default_periodic_audit_runs_after_sixty_four_fast_solves(self) -> None:
system = _system(zero_force_mass_project())
state = system.initial_state_vector()
solver = system.pressure_flow_solver
system.rhs(0.0, state)
for _iteration in range(64):
system.rhs(0.0, state)
before_boundary = solver.causal_execution_diagnostics()
self.assertEqual(before_boundary["auditInterval"], 64)
self.assertEqual(before_boundary["fastSolveCount"], 64)
self.assertEqual(before_boundary["fullResidualAuditCount"], 1)
system.rhs(0.0, state)
after_boundary = solver.causal_execution_diagnostics()
self.assertEqual(after_boundary["fastSolveCount"], 64)
self.assertEqual(after_boundary["fullResidualAuditCount"], 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,287 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from scipy.optimize import least_squares as scipy_least_squares
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
from app.simulation.components.amesim.mechanical.translational import (
AmesimF000,
AmesimForc,
AmesimLstp00a,
AmesimMecmas21,
)
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.systems.network import SimulationNetwork
class _CustomPnl0002(AmesimPnl0002):
"""External subclass: structural declarations alone are not a purity promise."""
def _branched_solver(
*,
custom_pipe: bool = False,
include_contact: bool = False,
) -> tuple[PressureFlowSolver, AmesimPnl0002]:
medium = IdealGasMedium()
left = Cylinder("left", medium, V=0.1, p0=500_000.0)
right = Tank("right", medium, V=0.1, p0=100_000.0)
pipe_type = _CustomPnl0002 if custom_pipe else AmesimPnl0002
pipe = pipe_type("pipe", medium, p0=300_000.0, T0=300.0)
isolated = Cylinder("isolated", medium, V=0.2, p0=700_000.0)
plug = AmesimPnpl01("isolated_plug")
network = SimulationNetwork("nonlinear-equation-blocks")
for component in (left, right, pipe, isolated, plug):
network.add_component(component)
network.connect("left", "port_b", "pipe", "port_1")
network.connect("pipe", "port_2", "right", "port_a")
network.connect("isolated", "port_b", "isolated_plug", "port_1")
if include_contact:
force = AmesimForc("contact_force")
force.res.signal = 40.0
contact = AmesimLstp00a(
"contact",
medium,
gap0=0.0,
kcont=1.0e11,
rcont=0.0,
Pdis=1.0e-7,
discContactOption=1.0,
)
mass = AmesimMecmas21(
"mass",
medium,
mass=2.0,
useFriction=1.0,
x0=1.0e9,
)
zero = AmesimF000("zero")
for component in (force, contact, mass, zero):
network.add_component(component)
network.connect("contact_force", "port_2", "contact", "port_1")
network.connect("contact", "port_2", "mass", "port_1")
network.connect("mass", "port_2", "zero", "port_1")
for component in network.dynamic_components():
component.refresh_thermodynamic_ports()
solver = PressureFlowSolver(network)
solver.solve()
return solver, pipe
def _damage_flows_after_seed(
solver: PressureFlowSolver,
pipe: AmesimPnl0002,
ports: tuple[str, ...],
*,
snapshots: list[tuple[float, ...]] | None = None,
) -> None:
original = solver._solve_explicit_flow_unknowns
def damaged(*args, **kwargs):
result = original(*args, **kwargs)
for index, port_name in enumerate(ports, start=1):
port = pipe.get_port(port_name)
port.m_flow += index * 0.01
if snapshots is not None:
snapshots.append(tuple(unknown.read() for unknown in solver.unknowns))
return result
solver._solve_explicit_flow_unknowns = damaged
class PressureFlowEquationBlockTests(unittest.TestCase):
def test_pnl0002_owner_is_safely_split_across_two_blocks(self) -> None:
solver, _pipe = _branched_solver()
pipe_rows = {
index
for index, equation in enumerate(solver.equation_templates)
if equation.owner == "component" and equation.owner_id == "pipe"
}
owner_blocks = [
block
for block in solver.equation_blocks
if pipe_rows.intersection(block.equation_indices)
]
self.assertTrue(solver.equation_blocks_are_trusted)
self.assertEqual(len(pipe_rows), 2)
self.assertEqual(len(owner_blocks), 2)
self.assertTrue(
set(owner_blocks[0].unknown_indices).isdisjoint(
owner_blocks[1].unknown_indices
)
)
def test_only_bad_block_uses_scoped_sparse_residual(self) -> None:
optimized, optimized_pipe = _branched_solver()
dense, dense_pipe = _branched_solver()
_damage_flows_after_seed(optimized, optimized_pipe, ("port_1",))
_damage_flows_after_seed(dense, dense_pipe, ("port_1",))
dense._equation_blocks_are_trusted = False
dense._jacobian_sparsity_is_trusted = False
block_diagnostics = optimized.solve()
dense_diagnostics = dense.solve()
self.assertEqual(block_diagnostics.jacobian_mode, "blockSparse")
self.assertEqual(block_diagnostics.nonlinear_block_count, 1)
self.assertEqual(block_diagnostics.nonlinear_block_unknown_count, 4)
self.assertFalse(block_diagnostics.block_fallback_used)
self.assertLess(
block_diagnostics.residual_evaluations,
dense_diagnostics.residual_evaluations,
)
for block_unknown, dense_unknown in zip(
optimized.unknowns,
dense.unknowns,
):
self.assertAlmostEqual(
block_unknown.read(),
dense_unknown.read(),
delta=1.0e-6 * max(abs(dense_unknown.read()), 1.0),
)
def test_multiple_bad_blocks_are_solved_in_one_union_call(self) -> None:
solver, pipe = _branched_solver()
_damage_flows_after_seed(solver, pipe, ("port_1", "port_2"))
with patch(
"scipy.optimize.least_squares",
wraps=scipy_least_squares,
) as least_squares:
diagnostics = solver.solve()
self.assertEqual(least_squares.call_count, 1)
self.assertEqual(diagnostics.jacobian_mode, "blockSparse")
self.assertEqual(diagnostics.nonlinear_block_count, 2)
self.assertEqual(diagnostics.nonlinear_block_unknown_count, 8)
subset_pattern = least_squares.call_args.kwargs["jac_sparsity"]
self.assertEqual(subset_pattern.shape, (8, 8))
self.assertLess(subset_pattern.nnz, solver.jacobian_sparsity.nnz)
def test_failed_local_candidate_restores_global_seed_before_fallback(
self,
) -> None:
solver, pipe = _branched_solver()
seeded_snapshots: list[tuple[float, ...]] = []
_damage_flows_after_seed(
solver,
pipe,
("port_1",),
snapshots=seeded_snapshots,
)
call_count = 0
def fail_block_then_solve_global(fun, x0, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
self.assertEqual(x0.shape, (4,))
return SimpleNamespace(
x=x0 + 123.0,
success=False,
status=-1,
message="forced block failure",
nfev=1,
)
self.assertEqual(x0.shape, (len(solver.unknowns),))
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
seeded_snapshots[-1],
)
return scipy_least_squares(fun, x0, **kwargs)
with patch(
"scipy.optimize.least_squares",
side_effect=fail_block_then_solve_global,
):
diagnostics = solver.solve()
self.assertGreaterEqual(call_count, 2)
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.block_fallback_used)
self.assertEqual(diagnostics.nonlinear_block_count, 1)
self.assertEqual(
diagnostics.block_fallback_reason,
"blockResidualNotConverged",
)
def test_block_memory_error_restores_seed_and_remains_fatal(self) -> None:
solver, pipe = _branched_solver()
seeded_snapshots: list[tuple[float, ...]] = []
_damage_flows_after_seed(
solver,
pipe,
("port_1",),
snapshots=seeded_snapshots,
)
with patch(
"scipy.optimize.least_squares",
side_effect=MemoryError("forced allocation failure"),
):
with self.assertRaises(MemoryError):
solver.solve()
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
seeded_snapshots[-1],
)
def test_custom_component_keeps_the_legacy_global_path(self) -> None:
solver, pipe = _branched_solver(custom_pipe=True)
_damage_flows_after_seed(solver, pipe, ("port_1",))
diagnostics = solver.solve()
self.assertFalse(solver.equation_blocks_are_trusted)
self.assertEqual(
solver.equation_blocks_fallback_reason,
"untrustedCustomComponent",
)
self.assertEqual(diagnostics.jacobian_mode, "dense")
self.assertTrue(diagnostics.block_fallback_used)
self.assertEqual(
diagnostics.block_fallback_reason,
"untrustedCustomComponent",
)
self.assertFalse(solver.causal_fast_path_eligible)
self.assertEqual(
solver.causal_execution_diagnostics()["fallbackReason"],
"untrustedCustomComponent",
)
def test_active_contact_conservatively_keeps_global_dense_fallback(self) -> None:
solver, pipe = _branched_solver(include_contact=True)
_damage_flows_after_seed(solver, pipe, ("port_1",))
diagnostics = solver.solve()
self.assertEqual(diagnostics.jacobian_mode, "dense")
self.assertTrue(diagnostics.block_fallback_used)
self.assertEqual(
diagnostics.block_fallback_reason,
"activeCausalContact",
)
contact = solver.network.components["contact"]
self.assertIsNotNone(contact._causal_penetration)
self.assertAlmostEqual(contact.contact_force, 40.0, places=7)
self.assertFalse(solver.causal_fast_path_eligible)
self.assertEqual(
solver.causal_execution_diagnostics()["fallbackReason"],
"activeSetCausalizationRequired",
)
if __name__ == "__main__":
unittest.main()
@@ -44,13 +44,19 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
chamber.refresh_thermodynamic_ports()
pipe.refresh_thermodynamic_ports()
result = PressureFlowSolver(network).solve()
solver = PressureFlowSolver(network)
result = solver.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)
self.assertFalse(solver.causal_fast_path_eligible)
self.assertEqual(
solver.causal_execution_diagnostics()["fallbackReason"],
"specialSeriesPressureSeed",
)
def test_dead_ended_pnl00r_is_seeded_at_zero_flow_pressure(self) -> None:
medium = IdealGasMedium()
@@ -63,13 +69,19 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
network.add_component(plug)
network.connect("resistance", "port_2", "closed", "port_1")
result = PressureFlowSolver(network).solve()
solver = PressureFlowSolver(network)
result = solver.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)
self.assertFalse(solver.causal_fast_path_eligible)
self.assertEqual(
solver.causal_execution_diagnostics()["fallbackReason"],
"specialClosedResistancePressureSeed",
)
@staticmethod
def _near_equal_pressure_network() -> tuple[
@@ -338,6 +350,48 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
self.assertIsNotNone(solver.last_diagnostics)
self.assertFalse(solver.last_diagnostics.success)
def test_positive_sub_pascal_seed_uses_the_exact_pressure_without_optimizer(
self,
) -> None:
medium = IdealGasMedium()
pressure = 0.43301848566882734
temperature = pressure / medium.R_gas
tank = Tank("tank", medium, V=1.0)
plug = AmesimPnpl01("plug")
network = SimulationNetwork("positive-sub-pascal-pressure")
network.add_component(tank)
network.add_component(plug)
network.connect("tank", "port_a", "plug", "port_1")
tank.state = VolumeState(
m=1.0,
U=medium.specific_internal_energy(temperature),
)
tank.refresh_thermodynamic_ports()
self.assertAlmostEqual(
tank.port_a.p,
pressure,
delta=pressure * 1.0e-11,
)
solver = PressureFlowSolver(network, max_evaluations=10)
with patch(
"scipy.optimize.least_squares",
side_effect=AssertionError(
"A finite positive pressure seed must stay on the fast path."
),
) as least_squares:
diagnostics = solver.solve()
self.assertTrue(diagnostics.success)
self.assertEqual(diagnostics.evaluations, 0)
self.assertAlmostEqual(
tank.port_a.p,
pressure,
delta=pressure * 1.0e-11,
)
least_squares.assert_not_called()
if __name__ == "__main__":
unittest.main()
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from unittest.mock import patch
import numpy as np
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.equations import EquationResidual
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.systems.network import SimulationNetwork
class _IncompleteDependencyComponent(AlgebraicComponent):
"""Deliberately violate the residual dependency authoring contract."""
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
def __init__(self, name: str) -> None:
super().__init__(name)
self.port_1 = self.register_declared_port("port_1")
self.port_1.p = 90_000.0
self.port_1.m_flow = 1.0
def pressure_flow_equation_values(self) -> tuple[float, ...]:
return self.port_1.p - 100_000.0, self.port_1.m_flow
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:pressure",
owner="component",
owner_id=self.name,
relation="state",
# The live residual reads p, but the declaration omits it on
# purpose. A zero structural row must disable sparse FD.
variables=(),
role="effort",
value=self.port_1.p - 100_000.0,
),
EquationResidual(
id=f"{self.name}:flow",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_1.m_flow",),
role="flow",
value=self.port_1.m_flow,
),
)
def _storage_boundary_solver(name: str) -> PressureFlowSolver:
medium = IdealGasMedium()
storage = Cylinder(f"{name}_storage", medium, V=0.1, p0=500_000.0)
boundary = AmesimPnpl01(f"{name}_boundary")
network = SimulationNetwork(name)
network.add_component(storage)
network.add_component(boundary)
network.connect(storage.name, "port_b", boundary.name, "port_1")
storage.refresh_thermodynamic_ports()
boundary.port_1.m_flow = 1.0
return PressureFlowSolver(network)
class PressureFlowSolverSparsityTests(unittest.TestCase):
def test_sparse_and_dense_paths_produce_the_same_solution(self) -> None:
sparse_solver = _storage_boundary_solver("sparse")
dense_solver = _storage_boundary_solver("dense")
dense_solver._jacobian_sparsity_is_trusted = False
with patch.object(
sparse_solver,
"_solve_explicit_flow_unknowns",
return_value=set(),
):
sparse = sparse_solver.solve()
with patch.object(
dense_solver,
"_solve_explicit_flow_unknowns",
return_value=set(),
):
dense = dense_solver.solve()
self.assertEqual(sparse.jacobian_mode, "blockSparse")
self.assertEqual(sparse.nonlinear_block_count, 1)
self.assertLess(
sparse.nonlinear_block_unknown_count,
len(sparse_solver.unknowns),
)
self.assertEqual(dense.jacobian_mode, "dense")
self.assertFalse(sparse.dense_fallback_used)
self.assertFalse(dense.dense_fallback_used)
self.assertGreater(sparse.residual_evaluations, sparse.evaluations)
self.assertLess(sparse.residual_evaluations, dense.residual_evaluations)
sparse_values = {
unknown.id.split("_", maxsplit=1)[-1]: unknown.read()
for unknown in sparse_solver.unknowns
}
dense_values = {
unknown.id.split("_", maxsplit=1)[-1]: unknown.read()
for unknown in dense_solver.unknowns
}
self.assertEqual(sparse_values.keys(), dense_values.keys())
for variable_id in sparse_values:
self.assertAlmostEqual(
sparse_values[variable_id],
dense_values[variable_id],
delta=1.0e-9,
)
self.assertEqual(
sparse.as_dict()["residualEvaluations"],
sparse.residual_evaluations,
)
def test_incomplete_dependency_metadata_uses_dense_finite_differences(
self,
) -> None:
component = _IncompleteDependencyComponent("incomplete")
network = SimulationNetwork("incomplete-dependency")
network.add_component(component)
solver = PressureFlowSolver(network)
diagnostics = solver.solve()
self.assertFalse(solver.jacobian_sparsity_is_trusted)
self.assertEqual(
solver.jacobian_sparsity_fallback_reason,
"equationWithoutDeclaredUnknown",
)
self.assertEqual(diagnostics.jacobian_mode, "dense")
self.assertFalse(diagnostics.dense_fallback_used)
self.assertAlmostEqual(component.port_1.p, 100_000.0, places=6)
self.assertAlmostEqual(component.port_1.m_flow, 0.0, places=12)
def test_failed_sparse_candidate_restarts_dense_from_original_x0(self) -> None:
solver = _storage_boundary_solver("retry")
# This regression specifically exercises the final whole-network
# sparse -> dense compatibility fallback, not the preceding block
# optimization.
solver._equation_blocks_are_trusted = False
original_x0: list[np.ndarray] = []
calls: list[str] = []
flow_indices = tuple(
index
for index, unknown in enumerate(solver.unknowns)
if unknown.variable == "m_flow"
)
def fake_least_squares(_fun, x0, **kwargs):
if "jac_sparsity" in kwargs:
calls.append("sparse")
original_x0.append(x0.copy())
failed = x0.copy()
failed[list(flow_indices)] = 123.0
return SimpleNamespace(
x=failed,
success=False,
status=-1,
message="forced sparse failure",
nfev=3,
)
calls.append("dense")
np.testing.assert_array_equal(x0, original_x0[0])
solved = x0.copy()
solved[list(flow_indices)] = 0.0
return SimpleNamespace(
x=solved,
success=True,
status=1,
message="dense fallback solved",
nfev=4,
)
with patch.object(
solver,
"_solve_explicit_flow_unknowns",
return_value=set(),
), patch(
"scipy.optimize.least_squares",
side_effect=fake_least_squares,
):
diagnostics = solver.solve()
self.assertEqual(calls, ["sparse", "dense"])
self.assertTrue(diagnostics.success)
self.assertEqual(diagnostics.evaluations, 7)
self.assertEqual(diagnostics.jacobian_mode, "sparseThenDense")
self.assertTrue(diagnostics.dense_fallback_used)
for index in flow_indices:
self.assertAlmostEqual(solver.unknowns[index].read(), 0.0, places=12)
def test_compiled_pattern_matches_declared_storage_boundary_structure(
self,
) -> None:
solver = _storage_boundary_solver("pattern")
self.assertTrue(solver.jacobian_sparsity_is_trusted)
self.assertEqual(solver.jacobian_sparsity.shape, (4, 4))
self.assertEqual(solver.jacobian_sparsity.nnz, 6)
if __name__ == "__main__":
unittest.main()
@@ -6,17 +6,12 @@ from unittest.mock import Mock, patch
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
from app.simulation.property_cache import property_cache_run
class PropertyPerformanceInstrumentationTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = AmesimHeliumPengRobinsonMedium()
self.medium.temperature_from_pressure_enthalpy.cache_clear()
self.medium.properties_from_mU.cache_clear()
def tearDown(self) -> None:
self.medium.temperature_from_pressure_enthalpy.cache_clear()
self.medium.properties_from_mU.cache_clear()
def test_temperature_iterations_are_recorded_only_on_cache_miss(self) -> None:
pressure = 15.3e6
@@ -30,14 +25,15 @@ class PropertyPerformanceInstrumentationTests(unittest.TestCase):
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations:
first = self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
second = self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
with property_cache_run():
first = self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
second = self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
self.assertEqual(second, first)
record_iterations.assert_called_once()
@@ -60,8 +56,17 @@ class PropertyPerformanceInstrumentationTests(unittest.TestCase):
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations:
first = self.medium.properties_from_mU(mass, internal_energy, volume)
second = self.medium.properties_from_mU(mass, internal_energy, volume)
with property_cache_run():
first = self.medium.properties_from_mU(
mass,
internal_energy,
volume,
)
second = self.medium.properties_from_mU(
mass,
internal_energy,
volume,
)
self.assertIs(second, first)
record_iterations.assert_called_once()
@@ -166,6 +166,77 @@ class SimulationPerformancePipelineTests(unittest.TestCase):
self.assertGreater(len(snapshot["properties"]), 0)
self.assertGreater(snapshot["propertyOutermostNs"], 0)
def test_audit_property_totals_match_run_local_cache(self) -> None:
script = textwrap.dedent(
"""
import json
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
from app.simulation.performance import profile_run
from app.simulation.property_cache import property_cache_run
medium = AmesimHeliumPengRobinsonMedium()
pressure = 15.3e6
temperature = 293.15
volume = 0.057
with property_cache_run() as cache:
assert cache is not None
with profile_run() as trace:
density = medium.density(pressure, temperature)
medium.density(pressure, temperature)
medium.isentropic_density_pressure_factor(
pressure, temperature, 1.0e6
)
medium.isentropic_density_pressure_factor(
pressure, temperature, 1.0e6
)
enthalpy = medium.specific_enthalpy_at_pressure(
pressure, temperature
)
medium.temperature_from_pressure_enthalpy(pressure, enthalpy)
medium.temperature_from_pressure_enthalpy(pressure, enthalpy)
mass = density * volume
energy = mass * medium.specific_internal_energy_at_pressure(
pressure, temperature
)
medium.properties_from_mU(mass, energy, volume)
medium.properties_from_mU(mass, energy, volume)
cache_info = cache.info()
snapshot = trace.snapshot()
cached_properties = [
metrics
for metrics in snapshot["properties"].values()
if metrics["cacheLookups"] > 0
]
print(json.dumps({
"cacheHits": cache_info.hits,
"cacheMisses": cache_info.misses,
"propertyHits": sum(item["cacheHits"] for item in cached_properties),
"propertyMisses": sum(item["cacheMisses"] for item in cached_properties),
}))
"""
)
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "audit"
environment["SIMULATIONAPP_PROPERTY_CACHE"] = "on"
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
totals = json.loads(completed.stdout)
self.assertGreater(totals["cacheHits"], 0)
self.assertGreater(totals["cacheMisses"], 0)
self.assertEqual(totals["cacheHits"], totals["propertyHits"])
self.assertEqual(totals["cacheMisses"], totals["propertyMisses"])
if __name__ == "__main__":
unittest.main()
+214
View File
@@ -0,0 +1,214 @@
from __future__ import annotations
import asyncio
from concurrent.futures import ThreadPoolExecutor
from math import nextafter
import os
from pathlib import Path
import subprocess
import sys
import textwrap
from threading import Barrier
import unittest
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
from app.simulation.property_cache import (
cache_property_calculation,
current_property_cache,
property_cache_run,
)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
class SimulationPropertyCacheTests(unittest.TestCase):
def test_reuses_only_exact_inputs(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
pressure = 15.3e6
temperature = 293.15
with property_cache_run() as cache:
assert cache is not None
first = medium.density(pressure, temperature)
repeated = medium.density(pressure, temperature)
changed = medium.density(nextafter(pressure, float("inf")), temperature)
info = cache.info()
self.assertEqual(repeated, first)
self.assertNotEqual(changed, first)
self.assertEqual(info.hits, 1)
self.assertEqual(info.misses, 2)
self.assertEqual(info.current_entries, 2)
def test_failed_calculations_are_not_cached(self) -> None:
class FailingProperty:
def __init__(self) -> None:
self.calls = 0
@cache_property_calculation("failure")
def calculate(self, value: float) -> float:
self.calls += 1
raise ValueError(f"invalid {value}")
owner = FailingProperty()
with property_cache_run() as cache:
assert cache is not None
for _ in range(2):
with self.assertRaisesRegex(ValueError, "invalid"):
owner.calculate(1.0)
info = cache.info()
self.assertEqual(owner.calls, 2)
self.assertEqual(info.hits, 0)
self.assertEqual(info.misses, 2)
self.assertEqual(info.current_entries, 0)
def test_lru_capacity_is_bounded_and_oldest_entry_is_evicted(self) -> None:
class CachedProperty:
def __init__(self) -> None:
self.calls = 0
@cache_property_calculation("bounded")
def calculate(self, value: int) -> int:
self.calls += 1
return value * 10
owner = CachedProperty()
with property_cache_run(max_entries=2) as cache:
assert cache is not None
owner.calculate(1)
owner.calculate(2)
owner.calculate(1)
owner.calculate(3)
second = owner.calculate(2)
info = cache.info()
self.assertEqual(second, 20)
self.assertEqual(owner.calls, 4)
self.assertEqual(info.current_entries, 2)
self.assertEqual(info.evictions, 2)
def test_each_run_gets_an_independent_cache_and_releases_context(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
with property_cache_run() as first_cache:
assert first_cache is not None
medium.density(100_000.0, 300.0)
medium.density(100_000.0, 300.0)
first_info = first_cache.info()
self.assertIsNone(current_property_cache())
with property_cache_run() as second_cache:
assert second_cache is not None
medium.density(100_000.0, 300.0)
second_info = second_cache.info()
self.assertIsNot(first_cache, second_cache)
self.assertEqual(first_info.hits, 1)
self.assertEqual(second_info.hits, 0)
self.assertEqual(second_info.misses, 1)
self.assertIsNone(current_property_cache())
def test_async_tasks_do_not_share_run_local_caches(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
async def exercise(pressure: float) -> tuple[int, int, int]:
with property_cache_run() as cache:
assert cache is not None
await asyncio.sleep(0)
medium.density(pressure, 300.0)
medium.density(pressure, 300.0)
info = cache.info()
return id(cache), info.hits, info.misses
async def run_both() -> list[tuple[int, int, int]]:
return list(
await asyncio.gather(
exercise(100_000.0),
exercise(200_000.0),
)
)
results = asyncio.run(run_both())
self.assertNotEqual(results[0][0], results[1][0])
self.assertEqual(results[0][1:], (1, 1))
self.assertEqual(results[1][1:], (1, 1))
def test_worker_threads_do_not_share_run_local_caches(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
barrier = Barrier(2)
def exercise(pressure: float) -> tuple[int, int, int]:
with property_cache_run() as cache:
assert cache is not None
barrier.wait(timeout=5.0)
medium.density(pressure, 300.0)
medium.density(pressure, 300.0)
info = cache.info()
return id(cache), info.hits, info.misses
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(
executor.map(
exercise,
(100_000.0, 200_000.0),
)
)
self.assertNotEqual(results[0][0], results[1][0])
self.assertEqual(results[0][1:], (1, 1))
self.assertEqual(results[1][1:], (1, 1))
def test_cache_on_and_off_produce_identical_helium_results(self) -> None:
script = textwrap.dedent(
"""
import hashlib
import json
from app.main import build_reactflow_system_xml, run_system_xml_simulation
from tests.test_amesim_pnvo001_signal_xml import (
high_pressure_helium_step_project,
)
result = run_system_xml_simulation(
build_reactflow_system_xml(high_pressure_helium_step_project())
)
assert result["success"], result["message"]
payload = json.dumps(
{
"status": result["status"],
"series": result["series"],
"final": result["final"],
},
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
print(hashlib.sha256(payload).hexdigest())
"""
)
hashes: list[str] = []
for enabled in ("on", "off"):
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "off"
environment["SIMULATIONAPP_PROPERTY_CACHE"] = enabled
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
hashes.append(completed.stdout.strip())
self.assertEqual(hashes[0], hashes[1])
if __name__ == "__main__":
unittest.main()
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
import asyncio
import os
import unittest
from unittest.mock import patch
from app.main import _app_lifespan, app
from app.simulation.warmup import (
SimulationWarmupReport,
_run_numerical_warmup,
_reset_simulation_warmup_for_tests,
warm_up_simulation_runtime,
)
class SimulationWarmupTests(unittest.TestCase):
def setUp(self) -> None:
_reset_simulation_warmup_for_tests()
def tearDown(self) -> None:
_reset_simulation_warmup_for_tests()
def test_warmup_runs_only_once_per_process(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch("app.simulation.warmup._run_numerical_warmup") as run_warmup,
):
first = warm_up_simulation_runtime()
second = warm_up_simulation_runtime()
self.assertIs(second, first)
self.assertEqual(first.status, "completed")
run_warmup.assert_called_once_with()
def test_disabled_warmup_does_not_touch_numerical_runtime(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "off"}),
patch("app.simulation.warmup._run_numerical_warmup") as run_warmup,
):
report = warm_up_simulation_runtime()
self.assertEqual(report.status, "disabled")
run_warmup.assert_not_called()
def test_regular_failure_is_reported_without_blocking_startup(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch(
"app.simulation.warmup._run_numerical_warmup",
side_effect=RuntimeError("broken warmup"),
),
self.assertLogs("app.simulation.warmup", level="ERROR"),
):
report = warm_up_simulation_runtime()
self.assertEqual(report.status, "failed")
self.assertIn("broken warmup", report.error or "")
def test_memory_error_remains_fatal(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch(
"app.simulation.warmup._run_numerical_warmup",
side_effect=MemoryError("out of memory"),
),
self.assertRaises(MemoryError),
):
warm_up_simulation_runtime()
def test_lifespan_stores_warmup_report_before_serving(self) -> None:
report = SimulationWarmupReport(status="completed", duration_ms=12.5)
async def enter_lifespan() -> None:
with patch(
"app.simulation.warmup.warm_up_simulation_runtime",
return_value=report,
) as warmup:
async with _app_lifespan(app):
self.assertEqual(
app.state.simulation_warmup,
report.as_dict(),
)
warmup.assert_called_once_with()
asyncio.run(enter_lifespan())
def test_real_numerical_warmup_completes(self) -> None:
with patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}):
report = warm_up_simulation_runtime()
self.assertEqual(report.status, "completed", report.error)
self.assertGreater(report.duration_ms, 0.0)
def test_numerical_warmup_exercises_sparse_lsmr_algebraic_path(self) -> None:
import scipy.optimize
actual_least_squares = scipy.optimize.least_squares
optimizer_calls: list[dict[str, object]] = []
def recording_least_squares(*args, **kwargs):
optimizer_calls.append(dict(kwargs))
return actual_least_squares(*args, **kwargs)
with patch.object(
scipy.optimize,
"least_squares",
recording_least_squares,
):
_run_numerical_warmup()
self.assertEqual(len(optimizer_calls), 1)
call = optimizer_calls[0]
self.assertEqual(call["tr_solver"], "lsmr")
sparsity = call["jac_sparsity"]
self.assertEqual(sparsity.shape, (2, 2))
self.assertEqual(sparsity.nnz, 2)
if __name__ == "__main__":
unittest.main()
+486
View File
@@ -0,0 +1,486 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
from app.simulation.components.amesim.mechanical.translational import (
AmesimLstp00a,
)
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.algebraic import (
AlgebraicSolveDiagnostics,
PressureFlowSolver,
)
from app.simulation.solvers.algebraic_blocks import (
StreamPressureBlockSolver,
_BlockSolveAttempt,
)
from app.simulation.systems.network import SimulationNetwork
def _pnl0002_solver(
*,
include_unselected_island: bool = False,
) -> tuple[PressureFlowSolver, AmesimPnl0002]:
medium = IdealGasMedium()
left = Cylinder("left", medium, V=0.1, p0=500_000.0)
right = Tank("right", medium, V=0.1, p0=100_000.0)
pipe = AmesimPnl0002(
"pipe",
medium,
p0=300_000.0,
T0=300.0,
)
network = SimulationNetwork("pnl0002-equation-blocks")
for component in (left, right, pipe):
network.add_component(component)
network.connect("left", "port_b", "pipe", "port_1")
network.connect("pipe", "port_2", "right", "port_a")
if include_unselected_island:
isolated = Cylinder("isolated", medium, V=0.2, p0=700_000.0)
plug = AmesimPnpl01("isolated_plug")
network.add_component(isolated)
network.add_component(plug)
network.connect("isolated", "port_b", "isolated_plug", "port_1")
for component in network.dynamic_components():
component.refresh_thermodynamic_ports()
solver = PressureFlowSolver(network)
solver.solve()
return solver, pipe
class StreamPressureBlockSolverTests(unittest.TestCase):
def test_pnl0002_uses_two_blocks_with_one_shared_component(self) -> None:
solver, pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
self.assertTrue(block_solver.available, block_solver.fallback_reason)
self.assertEqual(len(block_solver.blocks), 2)
self.assertTrue(
all("pipe" in block.scope_components for block in block_solver.blocks)
)
pipe_component_evaluations = [
evaluation
for block in block_solver.blocks
for evaluation in block.component_evaluations
if getattr(evaluation.evaluate, "__self__", None) is pipe
]
self.assertEqual(len(pipe_component_evaluations), 2)
self.assertTrue(
{unknown.id for unknown in block_solver.blocks[0].unknowns}.isdisjoint(
unknown.id for unknown in block_solver.blocks[1].unknowns
)
)
result = block_solver.solve(scale_context=solver.scale_context())
self.assertFalse(result.used_global_fallback)
self.assertEqual(len(result.diagnostics), 1)
self.assertAlmostEqual(
pipe.port_1.m_flow,
pipe.port_mass_flow(
pipe.port_1.p,
pipe.properties().p,
pipe.properties().T,
port_name="port_1",
),
places=12,
)
self.assertAlmostEqual(
pipe.port_2.m_flow,
pipe.port_mass_flow(
pipe.port_2.p,
pipe.properties().p,
pipe.properties().T,
port_name="port_2",
),
places=12,
)
def test_selected_block_seeding_preserves_every_unselected_unknown(self) -> None:
solver, _pipe = _pnl0002_solver(include_unselected_island=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
unselected = tuple(
unknown
for unknown in solver.unknowns
if unknown.component in {"isolated", "isolated_plug"}
)
for index, unknown in enumerate(unselected, start=1):
unknown.write(10_000.0 * index)
expected = tuple(unknown.read() for unknown in unselected)
result = block_solver.solve(scale_context=solver.scale_context())
self.assertFalse(result.used_global_fallback)
self.assertEqual(
tuple(unknown.read() for unknown in unselected),
expected,
)
def test_causal_secondary_pressure_mutation_fuses_to_verified_path(self) -> None:
solver, _pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
first = block_solver.solve(scale_context=solver.scale_context())
self.assertFalse(first.used_global_fallback)
expected_pressures = tuple(
unknown.read()
for unknown in block_solver._selected_unknowns
if unknown.variable == "p"
)
original_seed = block_solver._seed_selected_blocks
mutated_pressure = next(
unknown
for unknown in block_solver._selected_unknowns
if unknown.variable == "p"
)
def seed_then_mutate(entry_values):
seeded = original_seed(entry_values)
mutated_pressure.write(mutated_pressure.read() + 10_000.0)
return seeded
with patch.object(
block_solver,
"_seed_selected_blocks",
side_effect=seed_then_mutate,
):
result = block_solver.solve(scale_context=solver.scale_context())
self.assertFalse(result.used_global_fallback)
self.assertTrue(result.diagnostics[0].residual_verified_this_solve)
actual_pressures = tuple(
unknown.read()
for unknown in block_solver._selected_unknowns
if unknown.variable == "p"
)
for expected, actual in zip(expected_pressures, actual_pressures):
self.assertAlmostEqual(
actual,
expected,
delta=1.0e-12 * max(abs(expected), 1.0),
)
execution = block_solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"causalSecondaryRuntimeGateFailed",
)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_sparse_block_reports_actual_residual_evaluations(self) -> None:
solver, pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
pipe.port_1.m_flow += 0.01
with patch.object(
block_solver,
"_seed_selected_blocks",
return_value=None,
):
result = block_solver.solve(scale_context=solver.scale_context())
sparse = result.diagnostics[0]
self.assertEqual(sparse.jacobian_mode, "blockSparse")
self.assertGreater(sparse.evaluations, 0)
self.assertGreater(sparse.residual_evaluations, sparse.evaluations)
self.assertFalse(sparse.dense_fallback_used)
def test_failed_sparse_block_restores_its_original_unknowns(self) -> None:
solver, pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
block = block_solver.blocks[0]
pipe.port_1.m_flow += 0.01
expected = tuple(unknown.read() for unknown in block.unknowns)
def failed_sparse(_fun, x0, **_kwargs):
return SimpleNamespace(
x=x0 + 123.0,
success=False,
status=-1,
message="forced sparse block failure",
nfev=1,
)
with patch("scipy.optimize.least_squares", side_effect=failed_sparse):
attempt = block_solver._solve_block(
block,
solver.scale_context(),
)
self.assertIsNone(attempt.diagnostics)
self.assertEqual(attempt.optimizer_evaluations, 1)
self.assertEqual(
tuple(unknown.read() for unknown in block.unknowns),
expected,
)
def test_block_failure_restores_full_snapshot_before_global_fallback(self) -> None:
solver, pipe = _pnl0002_solver(include_unselected_island=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
pipe.port_1.m_flow += 0.01
solver.network.components["isolated_plug"].port_1.p = 12_345.0
expected = tuple(unknown.read() for unknown in solver.unknowns)
original_global_solve = solver.solve
def checked_global_solve(*args, **kwargs):
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
expected,
)
return original_global_solve(*args, **kwargs)
def failed_block(block, _scale_context, **_kwargs):
for unknown in block.unknowns:
unknown.write(unknown.read() + 321.0)
return _BlockSolveAttempt(
diagnostics=None,
optimizer_evaluations=2,
residual_evaluations=7,
failure_reason="blockResidualNotConverged",
)
with patch.object(
block_solver,
"_seed_selected_blocks",
return_value=None,
), patch.object(
block_solver,
"_solve_block",
side_effect=failed_block,
), patch.object(
solver,
"solve",
side_effect=checked_global_solve,
) as global_solve:
result = block_solver.solve(scale_context=solver.scale_context())
global_solve.assert_called_once()
self.assertTrue(result.used_global_fallback)
self.assertEqual(len(result.diagnostics), 1)
fallback_diagnostics = result.diagnostics[0]
self.assertIn(
fallback_diagnostics.jacobian_mode,
{
"seeded",
"sparse",
"dense",
"sparseThenDense",
"blockSparse",
},
)
self.assertGreaterEqual(
fallback_diagnostics.residual_evaluations,
fallback_diagnostics.evaluations,
)
self.assertGreaterEqual(fallback_diagnostics.evaluations, 2)
self.assertGreaterEqual(fallback_diagnostics.residual_evaluations, 7)
self.assertTrue(fallback_diagnostics.block_fallback_used)
self.assertIn(
"blockResidualNotConverged",
fallback_diagnostics.block_fallback_reason or "",
)
def test_seed_exception_restores_snapshot_before_global_fallback(self) -> None:
solver, _pipe = _pnl0002_solver(include_unselected_island=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
expected = tuple(unknown.read() for unknown in solver.unknowns)
original_global_solve = solver.solve
def broken_seed(_entry_values) -> None:
solver.unknowns[0].write(solver.unknowns[0].read() + 123_456.0)
raise ValueError("forced seed failure")
def checked_global_solve(*args, **kwargs):
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
expected,
)
return original_global_solve(*args, **kwargs)
with patch.object(
block_solver,
"_seed_selected_blocks",
side_effect=broken_seed,
), patch.object(
solver,
"solve",
side_effect=checked_global_solve,
) as global_solve:
result = block_solver.solve(scale_context=solver.scale_context())
global_solve.assert_called_once()
self.assertTrue(result.used_global_fallback)
def test_failed_global_fallback_does_not_leak_candidate_state(self) -> None:
solver, _pipe = _pnl0002_solver(include_unselected_island=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
expected = tuple(unknown.read() for unknown in solver.unknowns)
def failed_block(block, _scale_context, **_kwargs):
for unknown in block.unknowns:
unknown.write(unknown.read() + 123.0)
return _BlockSolveAttempt(
diagnostics=None,
optimizer_evaluations=1,
residual_evaluations=4,
failure_reason="blockResidualNotConverged",
)
def failed_global_solve(*_args, **_kwargs):
for unknown in solver.unknowns:
unknown.write(unknown.read() - 456.0)
raise RuntimeError("forced global fallback failure")
with patch.object(
block_solver,
"_seed_selected_blocks",
return_value=None,
), patch.object(
block_solver,
"_seeded_diagnostics",
return_value=None,
), patch.object(
block_solver,
"_solve_block",
side_effect=failed_block,
), patch.object(
solver,
"solve",
side_effect=failed_global_solve,
):
with self.assertRaisesRegex(
RuntimeError,
"forced global fallback failure",
):
block_solver.solve(scale_context=solver.scale_context())
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
expected,
)
def test_local_to_global_fallback_aggregates_attempt_chain_once(self) -> None:
solver, _pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
accepted_global = AlgebraicSolveDiagnostics(
success=True,
message="accepted global result",
evaluations=3,
pressure_scale=400_000.0,
flow_scale=0.01,
max_scaled_residual=0.25,
max_raw_residual=5.0,
residual_evaluations=11,
jacobian_mode="dense",
)
failed_local = _BlockSolveAttempt(
diagnostics=None,
optimizer_evaluations=2,
residual_evaluations=7,
failure_reason="blockResidualNotConverged",
)
with patch.object(
block_solver,
"_seeded_diagnostics",
return_value=None,
), patch.object(
block_solver,
"_solve_block",
return_value=failed_local,
), patch.object(
solver,
"solve",
return_value=accepted_global,
) as global_solve:
result = block_solver.solve(scale_context=solver.scale_context())
global_solve.assert_called_once()
self.assertTrue(result.used_global_fallback)
self.assertEqual(len(result.diagnostics), 1)
aggregate = result.diagnostics[0]
self.assertEqual(aggregate.evaluations, 5)
self.assertEqual(aggregate.residual_evaluations, 18)
self.assertEqual(aggregate.jacobian_mode, "dense")
self.assertEqual(aggregate.max_scaled_residual, 0.25)
self.assertTrue(aggregate.block_fallback_used)
self.assertEqual(
aggregate.block_fallback_reason,
"blockResidualNotConverged",
)
def test_failed_global_fallback_restores_lstp_causal_cache_for_base_errors(
self,
) -> None:
class ForcedFatalError(BaseException):
pass
for error_type in (MemoryError, ForcedFatalError):
with self.subTest(error_type=error_type.__name__):
solver, _pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
block_solver.fallback_reason = "forcedUntrustedStructure"
contact = AmesimLstp00a(
"contact",
IdealGasMedium(),
gap0=0.0,
kcont=1.0e6,
rcont=0.0,
Pdis=1.0e-6,
discContactOption=1.0,
)
contact.port_1.x = 1.25
contact.port_2.x = 1.0
contact.port_1.v = 0.5
contact.port_2.v = -0.25
contact.set_causal_contact(penetration=0.25, force=12.0)
solver._causal_contact_components = (contact,)
expected_unknowns = tuple(
unknown.read() for unknown in solver.unknowns
)
causal_names = tuple(
name
for name in vars(contact)
if name.startswith("_causal_")
)
expected_causal = tuple(
getattr(contact, name) for name in causal_names
)
expected_last = solver.last_diagnostics
def failed_global_solve(*_args, **_kwargs):
solver.unknowns[0].write(
solver.unknowns[0].read() + 123_456.0
)
contact.clear_causal_contact()
solver.last_diagnostics = None
raise error_type("forced global fallback failure")
with patch.object(
solver,
"solve",
side_effect=failed_global_solve,
):
with self.assertRaises(error_type):
block_solver.solve(scale_context=solver.scale_context())
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
expected_unknowns,
)
self.assertEqual(
tuple(getattr(contact, name) for name in causal_names),
expected_causal,
)
self.assertIs(solver.last_diagnostics, expected_last)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,178 @@
from __future__ import annotations
from collections.abc import Mapping
import unittest
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
from app.simulation.core.ports import PortDefinition
from app.simulation.solvers.stream import StreamResolver
from app.simulation.systems.network import SimulationNetwork
class _CountingDynamicAnchor(DynamicComponent):
PORTS = (PortDefinition.pneumatic("port"),)
def __init__(
self,
name: str,
*,
enthalpy: float,
temperature_reference_h: float,
) -> None:
super().__init__(name)
self.enthalpy = enthalpy
self.temperature_reference_h = temperature_reference_h
self.refresh_count = 0
self.port = self.register_declared_port("port")
self.port.h_outflow = -1.0
def make_ports_current(self) -> None:
self.port.h_outflow = self.enthalpy
def get_state_vector(self) -> list[float]:
return [0.0, 0.0]
def set_state_vector(self, values: list[float]) -> None:
if len(values) != self.state_size:
raise ValueError("Unexpected test state size.")
def refresh_thermodynamic_ports(self) -> None:
self.refresh_count += 1
self.make_ports_current()
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
return [0.0, 0.0]
class _PassThrough(AlgebraicComponent):
PORTS = (
PortDefinition.pneumatic("left"),
PortDefinition.pneumatic("right"),
)
def __init__(self, name: str, update_log: list[str]) -> None:
super().__init__(name)
self.left = self.register_declared_port("left")
self.right = self.register_declared_port("right")
self.update_log = update_log
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.update_log.append(self.name)
self.left.h_outflow = connected_h["right"]
self.right.h_outflow = connected_h["left"]
def _build_chain() -> tuple[
SimulationNetwork,
_CountingDynamicAnchor,
_PassThrough,
_PassThrough,
_CountingDynamicAnchor,
list[str],
]:
update_log: list[str] = []
left = _CountingDynamicAnchor(
"left_anchor",
enthalpy=100.0,
temperature_reference_h=1_100.0,
)
first = _PassThrough("first", update_log)
second = _PassThrough("second", update_log)
right = _CountingDynamicAnchor(
"right_anchor",
enthalpy=400.0,
temperature_reference_h=1_400.0,
)
network = SimulationNetwork("stream-chain")
for component in (left, first, second, right):
network.add_component(component)
network.connect("left_anchor", "port", "first", "left")
network.connect("first", "right", "second", "left")
network.connect("second", "right", "right_anchor", "port")
return network, left, first, second, right, update_log
class StreamResolverExecutionPlanTests(unittest.TestCase):
def test_standalone_solve_refreshes_each_dynamic_exactly_once(self) -> None:
network, left, _first, _second, right, _update_log = _build_chain()
diagnostics, _connected = StreamResolver(network).solve()
self.assertTrue(diagnostics.converged)
self.assertEqual(left.refresh_count, 1)
self.assertEqual(right.refresh_count, 1)
def test_current_dynamic_ports_skip_refresh(self) -> None:
network, left, _first, _second, right, _update_log = _build_chain()
left.make_ports_current()
right.make_ports_current()
diagnostics, connected = StreamResolver(network).solve(
dynamic_ports_are_current=True
)
self.assertTrue(diagnostics.converged)
self.assertEqual(left.refresh_count, 0)
self.assertEqual(right.refresh_count, 0)
self.assertEqual(connected["first"], {"left": 100.0, "right": 400.0})
def test_multiple_iterations_do_not_repeat_dynamic_refresh(self) -> None:
network, left, _first, _second, right, update_log = _build_chain()
diagnostics, _connected = StreamResolver(network).solve()
self.assertGreater(diagnostics.iterations, 1)
self.assertEqual(left.refresh_count, 1)
self.assertEqual(right.refresh_count, 1)
self.assertEqual(
update_log,
["first", "second"] * diagnostics.iterations,
)
def test_precompiled_bindings_preserve_outputs_and_references(self) -> None:
default_network, default_left, default_first, default_second, default_right, _ = (
_build_chain()
)
current_network, current_left, current_first, current_second, current_right, _ = (
_build_chain()
)
current_left.make_ports_current()
current_right.make_ports_current()
default_resolver = StreamResolver(default_network)
current_resolver = StreamResolver(current_network)
default_diagnostics, default_connected = default_resolver.solve()
current_diagnostics, current_connected = current_resolver.solve(
dynamic_ports_are_current=True
)
self.assertEqual(default_diagnostics, current_diagnostics)
self.assertEqual(default_connected, current_connected)
self.assertEqual(
(
default_left.port.h_outflow,
default_first.left.h_outflow,
default_first.right.h_outflow,
default_second.left.h_outflow,
default_second.right.h_outflow,
default_right.port.h_outflow,
),
(
current_left.port.h_outflow,
current_first.left.h_outflow,
current_first.right.h_outflow,
current_second.left.h_outflow,
current_second.right.h_outflow,
current_right.port.h_outflow,
),
)
references = default_resolver.connected_temperature_reference_enthalpies()
self.assertEqual(references["first"]["left"], 1_100.0)
self.assertEqual(references["second"]["right"], 1_400.0)
if __name__ == "__main__":
unittest.main()
+642
View File
@@ -0,0 +1,642 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import replace
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from app.main import compile_reactflow_network, compile_system_xml_network
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.orifices import (
AmesimPnor001,
AmesimPnvo001FixedOpening,
)
from app.simulation.components.amesim.flow.pipes import (
AmesimPnl00r,
AmesimPnl0001,
AmesimPnl0002,
)
from app.simulation.components.amesim.storage.chambers import AmesimPnch023
from app.simulation.components.experimental.flow.orifice import Orifice
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.algebraic import (
AlgebraicSolveDiagnostics,
AlgebraicSolveError,
)
from app.simulation.solvers.algebraic_blocks import StreamBlockSolveResult
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import (
GenericFluidSystem,
simulation_preparation_issues,
)
from app.simulation.systems.network import SimulationNetwork
from app.system_xml import validate_system_xml_document
from tests.test_amesim_pnvo001_signal_xml import high_pressure_helium_step_project
from tests.test_amesim_mechanical_xml import elastic_contact_project
from tests.test_generic_system_xml_simulation import chain_project
from tests.test_high_stiffness_explicit_rk45 import short_explicit_rk45_xml
class _UnclassifiedCustomOrifice(Orifice):
"""A custom subclass must not inherit the catalog purity declaration."""
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
super().update_stream_outflows(connected_h)
class _InvalidDependencyDeclarationOrifice(Orifice):
PRESSURE_FLOW_DEPENDS_ON_STREAM = 1
class _CountingCylinder(Cylinder):
def __init__(self, *args, **kwargs) -> None:
self.refresh_count = 0
super().__init__(*args, **kwargs)
def refresh_thermodynamic_ports(self):
self.refresh_count += 1
return super().refresh_thermodynamic_ports()
def _three_component_network(
middle: Orifice,
*,
prefix: str = "independent",
) -> tuple[SimulationNetwork, _CountingCylinder, Tank]:
medium = IdealGasMedium()
source = _CountingCylinder(
f"{prefix}_source",
medium,
V=0.02,
p0=500_000.0,
T0=320.0,
)
sink = Tank(
f"{prefix}_sink",
medium,
V=0.05,
p0=100_000.0,
T0=290.0,
)
network = SimulationNetwork(prefix)
for component in (source, middle, sink):
network.add_component(component)
network.connect(source.name, "port_b", middle.name, "port_a")
network.connect(middle.name, "port_b", sink.name, "port_a")
return network, source, sink
def _mixed_island_network(
*,
independent_pressure: float = 450_000.0,
) -> SimulationNetwork:
medium = IdealGasMedium()
sensitive_source = Cylinder(
"sensitive_source",
medium,
V=0.02,
p0=600_000.0,
T0=350.0,
)
sensitive_sink = Tank(
"sensitive_sink",
medium,
V=0.05,
p0=100_000.0,
T0=280.0,
)
valve = AmesimPnvo001FixedOpening(
"sensitive_valve",
medium,
area0=1.0e-5,
opening=0.8,
)
independent_source = Cylinder(
"independent_source",
medium,
V=0.02,
p0=independent_pressure,
T0=310.0,
)
independent_sink = Tank(
"independent_sink",
medium,
V=0.05,
p0=120_000.0,
T0=295.0,
)
independent_orifice = Orifice("independent_orifice", K=2.0e-5)
network = SimulationNetwork("mixed-islands")
for component in (
sensitive_source,
sensitive_sink,
valve,
independent_source,
independent_sink,
independent_orifice,
):
network.add_component(component)
network.connect("sensitive_source", "port_b", "sensitive_valve", "port_2")
network.connect("sensitive_valve", "port_3", "sensitive_sink", "port_a")
network.connect("independent_source", "port_b", "independent_orifice", "port_a")
network.connect("independent_orifice", "port_b", "independent_sink", "port_a")
return network
def _special_seed_islands_network() -> SimulationNetwork:
medium = IdealGasMedium()
series_source = AmesimPnch023("series_source", medium, p0=15.3e6)
series_source_plug = AmesimPnpl01("series_source_plug")
series_orifice = AmesimPnor001("series_orifice", medium)
series_pipe = AmesimPnl0001("series_pipe", medium, p0=14.0e6)
series_pipe_plug = AmesimPnpl01("series_pipe_plug")
closed_pipe = AmesimPnl0002("closed_pipe", medium, p0=200_000.0)
closed_pipe_left = AmesimPnpl01("closed_pipe_left")
closed_pipe_right = AmesimPnpl01("closed_pipe_right")
resistance_source = Cylinder(
"resistance_source",
medium,
V=0.02,
p0=500_000.0,
T0=310.0,
)
resistance = AmesimPnl00r("resistance", medium)
resistance_plug = AmesimPnpl01("resistance_plug")
network = SimulationNetwork("special-seed-islands")
for component in (
series_source,
series_source_plug,
series_orifice,
series_pipe,
series_pipe_plug,
closed_pipe,
closed_pipe_left,
closed_pipe_right,
resistance_source,
resistance,
resistance_plug,
):
network.add_component(component)
network.connect("series_source_plug", "port_1", "series_source", "port_1")
network.connect("series_source", "port_2", "series_orifice", "port_1")
network.connect("series_orifice", "port_2", "series_pipe", "port_1")
network.connect("series_pipe", "port_2", "series_pipe_plug", "port_1")
network.connect("closed_pipe_left", "port_1", "closed_pipe", "port_1")
network.connect("closed_pipe", "port_2", "closed_pipe_right", "port_1")
network.connect("resistance_source", "port_b", "resistance", "port_1")
network.connect("resistance", "port_2", "resistance_plug", "port_1")
return network
def _force_legacy_global_coupling(system: GenericFluidSystem) -> None:
plan = system._thermofluid_closure_plan
system._thermofluid_closure_plan = replace(
plan,
secondary_pressure_solvers=(system.pressure_flow_solver,),
secondary_component_groups=(plan.global_component_group,),
uses_conservative_global_solver=True,
)
class ThermofluidClosurePlanTests(unittest.TestCase):
def test_independent_network_solves_pressure_once_and_refreshes_once(self) -> None:
network, source, _sink = _three_component_network(
Orifice("independent_orifice", K=1.0e-5)
)
system = GenericFluidSystem(network)
source.refresh_count = 0
system.consistent_initial_state_vector()
self.assertEqual(system._thermofluid_closure_plan.secondary_pressure_solvers, ())
self.assertEqual(system.algebraic_solve_count, 1)
self.assertEqual(system.thermofluid_pressure_pass_count, 1)
self.assertEqual(source.refresh_count, 1)
def test_mixed_network_revisits_only_the_stream_sensitive_island(self) -> None:
system = GenericFluidSystem(_mixed_island_network())
plan = system._thermofluid_closure_plan
self.assertFalse(plan.uses_conservative_global_solver)
self.assertEqual(len(plan.secondary_pressure_solvers), 1)
self.assertEqual(
set(plan.secondary_component_groups[0]),
{"sensitive_source", "sensitive_sink", "sensitive_valve"},
)
self.assertEqual(
set(plan.secondary_pressure_solvers[0].network.components),
set(plan.secondary_component_groups[0]),
)
self.assertNotIn(
"independent_orifice",
plan.secondary_pressure_solvers[0].network.components,
)
state = system.consistent_initial_state_vector()
first = system.rhs(0.0, state)
second = system.rhs(0.0, state)
for first_value, second_value in zip(first, second):
self.assertAlmostEqual(first_value, second_value, delta=1.0e-9)
def test_secondary_attempt_chain_is_counted_as_one_logical_solve(self) -> None:
system = GenericFluidSystem(_mixed_island_network())
secondary = system._thermofluid_closure_plan.secondary_block_solvers[0]
aggregate = AlgebraicSolveDiagnostics(
success=True,
message="accepted global fallback",
evaluations=5,
pressure_scale=600_000.0,
flow_scale=0.01,
max_scaled_residual=0.2,
max_raw_residual=2.0,
residual_evaluations=18,
jacobian_mode="dense",
block_fallback_used=True,
block_fallback_reason="blockResidualNotConverged",
)
fake_result = StreamBlockSolveResult(
diagnostics=(aggregate,),
scopes=(tuple(system.network.components),),
used_global_fallback=True,
)
initial_diagnostics: list[AlgebraicSolveDiagnostics] = []
original_initial_solve = system.pressure_flow_solver.solve
def recorded_initial_solve(*args, **kwargs):
result = original_initial_solve(*args, **kwargs)
initial_diagnostics.append(result)
return result
with patch.object(
system.pressure_flow_solver,
"solve",
side_effect=recorded_initial_solve,
), patch.object(secondary, "solve", return_value=fake_result):
system.consistent_initial_state_vector()
self.assertEqual(len(initial_diagnostics), 1)
initial = initial_diagnostics[0]
self.assertEqual(system.algebraic_solve_count, 2)
self.assertEqual(
system.algebraic_block_fallback_count,
int(initial.block_fallback_used) + 1,
)
self.assertEqual(
system.algebraic_optimizer_evaluation_count,
initial.evaluations + 5,
)
self.assertEqual(
system.algebraic_residual_evaluation_count,
initial.residual_evaluations + 18,
)
def test_secondary_island_failure_reports_its_physical_scope(self) -> None:
system = GenericFluidSystem(_mixed_island_network())
plan = system._thermofluid_closure_plan
secondary = plan.secondary_pressure_solvers[0]
def failed_result(_fun, x0, **_kwargs):
return SimpleNamespace(
x=x0.copy(),
success=False,
status=-1,
message="forced secondary-island failure",
nfev=1,
)
with patch.object(
secondary,
"_solve_explicit_flow_unknowns",
return_value=None,
), patch("scipy.optimize.least_squares", side_effect=failed_result):
with self.assertRaises(AlgebraicSolveError) as raised:
secondary.solve(effort_variables=())
self.assertEqual(raised.exception.scope_kind, "physicalIsland")
self.assertEqual(
raised.exception.scope_components,
plan.secondary_component_groups[0],
)
def test_secondary_islands_preserve_special_pressure_seed_plans(self) -> None:
network = _special_seed_islands_network()
system = GenericFluidSystem(network)
plan = system._thermofluid_closure_plan
solvers = {
frozenset(solver.network.components): solver
for solver in plan.secondary_pressure_solvers
}
series_solver = solvers[
frozenset(
{
"series_source",
"series_source_plug",
"series_orifice",
"series_pipe",
"series_pipe_plug",
}
)
]
closed_pipe_solver = solvers[
frozenset(
{"closed_pipe", "closed_pipe_left", "closed_pipe_right"}
)
]
resistance_solver = solvers[
frozenset(
{
"resistance_source",
"resistance",
"resistance_plug",
}
)
]
self.assertEqual(len(series_solver._pnor_pnl0001_series_plan), 1)
self.assertEqual(len(closed_pipe_solver._closed_resistance_pressure_plan), 2)
self.assertEqual(len(resistance_solver._closed_resistance_pressure_plan), 1)
closed_pipe = network.components["closed_pipe"]
expected_pressure = closed_pipe.properties().p
closed_pipe.port_1.p = 10_000.0
closed_pipe.port_2.p = 20_000.0
network.components["closed_pipe_left"].port_1.p = 30_000.0
network.components["closed_pipe_right"].port_1.p = 40_000.0
closed_pipe_solver._seed_closed_resistance_pressures()
self.assertAlmostEqual(closed_pipe.port_1.p, expected_pressure)
self.assertAlmostEqual(closed_pipe.port_2.p, expected_pressure)
self.assertAlmostEqual(
network.components["closed_pipe_left"].port_1.p,
expected_pressure,
)
self.assertAlmostEqual(
network.components["closed_pipe_right"].port_1.p,
expected_pressure,
)
def test_unclassified_custom_stream_component_uses_legacy_global_solver(self) -> None:
network, _source, _sink = _three_component_network(
_UnclassifiedCustomOrifice("custom_orifice", K=1.0e-5),
prefix="custom",
)
system = GenericFluidSystem(network)
plan = system._thermofluid_closure_plan
self.assertTrue(plan.uses_conservative_global_solver)
self.assertEqual(plan.secondary_pressure_solvers, (system.pressure_flow_solver,))
self.assertEqual(plan.secondary_component_groups, (plan.global_component_group,))
def test_invalid_dependency_declaration_uses_legacy_global_solver(self) -> None:
network, _source, _sink = _three_component_network(
_InvalidDependencyDeclarationOrifice("invalid_orifice", K=1.0e-5),
prefix="invalid",
)
plan = GenericFluidSystem(network)._thermofluid_closure_plan
self.assertTrue(plan.uses_conservative_global_solver)
self.assertEqual(
plan.conservative_fallback_reason,
"invalidDependencyDeclaration",
)
def test_non_square_physical_island_metadata_forces_global_fallback(self) -> None:
system = GenericFluidSystem(_mixed_island_network())
templates = list(system.pressure_flow_solver.equation_templates)
moved = next(
index
for index, equation in enumerate(templates)
if equation.owner == "component"
and equation.owner_id == "independent_orifice"
)
equation = templates[moved]
templates[moved] = replace(
equation,
owner_id="sensitive_valve",
variables=tuple(
variable.replace("independent_orifice", "sensitive_valve")
for variable in equation.variables
),
)
system.pressure_flow_solver._equation_templates = tuple(templates)
plan = system._build_thermofluid_closure_plan()
self.assertTrue(plan.uses_conservative_global_solver)
self.assertEqual(
plan.conservative_fallback_reason,
"nonSquarePhysicalIsland",
)
def test_pruned_and_legacy_chain_results_are_numerically_equivalent(self) -> None:
config = SolveIVPConfig(
t_start=0.0,
t_stop=0.01,
method="BDF",
max_step=0.001,
)
optimized = GenericFluidSystem(compile_reactflow_network(chain_project()))
legacy = GenericFluidSystem(compile_reactflow_network(chain_project()))
_force_legacy_global_coupling(legacy)
optimized_result = optimized.simulate(config, sample_step=0.005)
legacy_result = legacy.simulate(config, sample_step=0.005)
self.assertTrue(optimized_result.success)
self.assertTrue(legacy_result.success)
self.assertEqual(optimized_result.series.keys(), legacy_result.series.keys())
for key, optimized_values in optimized_result.series.items():
legacy_values = legacy_result.series[key]
self.assertEqual(len(optimized_values), len(legacy_values), key)
for optimized_value, legacy_value in zip(
optimized_values,
legacy_values,
):
self.assertAlmostEqual(
optimized_value,
legacy_value,
delta=1.0e-11 * max(abs(legacy_value), 1.0),
msg=key,
)
self.assertEqual(optimized_result.final.keys(), legacy_result.final.keys())
for key, optimized_value in optimized_result.final.items():
legacy_value = legacy_result.final[key]
self.assertAlmostEqual(
optimized_value,
legacy_value,
delta=1.0e-11 * max(abs(legacy_value), 1.0),
msg=key,
)
self.assertLess(optimized.algebraic_solve_count, legacy.algebraic_solve_count)
def test_stream_dependent_rhs_is_history_independent_after_other_trial(self) -> None:
network_a = compile_reactflow_network(high_pressure_helium_step_project())
network_fresh = compile_reactflow_network(high_pressure_helium_step_project())
system = GenericFluidSystem(network_a)
fresh = GenericFluidSystem(network_fresh)
state = system.consistent_initial_state_vector()
fresh_state = fresh.consistent_initial_state_vector()
perturbed = list(state)
perturbed[0] *= 1.000001
perturbed[1] *= 0.999999
first = system.rhs(0.041, state)
system.rhs(0.041, perturbed)
repeated = system.rhs(0.041, state)
reference = fresh.rhs(0.041, fresh_state)
for expected, actual in zip(first, repeated):
self.assertAlmostEqual(actual, expected, delta=1.0e-10 * max(abs(expected), 1.0))
for expected, actual in zip(reference, repeated):
self.assertAlmostEqual(actual, expected, delta=1.0e-10 * max(abs(expected), 1.0))
def test_block_solve_reuses_global_scales_from_extreme_other_island(self) -> None:
optimized = GenericFluidSystem(
_mixed_island_network(independent_pressure=1.0e10)
)
legacy = GenericFluidSystem(
_mixed_island_network(independent_pressure=1.0e10)
)
_force_legacy_global_coupling(legacy)
optimized_state = optimized.initial_state_vector()
legacy_state = legacy.initial_state_vector()
optimized_rhs = optimized.rhs(0.0, optimized_state)
legacy_rhs = legacy.rhs(0.0, legacy_state)
for expected, actual in zip(legacy_rhs, optimized_rhs):
self.assertAlmostEqual(
actual,
expected,
delta=1.0e-10 * max(abs(expected), 1.0),
)
self.assertLessEqual(
optimized.max_algebraic_residual,
max(legacy.max_algebraic_residual, 1.0e-14),
)
def test_high_stiffness_contact_island_matches_legacy_global_closure(self) -> None:
report = validate_system_xml_document(short_explicit_rk45_xml())
self.assertTrue(report.valid)
assert report.document is not None
document = report.document
optimized = GenericFluidSystem(compile_system_xml_network(document))
legacy = GenericFluidSystem(compile_system_xml_network(document))
_force_legacy_global_coupling(legacy)
config = SolveIVPConfig(
t_start=document.simulation.t_start,
t_stop=document.simulation.t_stop,
method=document.simulation.method,
rtol=1.0e-6,
max_step=document.simulation.max_step,
)
optimized_result = optimized.simulate(
config,
sample_step=document.simulation.sample_step,
)
legacy_result = legacy.simulate(
config,
sample_step=document.simulation.sample_step,
)
self.assertTrue(optimized_result.success)
self.assertTrue(legacy_result.success)
optimized_totals = optimized_result.diagnostics["integration"]["totals"]
legacy_totals = legacy_result.diagnostics["integration"]["totals"]
self.assertEqual(
optimized_totals["stateTransitionCount"],
legacy_totals["stateTransitionCount"],
)
self.assertEqual(optimized_result.series.keys(), legacy_result.series.keys())
for key, optimized_values in optimized_result.series.items():
legacy_values = legacy_result.series[key]
self.assertEqual(len(optimized_values), len(legacy_values), key)
for optimized_value, legacy_value in zip(
optimized_values,
legacy_values,
):
self.assertAlmostEqual(
optimized_value,
legacy_value,
delta=2.0e-7 * max(abs(legacy_value), 1.0),
msg=key,
)
def test_secondary_fluid_island_does_not_clear_active_contact_state(self) -> None:
network = compile_reactflow_network(elastic_contact_project())
medium = IdealGasMedium()
source = Cylinder(
"separate_source",
medium,
V=0.02,
p0=600_000.0,
T0=350.0,
)
sink = Tank(
"separate_sink",
medium,
V=0.05,
p0=100_000.0,
T0=280.0,
)
valve = AmesimPnvo001FixedOpening(
"separate_valve",
medium,
area0=1.0e-5,
opening=0.8,
)
for component in (source, sink, valve):
network.add_component(component)
network.connect("separate_source", "port_b", "separate_valve", "port_2")
network.connect("separate_valve", "port_3", "separate_sink", "port_a")
self.assertEqual(simulation_preparation_issues(network), ())
system = GenericFluidSystem(network)
contact = network.components["contact_1"]
secondary = system._thermofluid_closure_plan.secondary_block_solvers[0]
original_solve = secondary.solve
observed: list[tuple[float | None, ...]] = []
def checked_solve(*, scale_context=None):
if contact._causal_penetration is None:
contact.set_causal_contact(penetration=1.0e-4, force=10.0)
before = (
contact._causal_penetration,
contact._causal_contact_force,
contact._causal_port_1_x,
contact._causal_port_2_x,
contact._causal_port_1_v,
contact._causal_port_2_v,
)
result = original_solve(scale_context=scale_context)
after = (
contact._causal_penetration,
contact._causal_contact_force,
contact._causal_port_1_x,
contact._causal_port_2_x,
contact._causal_port_1_v,
contact._causal_port_2_v,
)
self.assertEqual(after, before)
observed.append(before)
return result
secondary.solve = checked_solve
system.consistent_initial_state_vector()
self.assertTrue(observed)
self.assertIsNotNone(observed[0][0])
self.assertIsNotNone(observed[0][1])
if __name__ == "__main__":
unittest.main()