优化仿真求解性能并修复流量闭合问题(初版)
This commit is contained in:
1 parent
57b459bc72
commit
5332a788f3
55 files changed
+8973
-549
No files matched your search
+325
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user