Replace Python numerical kernels with native C execution

This commit is contained in:
ljz committed 2026-09-10 01:12:18 +08:00
1 parent 48da6be21c
commit 3b38f73fe0
227 files changed
+16801 -75499

No files matched your search

@@ -3,7 +3,7 @@
"id": "test_mql_8-progressive-v3",
"description": "Primary generic-solver regression target for the restored eight-branch inputs aligned to the test_mql AMESim archive, with a short smoke gate before staged extension.",
"source": {
"path": "tests/data/test-mql-8.xml",
"path": "tests/baselines/simulation/test_mql_8/sources/test-mql-8.xml",
"sha256": "0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b",
"bytes": 104110,
"xmlIsAuthoritative": true,
@@ -18,7 +18,7 @@
"resultPointCount": 1002
},
"companionProject": {
"path": "tests/data/test-mql-8.json",
"path": "tests/baselines/simulation/test_mql_8/sources/test-mql-8.json",
"sha256": "b44bf540ccd1c293fe2af2b9b9052b540abf83961ad955a0f6a4ab40fbe0bb18",
"bytes": 272592,
"executionInput": false
@@ -231,7 +231,7 @@
},
"correctness": {
"status": "complete",
"maximumScaledResidual": 1e-7,
"maximumScaledResidual": 1e-07,
"requireFiniteSeries": true,
"requireStrictlyIncreasingTimes": true,
"physicalProjectionCategories": [
@@ -242,9 +242,9 @@
"discreteMode"
],
"stateRelativeTolerance": 0.0002,
"stateAbsoluteTolerance": 1e-9,
"stateAbsoluteTolerance": 1e-09,
"checkpointTimeAbsoluteToleranceSeconds": 1e-12,
"eventTimeAbsoluteToleranceSeconds": 0.00002,
"eventTimeAbsoluteToleranceSeconds": 2e-05,
"signalEventTimeAbsoluteToleranceSeconds": 1e-12,
"mechanicalTransitionTimesAvailable": true,
"physicalBaselineAuthority": "amesim",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"provenance":"Frozen from the existing Python implementation before its removal on 2026-09-10. Expected values are not generated by the C implementation.","rtol":2e-08,"atol":2e-07,"cases":[{"components":[{"name":"amesim_pnch023_1","type":"amesim_pnch023","parameters":{"gi":1.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":15300000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnvo001_1","type":"amesim_pnvo001","parameters":{"gi":1.0,"cq":0.45,"area0":7.85e-05,"Cv":0.5,"Kv":0.4,"flowset":1.0,"opening0":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_step0_1","type":"amesim_step0","parameters":{"initial":0.0,"final":1.0,"time":0.04},"overrides":{},"medium":null},{"name":"amesim_pnch012_1","type":"amesim_pnch012","parameters":{"gi":1.0,"cvol0":0.015,"kth":1500.0,"sth":0.7,"extemp":293.15,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnpl01_2","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_ud00_1","type":"amesim_ud00","parameters":{"tstart":0.0,"start1":1e+17,"end1":1e+17,"t1":0.8,"start2":49000.0,"end2":49000.0,"t2":10.0,"start3":1.0,"end3":1.0,"t3":0.0,"start4":1.0,"end4":1.0,"t4":0.0,"start5":1.0,"end5":1.0,"t5":0.0,"start6":1.0,"end6":1.0,"t6":0.0,"start7":1.0,"end7":1.0,"t7":0.0,"start8":1.0,"end8":1.0,"t8":0.0,"nstages":2.0,"iscyclic":0.0},"overrides":{"iscyclic":true,"starts":[1e+17,49000.0,1.0,1.0,1.0,1.0,1.0,1.0],"ends":[1e+17,49000.0,1.0,1.0,1.0,1.0,1.0,1.0],"durations":[0.8,10.0,0.0,0.0,0.0,0.0,0.0,0.0],"nstages":2},"medium":null},{"name":"amesim_forc_1","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"amesim_pnrp17_1","type":"amesim_pnrp17","parameters":{"gi":1.0,"dp":0.2,"dr":0.001,"x0":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnch012_2","type":"amesim_pnch012","parameters":{"gi":1.0,"cvol0":0.015,"kth":1500.0,"sth":0.7,"extemp":293.15,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnpl01_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_7","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_8","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_9","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_mecmas21_2","type":"amesim_mecmas21","parameters":{"mass":50.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-1.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.8,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":4.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"amesim_f000_1","type":"amesim_f000","parameters":{},"overrides":{},"medium":null},{"name":"amesim_f000_2","type":"amesim_f000","parameters":{},"overrides":{},"medium":null},{"name":"amesim_lstp00a_1","type":"amesim_lstp00a","parameters":{"na":10.0,"gap0":0.0,"kconLine truncated
+325
View File
@@ -0,0 +1,325 @@
<?xml version="1.0" encoding="UTF-8"?>
<System name="skill-test" schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="10" sampleStep="0.02" maxStep="0.001" 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-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_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>
<Connection id="edge-contact-amesim_f000_2-port_1-amesim_pnrp17_1-port_4-1788429018340-0">
<Endpoint component="amesim_f000_2" port="port_1"/>
<Endpoint component="amesim_pnrp17_1" port="port_4"/>
</Connection>
<Connection id="edge-contact-amesim_f000_1-port_1-amesim_mecmas21_2-port_2-1788429022851-0">
<Endpoint component="amesim_f000_1" port="port_1"/>
<Endpoint component="amesim_mecmas21_2" port="port_2"/>
</Connection>
</Connections>
</System>
+45 -45
View File
@@ -110,7 +110,7 @@
"parameters": {
"gi": 1,
"cq": 0.9,
"area": 0.0000785,
"area": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1
@@ -156,7 +156,7 @@
"parameters": {
"gi": 1,
"cq": 0.9,
"area": 0.0000785,
"area": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1
@@ -389,7 +389,7 @@
"parameters": {
"gi": 1,
"cq": 0.9,
"area": 0.0000785,
"area": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1
@@ -435,7 +435,7 @@
"parameters": {
"gi": 1,
"cq": 0.9,
"area": 0.0000785,
"area": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1
@@ -623,7 +623,7 @@
"parameters": {
"gi": 1,
"cq": 0.9,
"area": 0.0000785,
"area": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1
@@ -669,7 +669,7 @@
"parameters": {
"gi": 1,
"cq": 0.9,
"area": 0.0000785,
"area": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1
@@ -812,7 +812,7 @@
"parameters": {
"gi": 1,
"cq": 0.9,
"area": 0.0000785,
"area": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1
@@ -858,7 +858,7 @@
"parameters": {
"gi": 1,
"cq": 0.9,
"area": 0.0000785,
"area": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1
@@ -1913,7 +1913,7 @@
"parameters": {
"gi": 1,
"cq": 0.45,
"area0": 0.0000785,
"area0": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1,
@@ -1967,7 +1967,7 @@
"parameters": {
"gi": 1,
"cq": 0.45,
"area0": 0.0000785,
"area0": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1,
@@ -2021,7 +2021,7 @@
"parameters": {
"gi": 1,
"cq": 0.45,
"area0": 0.0000785,
"area0": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1,
@@ -2075,7 +2075,7 @@
"parameters": {
"gi": 1,
"cq": 0.45,
"area0": 0.0000785,
"area0": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1,
@@ -2129,7 +2129,7 @@
"parameters": {
"gi": 1,
"cq": 0.45,
"area0": 0.0000785,
"area0": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1,
@@ -2183,7 +2183,7 @@
"parameters": {
"gi": 1,
"cq": 0.45,
"area0": 0.0000785,
"area0": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1,
@@ -2237,7 +2237,7 @@
"parameters": {
"gi": 1,
"cq": 0.45,
"area0": 0.0000785,
"area0": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1,
@@ -2291,7 +2291,7 @@
"parameters": {
"gi": 1,
"cq": 0.45,
"area0": 0.0000785,
"area0": 7.85e-05,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1,
@@ -5845,8 +5845,8 @@
"fcoul": 0,
"rvisc": 0,
"wind": 0,
"dvel": 0.000001,
"restdvel": 0.000001,
"dvel": 1e-06,
"restdvel": 1e-06,
"restcoeff": 0.65,
"astrib": 0.001,
"xmin": -1,
@@ -5927,8 +5927,8 @@
"fcoul": 0,
"rvisc": 0,
"wind": 0,
"dvel": 0.000001,
"restdvel": 0.000001,
"dvel": 1e-06,
"restdvel": 1e-06,
"restcoeff": 0.65,
"astrib": 0.001,
"xmin": -1,
@@ -6009,8 +6009,8 @@
"fcoul": 0,
"rvisc": 0,
"wind": 0,
"dvel": 0.000001,
"restdvel": 0.000001,
"dvel": 1e-06,
"restdvel": 1e-06,
"restcoeff": 0.65,
"astrib": 0.001,
"xmin": -1,
@@ -6091,8 +6091,8 @@
"fcoul": 0,
"rvisc": 0,
"wind": 0,
"dvel": 0.000001,
"restdvel": 0.000001,
"dvel": 1e-06,
"restdvel": 1e-06,
"restcoeff": 0.65,
"astrib": 0.001,
"xmin": -1,
@@ -6173,8 +6173,8 @@
"fcoul": 0,
"rvisc": 0,
"wind": 0,
"dvel": 0.000001,
"restdvel": 0.000001,
"dvel": 1e-06,
"restdvel": 1e-06,
"restcoeff": 0.65,
"astrib": 0.001,
"xmin": -1,
@@ -6255,8 +6255,8 @@
"fcoul": 0,
"rvisc": 0,
"wind": 0,
"dvel": 0.000001,
"restdvel": 0.000001,
"dvel": 1e-06,
"restdvel": 1e-06,
"restcoeff": 0.65,
"astrib": 0.001,
"xmin": -1,
@@ -6337,8 +6337,8 @@
"fcoul": 0,
"rvisc": 0,
"wind": 0,
"dvel": 0.000001,
"restdvel": 0.000001,
"dvel": 1e-06,
"restdvel": 1e-06,
"restcoeff": 0.65,
"astrib": 0.001,
"xmin": -1,
@@ -6419,8 +6419,8 @@
"fcoul": 0,
"rvisc": 0,
"wind": 0,
"dvel": 0.000001,
"restdvel": 0.000001,
"dvel": 1e-06,
"restdvel": 1e-06,
"restcoeff": 0.65,
"astrib": 0.001,
"xmin": -1,
@@ -6735,7 +6735,7 @@
"sdiam": 0.02,
"wdiam": 0.002,
"rcont": 100000000000,
"Pdis": 1.0000000000000001e-7,
"Pdis": 1.0000000000000001e-07,
"stiffmode": 1,
"discContactOption": 1
},
@@ -6791,7 +6791,7 @@
"sdiam": 0.02,
"wdiam": 0.002,
"rcont": 100000000000,
"Pdis": 1.0000000000000001e-7,
"Pdis": 1.0000000000000001e-07,
"stiffmode": 1,
"discContactOption": 1
},
@@ -6847,7 +6847,7 @@
"sdiam": 0.02,
"wdiam": 0.002,
"rcont": 100000000000,
"Pdis": 1.0000000000000001e-7,
"Pdis": 1.0000000000000001e-07,
"stiffmode": 1,
"discContactOption": 1
},
@@ -6903,7 +6903,7 @@
"sdiam": 0.02,
"wdiam": 0.002,
"rcont": 100000000000,
"Pdis": 1.0000000000000001e-7,
"Pdis": 1.0000000000000001e-07,
"stiffmode": 1,
"discContactOption": 1
},
@@ -6959,7 +6959,7 @@
"sdiam": 0.02,
"wdiam": 0.002,
"rcont": 100000000000,
"Pdis": 1.0000000000000001e-7,
"Pdis": 1.0000000000000001e-07,
"stiffmode": 1,
"discContactOption": 1
},
@@ -7015,7 +7015,7 @@
"sdiam": 0.02,
"wdiam": 0.002,
"rcont": 100000000000,
"Pdis": 1.0000000000000001e-7,
"Pdis": 1.0000000000000001e-07,
"stiffmode": 1,
"discContactOption": 1
},
@@ -7071,7 +7071,7 @@
"sdiam": 0.02,
"wdiam": 0.002,
"rcont": 100000000000,
"Pdis": 1.0000000000000001e-7,
"Pdis": 1.0000000000000001e-07,
"stiffmode": 1,
"discContactOption": 1
},
@@ -7127,7 +7127,7 @@
"sdiam": 0.02,
"wdiam": 0.002,
"rcont": 100000000000,
"Pdis": 1.0000000000000001e-7,
"Pdis": 1.0000000000000001e-07,
"stiffmode": 1,
"discContactOption": 1
},
@@ -7373,8 +7373,8 @@
"fcoul": 0,
"rvisc": 0,
"wind": 0,
"dvel": 0.000001,
"restdvel": 0.000001,
"dvel": 1e-06,
"restdvel": 1e-06,
"restcoeff": 0.65,
"astrib": 0.001,
"xmin": -0.72,
@@ -7751,8 +7751,8 @@
"fcoul": 0,
"rvisc": 0,
"wind": 0,
"dvel": 0.000001,
"restdvel": 0.000001,
"dvel": 1e-06,
"restdvel": 1e-06,
"restcoeff": 0.65,
"astrib": 0.001,
"xmin": 0,
@@ -10198,7 +10198,7 @@
"t_start": 0,
"t_stop": 10,
"step": 0.01,
"max_step": 0.001,
"max_step": 0.02,
"method": "BDF"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<System name="test-mql-8" schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="10" sampleStep="0.01" maxStep="0.001" method="BDF"/>
<Simulation tStart="0" tStop="10" sampleStep="0.01" maxStep="0.02" method="BDF"/>
<Components>
<Component id="pn_gas_data" type="amesim_helium_medium" modelVersion="0.1.0">
<Parameter name="gi" value="1"/>
+30
View File
@@ -0,0 +1,30 @@
"""Rebuild networks from the Python baseline captured before its retirement."""
import json
from pathlib import Path
from app.simulation.core.medium import IdealGasMedium
from app.simulation.components.amesim.media.mediums import AmesimHeliumPengRobinsonMedium, AmesimIdealAirMedium
from app.simulation.native_codegen.extended import catalog_contracts
from app.simulation.systems.network import SimulationNetwork
def reference_data():
return json.loads((Path(__file__).parent / 'data/native-python-reference.json').read_text(encoding='utf-8'))
def reference_network(case):
net = SimulationNetwork('frozen_reference')
media = {}
medium_types = {cls.__name__: cls for cls in (IdealGasMedium, AmesimHeliumPengRobinsonMedium, AmesimIdealAirMedium)}
for item in case['components']:
spec = item['medium']
key = json.dumps(spec, sort_keys=True)
if key not in media:
media[key] = medium_types[spec['type']](**spec['parameters']) if spec else IdealGasMedium()
component = catalog_contracts()[item['type']].create(name=item['name'], medium=media[key], parameters=item['parameters'])
for name, value in item['overrides'].items():
setattr(component, name, value)
net.add_component(component)
for left, right in case['connections']:
net.connect(*left, *right)
return net
@@ -1,38 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.examples.test_mql import system as test_mql
MATRIX_PATH = (
Path(__file__).resolve().parents[1]
/ "docs"
/ "other"
/ "amesim-component-migration-matrix.md"
)
class AmesimComponentMigrationMatrixTests(unittest.TestCase):
def test_matrix_covers_every_test_mql_submodel_family(self) -> None:
text = MATRIX_PATH.read_text(encoding="utf-8")
families = set(test_mql.SUBMODEL_COUNTS) | set(test_mql.LINE_SUBMODEL_COUNTS)
missing = sorted(family for family in families if f"`{family}`" not in text)
self.assertEqual(missing, [])
def test_pnrp17_is_marked_as_the_first_public_cross_domain_component(self) -> None:
text = MATRIX_PATH.read_text(encoding="utf-8")
row = next(
line for line in text.splitlines() if "| `PNRP17` |" in line
)
self.assertIn("第一版公开", row)
self.assertIn("`amesim_pnrp17`", row)
self.assertNotIn("暂不公开", row)
if __name__ == "__main__":
unittest.main()
+3 -15
View File
@@ -155,7 +155,6 @@ class AmesimGasRegistryTests(unittest.TestCase):
self.assertEqual(medium.PROPERTY_METHOD_ID, "ideal_gas")
self.assertEqual(medium.R_gas, 287.0)
self.assertEqual(medium.cp_ref, 1005.0)
self.assertEqual(medium.cv, 718.0)
def test_gi_normalization_rejects_fractional_and_out_of_range_values(self) -> None:
self.assertEqual(normalize_amesim_gas_index(0.0), 0)
@@ -369,7 +368,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "gi.*at least 1"):
compile_reactflow_network(project)
def test_default_compile_uses_air_before_state_initialization(self) -> None:
def test_default_compile_selects_air_without_python_state(self) -> None:
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="default-air",
@@ -380,12 +379,9 @@ class AmesimGasRegistryTests(unittest.TestCase):
chamber = network.components["chamber_1"]
self.assertEqual(chamber.medium.name, "AMESimAirIdealGas")
self.assertAlmostEqual(
chamber.state.m,
chamber.medium.density(200000.0, 300.0) * 0.01,
)
self.assertFalse(hasattr(chamber, "state"))
def test_gi_is_resolved_before_dynamic_state_initialization(self) -> None:
def test_gi_is_resolved_before_native_compilation(self) -> None:
registry, _, future_helium = two_slot_registry()
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
@@ -400,14 +396,6 @@ class AmesimGasRegistryTests(unittest.TestCase):
chamber = network.components["chamber_1"]
self.assertIs(chamber.medium, future_helium)
self.assertAlmostEqual(
chamber.state.m,
future_helium.density(200000.0, 300.0) * 0.01,
)
self.assertNotAlmostEqual(
chamber.state.m,
IdealGasMedium().density(200000.0, 300.0) * 0.01,
)
def test_selected_medium_is_injected_into_the_whole_pneumatic_circuit(
self,
-287
View File
@@ -1,287 +0,0 @@
from __future__ import annotations
import unittest
from app.main import ReactFlowProjectPayload, compile_reactflow_network
from app.simulation.components.amesim.library import LIBRARY
from app.simulation.components.amesim.media.mediums import (
AMESIM_AIR_PROPERTY_MODELS,
AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL,
AMESIM_HELIUM_PROPERTY_MODELS,
AmesimHeliumPengRobinsonMedium,
)
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
class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase):
def test_uses_amesim_2404_peng_robinson_and_nasa_constants(
self,
) -> None:
medium = AmesimHeliumPengRobinsonMedium()
pressure = 15.3e6
temperature = 293.15
density = medium.density(pressure, temperature)
volume = 0.02
self.assertIs(medium.fluid, HELIUM_PR)
self.assertEqual(medium.SUBSTANCE_ID, "helium")
self.assertEqual(medium.PROPERTY_METHOD_ID, "peng_robinson")
self.assertAlmostEqual(medium.R_gas, HELIUM_PR.specific_gas_constant)
self.assertEqual(medium.cp_ref, 2.5 * medium.R_gas)
self.assertEqual(medium.cv, 1.5 * medium.R_gas)
self.assertEqual(medium.cp_at_temperature(400.0), 2.5 * medium.R_gas)
self.assertEqual(medium.cv_at_temperature(400.0), 1.5 * medium.R_gas)
self.assertAlmostEqual(medium.gamma, 5.0 / 3.0)
self.assertAlmostEqual(density, HELIUM_PR.density(pressure, temperature))
self.assertAlmostEqual(
medium.pressure(density * volume, temperature, volume),
pressure,
delta=pressure * 1.0e-12,
)
def test_nasa_caloric_reference_and_sutherland_viscosity_complete_contract(
self,
) -> None:
medium = AmesimHeliumPengRobinsonMedium()
temperature = 340.0
internal_energy = medium.specific_internal_energy(temperature)
enthalpy = medium.specific_enthalpy(temperature)
self.assertEqual(
internal_energy,
medium.R_gas * (1.5 * temperature - 745.375),
)
self.assertEqual(
enthalpy,
medium.R_gas * (2.5 * temperature - 745.375),
)
self.assertEqual(
medium.temperature_from_internal_energy(internal_energy),
temperature,
)
self.assertEqual(
medium.temperature_from_enthalpy(enthalpy),
temperature,
)
self.assertAlmostEqual(medium.dynamic_viscosity(293.15), 1.96e-5)
self.assertAlmostEqual(
medium.diagnostic_dynamic_viscosity(293.15),
1.9616132203938165e-5,
)
self.assertEqual(medium.sutherland_constant, 79.4)
def test_pipe_diagnostic_viscosity_reproduces_amesim_nasa_table(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
self.assertEqual(
medium.nasa_viscosity_coefficients,
(0.7501594, 35.76324, -2212.129, 0.9212635),
)
self.assertAlmostEqual(
medium.diagnostic_dynamic_viscosity(233.92077861710192),
1.683135986478913e-5,
delta=1.0e-19,
)
self.assertNotAlmostEqual(
medium.diagnostic_dynamic_viscosity(233.92077861710192),
medium.dynamic_viscosity(233.92077861710192),
delta=1.0e-8,
)
with self.assertRaises(ValueError):
medium.diagnostic_dynamic_viscosity(0.0)
def test_pressure_transport_enthalpy_includes_peng_robinson_departure(
self,
) -> None:
medium = AmesimHeliumPengRobinsonMedium()
pressure = 13_839_965.0
temperature = 287.7322
ideal_enthalpy = medium.specific_enthalpy(temperature)
transport_enthalpy = medium.specific_enthalpy_at_pressure(pressure, temperature)
self.assertGreater(
transport_enthalpy,
ideal_enthalpy,
)
self.assertAlmostEqual(
medium.temperature_from_pressure_enthalpy(pressure, transport_enthalpy),
temperature,
delta=1.0e-8,
)
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)
density = medium.density(pressure, temperature)
volume = 0.01
properties = medium.properties_from_mU(
density * volume,
density
* volume
* medium.specific_internal_energy_at_pressure(pressure, temperature),
volume,
)
self.assertAlmostEqual(properties.p, pressure, delta=pressure * 1.0e-10)
self.assertAlmostEqual(properties.T, temperature, delta=1.0e-8)
self.assertAlmostEqual(
properties.h,
transport_enthalpy,
delta=2.0e-8,
)
def test_negative_mass_is_a_recoverable_integrator_trial_state(self) -> None:
media = (IdealGasMedium(), AmesimHeliumPengRobinsonMedium())
for medium in media:
with self.subTest(medium=medium.name):
with self.assertRaises(RecoverableTrialStateError):
medium.temperature_from_mass_internal_energy(-1.0, 1.0)
with self.assertRaises(RecoverableTrialStateError):
medium.properties_from_mU(-1.0, 1.0, 1.0)
def test_exact_repeated_real_gas_state_recovery_is_cached(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
pressure = 15.3e6
temperature = 293.15
volume = 0.057
mass = medium.density(pressure, temperature) * volume
internal_energy = mass * medium.specific_internal_energy_at_pressure(
pressure,
temperature,
)
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)
def test_amesim_2404_real_gas_isentropic_factor_reference(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
self.assertAlmostEqual(
medium.isentropic_density_pressure_factor(
15_201_996.778497815,
292.3997890954483,
405_072.8123335606,
),
0.5807873871273339,
delta=1.0e-12,
)
def test_definition_maps_local_selector_to_amesim_fluid_and_eos_codes(
self,
) -> None:
self.assertEqual(LIBRARY.version, "0.3.0")
self.assertEqual(AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL, 0)
self.assertEqual(AMESIM_HELIUM_PROPERTY_MODELS[0].value, 0)
self.assertEqual(AMESIM_HELIUM_PROPERTY_MODELS[0].eos_type, 6)
self.assertEqual(AMESIM_AIR_PROPERTY_MODELS[0].eos_type, 1)
spec = COMPONENT_MODEL_REGISTRY["amesim_helium_medium"]
property_model = spec.parameter_by_name["property_model"]
self.assertEqual(spec.display.category_id, "media")
self.assertEqual(spec.display.role, "amesimGasMediumDefinition")
self.assertEqual(spec.display.label, "氦气介质定义")
self.assertEqual(spec.model_version, "0.1.0")
self.assertEqual(spec.ports, ())
self.assertEqual(property_model.default, 0.0)
self.assertEqual(property_model.editor, "amesimGasPropertyModel")
self.assertEqual(
[(option.value, option.label) for option in property_model.options],
[(0, "Peng–Robinson")],
)
component = spec.create(
"helium_properties",
IdealGasMedium(),
{"gi": 2.0, "property_model": 0.0},
)
definition = component.gas_definition()
self.assertEqual(component.parameter_values, {"gi": 2.0, "property_model": 0.0})
self.assertEqual(definition.label, "氦气(Peng–Robinson)")
self.assertEqual(definition.fluid_type, 12)
self.assertEqual(definition.eos_type, 6)
self.assertIsInstance(definition.medium, AmesimHeliumPengRobinsonMedium)
def test_project_compile_injects_helium_before_dynamic_state_initialization(
self,
) -> None:
pressure = 13_839_965.0
temperature = 287.7322
volume = 0.01
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="helium-peng-robinson-compile",
nodes=[
component_node(
"helium_properties",
"amesim_helium_medium",
[],
{"gi": 2.0, "property_model": 0.0},
),
component_node(
"chamber_1",
"amesim_pnch023",
[
physical_port("port_1", "bidirectional", "left"),
physical_port("port_2", "bidirectional", "right"),
],
{
"gi": 2.0,
"cvol": volume,
"kth": 0.0,
"sth": 0.1,
"extemp": 293.15,
"p0": pressure,
"T0": temperature,
},
),
],
)
network = compile_reactflow_network(project)
chamber = network.components["chamber_1"]
self.assertNotIn("helium_properties", network.components)
self.assertIsInstance(chamber.medium, AmesimHeliumPengRobinsonMedium)
self.assertAlmostEqual(
chamber.state.m,
HELIUM_PR.density(pressure, temperature) * volume,
delta=1.0e-12,
)
if __name__ == "__main__":
unittest.main()
@@ -219,15 +219,6 @@ class AmesimHeliumStepLongRunTests(unittest.TestCase):
self.assertTrue(result["success"], result["message"])
self.assertEqual(result["status"], "completed")
self.assertAlmostEqual(result["simulatedUntil"], 10.0)
self.assertEqual(result["diagnostics"]["signal"]["eventTimes"], [0.04])
self.assertLessEqual(
result["diagnostics"]["pressureFlow"]["maxEvaluationsPerSolve"],
5,
)
self.assertLessEqual(
result["diagnostics"]["pressureFlow"]["maxScaledResidual"],
1.0e-7,
)
series = result["series"]
self.assertIn(3.04, series["time"])
@@ -1,98 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.examples.test_mql.primitives.mechanical import (
AmesimElasticEndstop,
AmesimMassFrictionEndstops,
AmesimPistonGeometry,
circular_area,
m_to_mm,
mm_to_m,
)
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class AmesimMechanicalComponentsTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
def test_unit_helpers_and_area(self) -> None:
self.assertAlmostEqual(mm_to_m(200.0), 0.2)
self.assertAlmostEqual(m_to_mm(0.37), 370.0)
self.assertAlmostEqual(circular_area(0.2), 0.031415926535897934)
def test_piston_geometry_matches_amesim_length_and_volume(self) -> None:
geometry = AmesimPistonGeometry(piston_diameter_m=0.2, rod_diameter_m=0.001)
index = -1
x4 = self.amesim_results.series("x4@pn_brp2_8")[index]
x5 = self.amesim_results.series("x5@pn_brp2_8")[index]
self.assertAlmostEqual(
geometry.chamber_length_mm(x4, x5),
self.amesim_results.series("length@pn_brp2_8")[index],
)
self.assertAlmostEqual(
geometry.chamber_volume_cm3(x4, x5),
self.amesim_results.series("vol1@pn_brp2_8")[index],
delta=1.0e-3,
)
def test_piston_geometry_matches_amesim_volume_rate(self) -> None:
geometry = AmesimPistonGeometry(piston_diameter_m=0.2, rod_diameter_m=0.001)
index = 100
v4 = self.amesim_results.series("v4@pn_brp2_8")[index]
v5 = self.amesim_results.series("v5@pn_brp2_8")[index]
self.assertAlmostEqual(
geometry.chamber_volume_rate_l_min(v4, v5),
self.amesim_results.series("vvol1@pn_brp2_8")[index],
delta=1.0e-8,
)
def test_elastic_endstop_contact_force_matches_amesim_final_sample(self) -> None:
endstop = AmesimElasticEndstop(
contact_stiffness_n_per_m=1.0e11,
contact_damping_n_per_m_per_s=1.0e11,
)
index = -1
gap_mm = self.amesim_results.series("gap@elasticendstop_8")[index]
penetration_velocity_m_s = self.amesim_results.series("v5@pn_brp2_8")[index]
self.assertAlmostEqual(
endstop.contact_force(gap_mm, penetration_velocity_m_s),
self.amesim_results.series("f1@elasticendstop_8")[index],
delta=1.0e-3,
)
def test_elastic_endstop_returns_zero_before_contact(self) -> None:
endstop = AmesimElasticEndstop(contact_stiffness_n_per_m=1.0e11)
self.assertEqual(endstop.static_contact_force(0.1), 0.0)
self.assertEqual(endstop.penetration_m_from_gap_mm(0.1), 0.0)
def test_mass_endstop_penetration_and_viscous_friction(self) -> None:
mass = AmesimMassFrictionEndstops(
mass_kg=50.0,
lower_limit_m=-1.0,
upper_limit_m=0.8,
lower_stiffness_n_per_m=1.0e9,
upper_stiffness_n_per_m=1.0e9,
viscous_friction_n_per_m_per_s=12.0,
)
self.assertEqual(mass.lower_penetration_m(0.0), 0.0)
self.assertEqual(mass.upper_penetration_m(0.0), 0.0)
self.assertAlmostEqual(mass.lower_static_force_magnitude(-1.001), 1.0e6, delta=1.0e-6)
self.assertAlmostEqual(mass.upper_static_force_magnitude(0.801), 1.0e6, delta=1.0e-6)
self.assertAlmostEqual(mass.viscous_friction_force(0.25), -3.0)
if __name__ == "__main__":
unittest.main()
@@ -23,50 +23,8 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
(("port_2", "left"), ("port_1", "right")),
)
def test_f000_constrains_mechanical_port_force_to_zero(self) -> None:
source = AmesimF000("zero_1")
source.port_1.f = 12.5
residuals = source.pressure_flow_equation_residuals()
self.assertEqual(len(residuals), 1)
self.assertEqual(residuals[0].variables, ("zero_1.port_1.f",))
self.assertAlmostEqual(residuals[0].value, 12.5)
def test_forc_converts_signal_to_opposing_source_port_force(self) -> None:
converter = AmesimForc("force_1")
converter.res.signal = 20.0
converter.port_2.f = -20.0
residuals = converter.pressure_flow_equation_residuals()
self.assertAlmostEqual(converter.output_force, 20.0)
self.assertEqual(
converter.pressure_flow_equation_values(),
tuple(residual.value for residual in residuals),
)
self.assertAlmostEqual(residuals[0].value, 0.0)
self.assertEqual(converter.component_result_values(), {"force": 20.0})
self.assertEqual(converter.parameter_values, {"direction": 1.0})
def test_forc_negative_direction_reverses_applied_force(self) -> None:
converter = AmesimForc.create(
name="force_reverse",
medium=self.medium,
parameters={"direction": -1.0},
)
converter.res.signal = 20.0
converter.port_2.f = 20.0
residuals = converter.pressure_flow_equation_residuals()
self.assertEqual(
converter.pressure_flow_equation_values(),
tuple(residual.value for residual in residuals),
)
self.assertAlmostEqual(residuals[0].value, 0.0)
self.assertEqual(converter.parameter_values, {"direction": -1.0})
self.assertEqual(converter.component_result_values(), {"force": -20.0})
def test_forc_direction_only_accepts_positive_or_negative_one(self) -> None:
definition = AmesimForc.PARAMETERS[0]
@@ -81,46 +39,7 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "must be one of 1, -1"):
AmesimForc("invalid_force", direction=0.0)
def test_mecmas21_acceleration_uses_connected_port_forces(self) -> None:
mass = AmesimMecmas21(
"mass_1",
self.medium,
mass=2.0,
fcoul=0.0,
rvisc=0.0,
wind=0.0,
x0=0.1,
v0=0.2,
)
mass.port_1.f = 10.0
mass.port_2.f = -2.0
self.assertEqual(mass.get_state_vector(), [0.2, 0.1])
self.assertAlmostEqual(mass.acceleration(), 4.0)
self.assertEqual(mass.state_derivative_from_ports({}), [4.0, 0.2])
self.assertAlmostEqual(mass.port_1.x, 0.1)
self.assertAlmostEqual(mass.port_2.v, 0.2)
def test_mecmas21_uses_amesim_one_two_friction_encoding(self) -> None:
disabled = AmesimMecmas21(
"mass_disabled",
self.medium,
useFriction=1.0,
fcoul=5.0,
v0=2.0,
)
enabled = AmesimMecmas21(
"mass_enabled",
self.medium,
useFriction=2.0,
fcoul=5.0,
v0=2.0,
)
self.assertFalse(disabled.use_friction)
self.assertEqual(disabled._dry_friction_force(), 0.0)
self.assertTrue(enabled.use_friction)
self.assertEqual(enabled._dry_friction_force(), -5.0)
def test_mecmas21_choice_metadata_preserves_amesim_codes(self) -> None:
definitions = {
@@ -223,34 +142,6 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
with self.subTest(always_visible=name):
self.assertEqual(definitions[name].visible_when, ())
def test_lmechn1_ties_active_port_kinematics_and_balances_force(self) -> None:
node = AmesimLmechn1("node_1", self.medium, v1=2.0, sum=1.0)
node.port_1.x = 0.2
node.port_1.v = 0.3
node.port_1.f = 4.0
node.port_2.x = 0.2
node.port_2.v = 0.3
node.port_2.f = 6.0
node.port_3.x = 0.2
node.port_3.v = 0.3
node.port_3.f = -10.0
residuals = node.pressure_flow_equation_residuals()
self.assertEqual(node.active_ports, ("port_1", "port_2", "port_3"))
self.assertEqual(
tuple(definition.name for definition in node.active_port_definitions),
node.active_ports,
)
self.assertAlmostEqual(node.total_force, 10.0)
self.assertAlmostEqual(node.force_balance, 0.0)
self.assertEqual(len(residuals), 5)
self.assertEqual(
node.pressure_flow_equation_values(),
tuple(residual.value for residual in residuals),
)
self.assertTrue(all(abs(residual.value) <= 1.0e-12 for residual in residuals))
self.assertEqual(node.component_result_values(), {"tforce": 10.0})
def test_lmechn1_defaults_to_two_right_ports(self) -> None:
node = AmesimLmechn1("node_default", self.medium)
@@ -276,39 +167,7 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "must be at most 20"):
AmesimLmechn1("node_21", self.medium, v1=21.0, sum=1.0)
def test_lstp00a_generates_opposing_contact_forces(self) -> None:
contact = AmesimLstp00a(
"contact_1",
self.medium,
gap0=0.0,
kcont=1000.0,
rcont=10.0,
)
contact.port_1.x = 0.002
contact.port_2.x = 0.0
contact.port_1.v = 0.1
contact.port_2.v = 0.0
contact.port_1.f = 3.0
contact.port_2.f = -3.0
self.assertAlmostEqual(contact.gap, -0.002)
self.assertAlmostEqual(contact.penetration, 0.002)
self.assertAlmostEqual(contact.contact_force, 3.0)
self.assertEqual(
contact.component_result_values(),
{"gap": -0.002, "penetration": 0.002, "force": 3.0},
)
residuals = contact.pressure_flow_equation_residuals()
self.assertAlmostEqual(residuals[0].value, 0.0)
self.assertAlmostEqual(residuals[1].value, 0.0)
def test_lstp00a_returns_zero_before_contact(self) -> None:
contact = AmesimLstp00a("contact_1", self.medium, gap0=0.001, kcont=1000.0)
contact.port_1.x = 0.0005
contact.port_2.x = 0.0
self.assertAlmostEqual(contact.gap, 0.0005)
self.assertAlmostEqual(contact.contact_force, 0.0)
def test_mecmas21_registry_rejects_fractional_choice_value(self) -> None:
with self.assertRaisesRegex(ValueError, "must be one of"):
-141
View File
@@ -10,8 +10,6 @@ from app.main import (
compile_system_xml_network,
run_system_xml_simulation,
)
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import GenericFluidSystem
from app.system_xml import validate_system_xml_document
from tests.test_amesim_pnvo001_signal_xml import signal_edge, signal_port
from tests.test_generic_system_xml_simulation import component_node, physical_edge
@@ -293,149 +291,10 @@ def force_node_mass_project(right_port_count: int = 8) -> ReactFlowProjectPayloa
class AmesimMechanicalXmlTests(unittest.TestCase):
def test_mechanical_states_use_machine_scale_absolute_tolerances(self) -> None:
system = GenericFluidSystem(
compile_reactflow_network(zero_force_mass_project())
)
tolerances = system.mechanical_state_reducer.absolute_tolerances(1.0e-8)
self.assertEqual(tolerances, [1.0e-12, 1.0e-12])
self.assertEqual(len(tolerances), len(system.initial_state_vector()))
def test_self_loop_contact_does_not_relax_mass_velocity(self) -> None:
contact_parameters = {**LSTP00A_DEFAULTS, "rcont": 1.0e11}
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-mechanical-self-contact",
nodes=[
component_node(
"mass_1",
"amesim_mecmas21",
[
mechanical_port("port_1", "left"),
mechanical_port("port_2", "right"),
],
MECMAS21_DEFAULTS,
),
component_node(
"contact_1",
"amesim_lstp00a",
[
mechanical_port("port_1", "left"),
mechanical_port("port_2", "right"),
],
contact_parameters,
),
],
edges=[
physical_edge(
"edge-1",
"mass_1",
"port_1",
"contact_1",
"port_1",
),
physical_edge(
"edge-2",
"mass_1",
"port_2",
"contact_1",
"port_2",
),
],
simulation={
"t_start": 0.0,
"t_stop": 0.02,
"step": 0.01,
"max_step": 0.005,
"method": "BDF",
},
)
system = GenericFluidSystem(compile_reactflow_network(project))
plan = system.mechanical_state_reducer.absolute_tolerance_plan(
1.0e-8,
mode="contact-aware-v1",
)
self.assertEqual(plan.values, (1.0e-12, 1.0e-12))
self.assertEqual(plan.groups[0].contacts, ())
self.assertFalse(plan.groups[0].eligible)
self.assertEqual(plan.groups[0].reason, "noFlexibleContact")
def test_weak_contact_damping_does_not_relax_velocity(self) -> None:
project = elastic_contact_project()
project.nodes[2].data.parameters["rcont"] = 1.0e-300
system = GenericFluidSystem(compile_reactflow_network(project))
plan = system.mechanical_state_reducer.absolute_tolerance_plan(
1.0e-8,
mode="contact-aware-v1",
)
self.assertTrue(plan.groups)
self.assertTrue(all(not group.eligible for group in plan.groups))
self.assertTrue(
all(group.reason == "weakContactDamping" for group in plan.groups)
)
self.assertTrue(
all(
group.velocity_atol == 1.0e-12
and group.position_atol == 1.0e-12
for group in plan.groups
)
)
def test_legacy_tolerance_ignores_extreme_contact_scales(self) -> None:
project = elastic_contact_project()
project.nodes[2].data.parameters.update(
{"kcont": 5.0e-324, "Pdis": 5.0e-324, "rcont": 1.0}
)
system = GenericFluidSystem(compile_reactflow_network(project))
legacy = system.mechanical_state_reducer.absolute_tolerance_plan(
1.0e-8,
mode="legacy",
)
contact_aware = (
system.mechanical_state_reducer.absolute_tolerance_plan(
1.0e-8,
mode="contact-aware-v1",
)
)
self.assertTrue(all(value == 1.0e-12 for value in legacy.values))
self.assertTrue(
all(group.reason == "legacyMode" for group in legacy.groups)
)
self.assertTrue(
all(
group.reason == "invalidContactScale"
for group in contact_aware.groups
)
)
def test_contact_aware_tolerance_is_limited_to_bdf(self) -> None:
system = GenericFluidSystem(
compile_reactflow_network(zero_force_mass_project())
)
result = system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=0.001,
method="RK45",
max_step=0.001,
),
sample_step=0.001,
)
self.assertTrue(result.success)
tolerance = result.diagnostics["integration"][
"mechanicalAbsoluteTolerance"
]
self.assertEqual(tolerance["mode"], "legacy")
def test_sparse_reactflow_mecmas_parameters_use_registered_defaults(self) -> None:
-98
View File
@@ -11,109 +11,11 @@ class AmesimPnch012ComponentTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = IdealGasMedium()
def test_default_create_preserves_parameterized_volume_contract(self) -> None:
chamber = COMPONENT_MODEL_REGISTRY["amesim_pnch012"].create(
"chamber_1",
self.medium,
{},
)
self.assertIsInstance(chamber, AmesimPnch012)
self.assertEqual(set(chamber.ports), {"port_1", "port_2", "port_3", "port_4"})
self.assertEqual(len(chamber.get_state_vector()), 2)
self.assertEqual(chamber.parameter_values["cvol0"], 0.015)
self.assertAlmostEqual(chamber.total_volume(), 0.015)
def test_total_volume_adds_four_external_volume_parameters(self) -> None:
chamber = AmesimPnch012(
"chamber_1",
self.medium,
cvol0=0.015,
vol1=0.001,
vol2=0.002,
vol3=0.003,
vol4=0.004,
dvol1=1.0e-6,
dvol2=2.0e-6,
dvol3=-1.0e-6,
dvol4=0.0,
)
self.assertAlmostEqual(chamber.total_volume(), 0.025)
self.assertAlmostEqual(chamber.total_volume_rate(), 2.0e-6)
def test_initial_state_uses_total_volume_pressure_and_temperature(self) -> None:
chamber = AmesimPnch012(
"chamber_1",
self.medium,
cvol0=0.015,
vol1=0.010,
p0=200000.0,
T0=300.0,
)
props = chamber.properties()
self.assertAlmostEqual(props.p, 200000.0, delta=1.0e-8)
self.assertAlmostEqual(props.T, 300.0)
self.assertAlmostEqual(chamber.port_1.p, props.p)
self.assertAlmostEqual(chamber.port_4.p, props.p)
def test_pressure_flow_residuals_bind_all_ports_to_chamber_state(self) -> None:
chamber = AmesimPnch012("chamber_1", self.medium)
chamber.properties()
residuals = {
residual.id.rsplit(":", 1)[1]: residual
for residual in chamber.pressure_flow_equation_residuals()
}
self.assertEqual(
set(residuals),
{
"port_1_pressure_state",
"port_2_pressure_state",
"port_3_pressure_state",
"port_4_pressure_state",
},
)
self.assertTrue(all(abs(residual.value) < 1.0e-9 for residual in residuals.values()))
def test_derivative_sums_four_mass_flows_and_boundary_work(self) -> None:
chamber = AmesimPnch012(
"chamber_1",
self.medium,
dvol1=1.0e-6,
kth=2.0,
sth=0.5,
extemp=310.0,
)
props = chamber.properties()
chamber.port_1.m_flow = 0.2
chamber.port_2.m_flow = -0.1
chamber.port_3.m_flow = 0.05
chamber.port_4.m_flow = 0.0
derivative = chamber.state_derivative_from_ports(
{
"port_1": props.h + 1000.0,
"port_2": props.h - 1000.0,
"port_3": props.h + 500.0,
"port_4": props.h,
}
)
self.assertAlmostEqual(derivative[0], 0.15)
self.assertLess(derivative[1], 0.2 * (props.h + 1000.0) + 0.05 * (props.h + 500.0))
def test_results_include_total_volume_and_volume_rate(self) -> None:
chamber = AmesimPnch012("chamber_1", self.medium, vol1=0.001, dvol1=1.0e-6)
values = chamber.component_result_values()
self.assertIn("vol", values)
self.assertIn("dvol", values)
self.assertAlmostEqual(values["vol"], 0.016)
self.assertAlmostEqual(values["dvol"], 1.0e-6)
if __name__ == "__main__":
-53
View File
@@ -35,61 +35,8 @@ class AmesimPnch023ComponentTests(unittest.TestCase):
{"gi": 1.5},
)
def test_initial_state_matches_requested_pressure_temperature(self) -> None:
chamber = AmesimPnch023(
"chamber_1",
self.medium,
cvol=0.057,
p0=15300000.0,
T0=293.15,
)
props = chamber.properties()
self.assertAlmostEqual(props.p, 15300000.0)
self.assertAlmostEqual(props.T, 293.15)
self.assertAlmostEqual(chamber.port_1.p, props.p)
self.assertAlmostEqual(chamber.port_2.p, props.p)
def test_two_port_derivative_conserves_mass_and_adds_heat_transfer(self) -> None:
chamber = AmesimPnch023(
"chamber_1",
self.medium,
kth=10.0,
sth=2.0,
extemp=310.0,
T0=300.0,
)
props = chamber.properties()
chamber.port_1.m_flow = 0.2
chamber.port_2.m_flow = -0.1
derivative = chamber.state_derivative_from_ports(
{
"port_1": props.h + 1000.0,
"port_2": props.h - 1000.0,
}
)
self.assertAlmostEqual(derivative[0], 0.1)
self.assertAlmostEqual(
derivative[1],
0.2 * (props.h + 1000.0) - 0.1 * props.h + 10.0 * 2.0 * 10.0,
)
def test_pressure_residuals_bind_both_ports_to_state(self) -> None:
chamber = AmesimPnch023("chamber_1", self.medium, p0=200000.0, T0=300.0)
chamber.port_1.p = 200100.0
chamber.port_2.p = 199900.0
residuals = {
residual.id.rsplit(":", 1)[1]: residual
for residual in chamber.pressure_flow_equation_residuals()
}
self.assertEqual(set(residuals), {"port_1_pressure_state", "port_2_pressure_state"})
self.assertAlmostEqual(residuals["port_1_pressure_state"].value, 100.0)
self.assertAlmostEqual(residuals["port_2_pressure_state"].value, -100.0)
if __name__ == "__main__":
-253
View File
@@ -1,253 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.examples.test_mql.primitives.pneumatic import (
HELIUM_PNEUMATIC_GAS,
AmesimPneumaticGas,
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
AmesimVariablePneumaticVolume,
cm3_to_m3,
compressible_orifice_mass_flow,
diameter_mm_to_area_m2,
kg_to_g,
liters_to_m3,
m3_to_cm3,
mm2_to_m2,
)
class AmesimPneumaticComponentsTest(unittest.TestCase):
def test_unit_conversions(self) -> None:
self.assertAlmostEqual(liters_to_m3(15.0), 0.015)
self.assertAlmostEqual(m3_to_cm3(0.015), 15000.0)
self.assertAlmostEqual(cm3_to_m3(15000.0), 0.015)
self.assertAlmostEqual(kg_to_g(1.25), 1250.0)
self.assertAlmostEqual(mm2_to_m2(78.5), 78.5e-6)
self.assertAlmostEqual(diameter_mm_to_area_m2(10.0), 7.853981633974483e-5)
def test_reference_enthalpy_uses_amesim_operating_temperature_baseline(self) -> None:
gas = AmesimPneumaticGas(cp=5193.0, cv=3116.0)
self.assertAlmostEqual(gas.specific_reference_enthalpy(298.15), 0.0)
self.assertAlmostEqual(
gas.specific_reference_enthalpy(290.2036),
5193.0 * (290.2036 - 298.15),
)
self.assertAlmostEqual(
gas.reference_temperature_from_specific_enthalpy(
gas.specific_reference_enthalpy(287.7322),
),
287.7322,
)
def test_pressure_reference_enthalpy_includes_real_gas_departure(self) -> None:
gas = AmesimPneumaticGas(cp=5193.0, cv=3116.0)
ideal_reference_h = gas.specific_reference_enthalpy(287.7322)
pressure_reference_h = gas.pressure_reference_enthalpy(13_839_965.0, 287.7322)
self.assertAlmostEqual(ideal_reference_h, -54099.6354, delta=0.001)
self.assertAlmostEqual(
pressure_reference_h - ideal_reference_h,
12762.690087540526,
delta=1.0e-4,
)
def test_pressure_transport_enthalpy_restores_absolute_energy_offset(self) -> None:
gas = HELIUM_PNEUMATIC_GAS
pressure = 13_839_965.0
temperature = 287.7322
self.assertAlmostEqual(
gas.pressure_transport_enthalpy(pressure, temperature),
gas.pressure_reference_enthalpy(pressure, temperature)
+ gas.cp * 298.15,
)
def test_reference_temperature_from_enthalpy_rejects_non_positive_cp(self) -> None:
gas = AmesimPneumaticGas(cp=0.0, cv=3116.0)
with self.assertRaisesRegex(ValueError, "cp must be positive"):
gas.reference_temperature_from_specific_enthalpy(42.0)
def test_volume_initial_state_matches_requested_pressure_temperature(self) -> None:
volume = AmesimPneumaticVolume.from_liters(
name="pn_general_chamber",
volume_liters=57.0,
p0=15.3e6,
T0=293.15,
)
props = volume.properties()
self.assertAlmostEqual(props.p, 15.3e6, delta=15.3e6 * 1.0e-12)
self.assertAlmostEqual(props.T, 293.15)
self.assertGreater(props.rho, 20.0)
self.assertLess(props.rho, 30.0)
def test_volume_reports_amesim_style_observables(self) -> None:
volume = AmesimPneumaticVolume.from_liters(
name="pn_general_chamber",
volume_liters=57.0,
p0=15.3e6,
T0=293.15,
)
self.assertAlmostEqual(volume.volume_cm3(), 57000.0)
self.assertAlmostEqual(
volume.pressure_gauge_pa(reference_pressure_pa=101_300.0),
15198700.0,
)
self.assertGreater(volume.gas_mass_g(), 1300.0)
def test_volume_balances_two_connections_with_stream_enthalpy(self) -> None:
volume = AmesimPneumaticVolume.from_liters(
name="pn_general_chamber",
volume_liters=57.0,
p0=15.3e6,
T0=293.15,
)
internal_h = volume.properties().h
derivative = volume.derivatives_from_two_connections(
port_a_m_flow=0.2,
connected_h_a=internal_h + 1000.0,
port_b_m_flow=-0.1,
connected_h_b=internal_h - 1000.0,
internal_h=internal_h,
)
self.assertAlmostEqual(derivative.m, 0.1)
self.assertAlmostEqual(
derivative.U,
0.2 * (internal_h + 1000.0) - 0.1 * internal_h,
)
self.assertAlmostEqual(volume.port_a.p, volume.port_b.p)
def test_volume_applies_amesim_heat_exchange_to_energy_derivative(self) -> None:
volume = AmesimPneumaticVolume.from_liters(
name="heated_chamber",
volume_liters=15.0,
p0=100000.0,
T0=300.0,
heat_transfer_coefficient=1500.0,
heat_transfer_area=0.7,
external_temperature_k=293.15,
)
props = volume.properties()
derivative = volume.derivatives_from_two_connections(
port_a_m_flow=0.0,
connected_h_a=props.h,
port_b_m_flow=0.0,
connected_h_b=props.h,
internal_h=props.h,
)
self.assertAlmostEqual(
derivative.U,
1500.0 * 0.7 * (293.15 - 300.0),
)
def test_variable_volume_tracks_external_volume(self) -> None:
volume = AmesimVariablePneumaticVolume.from_liters(
name="pn_c1_8",
dead_volume_liters=15.0,
p0=100000.0,
T0=293.15,
)
self.assertAlmostEqual(volume.volume_cm3(), 15000.0)
volume.set_external_volume_m3(cm3_to_m3(34242.54636512914))
self.assertAlmostEqual(volume.volume_cm3(), 49242.54636512914)
def test_variable_volume_boundary_work_uses_external_volume_rate(self) -> None:
volume = AmesimVariablePneumaticVolume.from_liters(
name="pn_c1_8",
dead_volume_liters=15.0,
p0=100000.0,
T0=293.15,
)
props = volume.properties()
volume.set_external_volume_m3(0.0, external_volume_rate_m3_s=2.0e-6)
derivative = volume.derivatives_from_two_connections(
port_a_m_flow=0.0,
connected_h_a=props.h,
port_b_m_flow=0.0,
connected_h_b=props.h,
internal_h=props.h,
)
self.assertAlmostEqual(derivative.m, 0.0)
self.assertAlmostEqual(derivative.U, -props.p * 2.0e-6)
def test_orifice_effective_area_clamps_opening(self) -> None:
orifice = AmesimPneumaticOrifice.from_mm2(
name="pn_variable_orifice",
area_mm2=78.5,
opening=1.4,
)
self.assertAlmostEqual(orifice.effective_area, 78.5e-6)
orifice.opening = -0.25
self.assertAlmostEqual(orifice.effective_area, 0.0)
def test_orifice_returns_signed_mass_flow(self) -> None:
orifice = AmesimPneumaticOrifice.from_mm2(
name="pn_orifice_18",
area_mm2=78.5,
flow_coefficient=0.9,
)
forward = orifice.mass_flow(15.3e6, 1.0e6, 293.15)
reverse = orifice.mass_flow(1.0e6, 15.3e6, 293.15)
self.assertGreater(forward, 0.0)
self.assertAlmostEqual(reverse, -forward)
self.assertEqual(orifice.mass_flow(1.0e6, 1.0e6, 293.15), 0.0)
def test_choked_flow_is_independent_of_lower_downstream_pressure(self) -> None:
base = compressible_orifice_mass_flow(
upstream_pressure=15.3e6,
downstream_pressure=1.0e6,
upstream_temperature=293.15,
area=78.5e-6,
flow_coefficient=0.9,
gas=HELIUM_PNEUMATIC_GAS,
)
lower_back_pressure = compressible_orifice_mass_flow(
upstream_pressure=15.3e6,
downstream_pressure=0.1e6,
upstream_temperature=293.15,
area=78.5e-6,
flow_coefficient=0.9,
gas=HELIUM_PNEUMATIC_GAS,
)
self.assertGreater(base, 0.0)
self.assertAlmostEqual(lower_back_pressure, base)
def test_subcritical_flow_decreases_as_back_pressure_rises(self) -> None:
low_back_pressure = compressible_orifice_mass_flow(
upstream_pressure=1.0e6,
downstream_pressure=0.6e6,
upstream_temperature=293.15,
area=78.5e-6,
flow_coefficient=0.9,
)
high_back_pressure = compressible_orifice_mass_flow(
upstream_pressure=1.0e6,
downstream_pressure=0.9e6,
upstream_temperature=293.15,
area=78.5e-6,
flow_coefficient=0.9,
)
self.assertGreater(low_back_pressure, high_back_pressure)
self.assertGreater(high_back_pressure, 0.0)
if __name__ == "__main__":
unittest.main()
@@ -1,157 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.examples.test_mql.primitives.pneumatic_lines import (
AmesimPnl0002Pipe,
AmesimPnl0003Pipe,
AmesimPnl00rPipe,
)
class AmesimPnl0002PipeTests(unittest.TestCase):
def setUp(self) -> None:
self.pipe = AmesimPnl0002Pipe(
name="pneumatic_86",
diameter_mm=20.0,
length_m=2.0,
relative_roughness=0.045 / 20.0,
pctr_0=100_000.0,
Tctr_0=293.15,
)
def test_initial_state_uses_center_compliance(self) -> None:
properties = self.pipe.properties()
self.assertAlmostEqual(self.pipe.volume, 6.283185307179586e-4)
self.assertEqual(len(self.pipe.get_state_vector()), 2)
self.assertAlmostEqual(properties.p, 100_000.0, delta=1.0e-6)
self.assertAlmostEqual(properties.T, 293.15)
self.assertAlmostEqual(self.pipe.port_1.p, properties.p)
self.assertAlmostEqual(self.pipe.port_2.p, properties.p)
def test_port_flow_enters_center_from_higher_external_pressure(self) -> None:
forward = self.pipe.port_mass_flow(
port_pressure_pa=101_000.0,
port_temperature_k=293.15,
)
reverse = self.pipe.port_mass_flow(
port_pressure_pa=99_000.0,
port_temperature_k=293.15,
)
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
def test_connection_derivatives_conserve_two_external_port_flows(self) -> None:
properties = self.pipe.properties()
derivative = self.pipe.derivatives_from_connections(
port_1_m_flow=0.2,
connected_h_1=properties.h + 1000.0,
port_2_m_flow=-0.1,
connected_h_2=properties.h - 1000.0,
)
self.assertAlmostEqual(derivative.m, 0.1)
class AmesimPnl0003PipeTests(unittest.TestCase):
def setUp(self) -> None:
self.pipe = AmesimPnl0003Pipe(
name="pneumatic_88",
diameter_mm=20.0,
length_m=0.3,
relative_roughness=0.045 / 20.0,
p1_0=15.3e6,
p2_0=15.3e6,
T1_0=293.15,
T2_0=293.15,
)
def test_initial_state_uses_two_half_volume_compliances(self) -> None:
port_1 = self.pipe.properties_1()
port_2 = self.pipe.properties_2()
self.assertAlmostEqual(self.pipe.volume, 9.424777960769381e-5)
self.assertAlmostEqual(self.pipe.compliance_volume, self.pipe.volume / 2.0)
self.assertEqual(len(self.pipe.get_state_vector()), 4)
self.assertAlmostEqual(port_1.p, 15.3e6, delta=1.0e-5)
self.assertAlmostEqual(port_2.p, 15.3e6, delta=1.0e-5)
self.assertAlmostEqual(port_1.T, 293.15)
self.assertAlmostEqual(port_2.T, 293.15)
def test_center_resistance_flow_follows_end_pressure_gradient(self) -> None:
state = self.pipe.get_state_vector()
state[0] *= 1.01
state[1] *= 1.01
self.pipe.set_state_vector(state)
forward = self.pipe.resistance_mass_flow()
state[0] /= 1.01 * 1.01
state[1] /= 1.01 * 1.01
state[2] *= 1.01
state[3] *= 1.01
self.pipe.set_state_vector(state)
reverse = self.pipe.resistance_mass_flow()
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
def test_connection_derivatives_conserve_internal_center_flow_mass(self) -> None:
state = self.pipe.get_state_vector()
state[0] *= 1.01
state[1] *= 1.01
self.pipe.set_state_vector(state)
d1, d2 = self.pipe.derivatives_from_connections(
port_1_m_flow=0.2,
connected_h_1=self.pipe.properties_1().h + 1000.0,
port_2_m_flow=-0.1,
connected_h_2=self.pipe.properties_2().h - 1000.0,
)
self.assertAlmostEqual(d1.m + d2.m, 0.1)
class AmesimPnl00rPipeTests(unittest.TestCase):
def setUp(self) -> None:
self.pipe = AmesimPnl00rPipe(
name="pneumatic_100",
diameter_mm=14.0,
length_m=1.0,
relative_roughness=0.045 / 14.0,
)
def test_stateless_resistance_flow_follows_pressure_gradient(self) -> None:
forward = self.pipe.mass_flow(
port_1_pressure_pa=15.31e6,
port_1_temperature_k=293.15,
port_2_pressure_pa=15.29e6,
port_2_temperature_k=293.15,
)
reverse = self.pipe.mass_flow(
port_1_pressure_pa=15.29e6,
port_1_temperature_k=293.15,
port_2_pressure_pa=15.31e6,
port_2_temperature_k=293.15,
)
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.01)
def test_diagnostics_expose_darcy_terms(self) -> None:
diagnostics = self.pipe.diagnostics(
mass_flow_kg_s=1.0e-4,
pressure_pa=15.3e6,
temperature_k=293.15,
)
self.assertGreater(diagnostics.reynolds_number, 0.0)
self.assertGreater(diagnostics.gas_velocity_m_s, 0.0)
self.assertGreater(diagnostics.pressure_drop_pa, 0.0)
if __name__ == "__main__":
unittest.main()
@@ -27,132 +27,10 @@ class AmesimPneumaticNodeComponentTests(unittest.TestCase):
)
self.assertEqual(node.parameter_values, {})
def test_pn3node2_residuals_use_port_2_as_pressure_reference(self) -> None:
node = AmesimPn3Node2("pn3_1")
node.port_1.p = 101000.0
node.port_2.p = 100000.0
node.port_3.p = 99000.0
node.port_1.m_flow = 0.2
node.port_2.m_flow = -0.3
node.port_3.m_flow = 0.1
residuals = {residual.id.rsplit(":", 1)[1]: residual for residual in node.pressure_flow_equation_residuals()}
self.assertEqual(set(residuals), {"port_1_pressure_reference", "port_3_pressure_reference", "mass_flow_balance"})
self.assertEqual(residuals["port_1_pressure_reference"].value, 1000.0)
self.assertEqual(residuals["port_3_pressure_reference"].value, -1000.0)
self.assertAlmostEqual(residuals["mass_flow_balance"].value, 0.0)
def test_p4node2_residuals_cover_four_ports(self) -> None:
node = AmesimP4Node2("p4_1")
node.port_1.p = 100000.0
node.port_2.p = 100000.0
node.port_3.p = 100000.0
node.port_4.p = 100000.0
node.port_1.m_flow = 0.1
node.port_2.m_flow = 0.2
node.port_3.m_flow = -0.4
node.port_4.m_flow = 0.1
residuals = node.pressure_flow_equation_residuals()
self.assertEqual(len(residuals), 4)
self.assertTrue(all(residual.value == 0.0 for residual in residuals))
self.assertEqual(
residuals[-1].variables,
(
"p4_1.port_1.m_flow",
"p4_1.port_2.m_flow",
"p4_1.port_3.m_flow",
"p4_1.port_4.m_flow",
),
)
def test_node_uses_port_2_temperature_for_non_reference_outlets(self) -> None:
node = AmesimP4Node2("p4_1")
node.port_1.m_flow = 0.25
node.port_2.m_flow = 0.75
node.port_3.m_flow = -0.5
node.port_4.m_flow = -0.5
node.update_stream_outflows(
{
"port_1": 100.0,
"port_2": 300.0,
"port_3": 900.0,
"port_4": 1200.0,
}
)
self.assertEqual(node.temperature_reference_h, 300.0)
self.assertEqual(node.port_1.h_outflow, 300.0)
self.assertEqual(node.port_2.h_outflow, 250.0)
self.assertEqual(node.port_3.h_outflow, 300.0)
self.assertEqual(node.port_4.h_outflow, 300.0)
def test_port_2_outlet_closes_node_energy_balance(self) -> None:
node = AmesimP4Node2("p4_1")
node.port_1.m_flow = -0.01823
node.port_2.m_flow = -2.56077
node.port_3.m_flow = 2.579
node.port_4.m_flow = 0.0
node.update_stream_outflows(
{
"port_1": 2_630_000.0,
"port_2": 140_900.0,
"port_3": 63_960.0,
"port_4": 0.0,
}
)
self.assertEqual(node.port_1.h_outflow, 140_900.0)
energy_flow = sum(
port.m_flow
* (
{
"port_1": 2_630_000.0,
"port_2": 140_900.0,
"port_3": 63_960.0,
"port_4": 0.0,
}[name]
if port.m_flow > 0.0
else port.h_outflow
)
for name, port in node.ports.items()
)
self.assertAlmostEqual(energy_flow, 0.0, places=10)
def test_port_2_outflow_enthalpy_remains_finite_through_flow_reversal(self) -> None:
node = AmesimP4Node2("p4_1")
connected_h = {
"port_1": 300.0,
"port_2": 300.0,
"port_3": 200.0,
"port_4": 100.0,
}
outflow_enthalpies: list[float] = []
for reference_flow in (-1.0e-6, 0.0, 1.0e-6):
node.port_1.m_flow = -1.0
node.port_2.m_flow = reference_flow
node.port_3.m_flow = -reference_flow
node.port_4.m_flow = 1.0
node.update_stream_outflows(connected_h)
self.assertAlmostEqual(
sum(port.m_flow for port in node.ports.values()),
0.0,
places=15,
)
outflow_enthalpies.append(node.port_2.h_outflow)
self.assertTrue(all(abs(value) < 1.0e4 for value in outflow_enthalpies))
self.assertLess(
max(outflow_enthalpies) - min(outflow_enthalpies),
1.0,
)
if __name__ == "__main__":
-325
View File
@@ -13,60 +13,14 @@ from app.simulation.core.medium import IdealGasMedium
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
class _ScaledReynoldsPnl0001(AmesimPnl0001):
def __init__(self, *args, reynolds_scale: float, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.reynolds_scale = float(reynolds_scale)
self.reynolds_calls: list[tuple[float, float]] = []
def reynolds_number(self, mass_flow: float, temperature: float) -> float:
self.reynolds_calls.append((mass_flow, temperature))
return self.reynolds_scale * super().reynolds_number(
mass_flow,
temperature,
)
class _DelegatingFrictionPnl0001(AmesimPnl0001):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.friction_reynolds: list[float] = []
def friction_factor(self, reynolds_number: float) -> float:
self.friction_reynolds.append(reynolds_number)
return super().friction_factor(reynolds_number)
class AmesimPnl0001ComponentTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = IdealGasMedium()
def test_default_create_preserves_amesim_parameter_contract(self) -> None:
pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0001"].create(
"pnl_1",
self.medium,
{},
)
self.assertIsInstance(pipe, AmesimPnl0001)
self.assertEqual(set(pipe.ports), {"port_1", "port_2"})
self.assertEqual(
set(pipe.parameter_values),
{
"diam",
"le",
"rr",
"k",
"kth",
"extemp",
"gi",
"mode",
"p0",
"T0",
},
)
self.assertEqual(len(pipe.get_state_vector()), 2)
self.assertAlmostEqual(pipe.volume, 7.853981633974483e-5)
def test_rejects_fractional_integer_parameters(self) -> None:
with self.assertRaisesRegex(ValueError, "gi must be an integer"):
@@ -82,298 +36,19 @@ class AmesimPnl0001ComponentTests(unittest.TestCase):
{"mode": 1.5},
)
def test_initial_state_uses_pipe_volume_pressure_and_temperature(self) -> None:
pipe = AmesimPnl0001(
"pnl_1",
self.medium,
diam=0.014,
le=1.0,
rr=0.045 / 14.0,
p0=15.3e6,
T0=293.15,
)
props = pipe.properties()
self.assertAlmostEqual(pipe.volume, 1.539380400258999e-4)
self.assertAlmostEqual(props.p, 15.3e6, delta=1.0e-5)
self.assertAlmostEqual(props.T, 293.15)
self.assertAlmostEqual(pipe.port_2.p, props.p)
def test_resistance_flow_follows_pressure_gradient(self) -> None:
pipe = AmesimPnl0001(
"pnl_1",
self.medium,
diam=0.014,
le=1.0,
rr=0.045 / 14.0,
p0=15.3e6,
T0=293.15,
)
props = pipe.properties()
forward = pipe.mass_flow(15.31e6, props.p, props.T)
reverse = pipe.mass_flow(15.29e6, props.p, props.T)
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.02)
def test_high_pressure_near_equal_flow_is_continuous_outside_roundoff(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium, p0=15.3e6)
small = pipe.mass_flow(15.3e6 + 0.1, 15.3e6, 293.15)
larger = pipe.mass_flow(15.3e6 + 1.0, 15.3e6, 293.15)
self.assertGreater(small, 0.0)
self.assertGreater(larger, small)
def test_pressure_roundoff_does_not_create_a_fictitious_flow(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium, p0=15.3e6)
self.assertEqual(pipe.mass_flow(15.3e6, 15.3e6 + 2.0e-9, 293.15), 0.0)
def test_overridden_reynolds_number_is_called_on_every_iteration(self) -> None:
slow_reynolds = _ScaledReynoldsPnl0001(
"slow_reynolds",
self.medium,
reynolds_scale=0.25,
)
fast_reynolds = _ScaledReynoldsPnl0001(
"fast_reynolds",
self.medium,
reynolds_scale=4.0,
)
options = {
"upstream_pressure": 2.0e6,
"downstream_pressure": 1.0e6,
"upstream_temperature": 300.0,
"resistance_length": 1.0,
"max_iterations": 4,
}
slow_flow = slow_reynolds._one_way_pn2pipefr_mass_flow(**options)
fast_flow = fast_reynolds._one_way_pn2pipefr_mass_flow(**options)
self.assertEqual(len(slow_reynolds.reynolds_calls), 4)
self.assertEqual(len(fast_reynolds.reynolds_calls), 4)
self.assertTrue(
all(
temperature == 300.0
for _mass_flow, temperature in slow_reynolds.reynolds_calls
)
)
self.assertLess(slow_flow, fast_flow)
def test_zero_iterations_does_not_evaluate_dynamic_viscosity(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium)
original = IdealGasMedium.dynamic_viscosity
with (
patch.object(
IdealGasMedium,
"dynamic_viscosity",
autospec=True,
side_effect=original,
) as dynamic_viscosity,
patch(
"app.simulation.components.amesim.flow.pipes.log10",
side_effect=log10,
) as logarithm,
):
flow = pipe._one_way_pn2pipefr_mass_flow(
upstream_pressure=2.0e6,
downstream_pressure=1.0e6,
upstream_temperature=300.0,
resistance_length=1.0,
max_iterations=0,
)
self.assertGreater(flow, 0.0)
dynamic_viscosity.assert_not_called()
logarithm.assert_not_called()
def test_builtin_iterations_evaluate_dynamic_viscosity_once(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium)
original = IdealGasMedium.dynamic_viscosity
with patch.object(
IdealGasMedium,
"dynamic_viscosity",
autospec=True,
side_effect=original,
) as dynamic_viscosity:
flow = pipe._one_way_pn2pipefr_mass_flow(
upstream_pressure=2.0e6,
downstream_pressure=1.0e6,
upstream_temperature=300.0,
resistance_length=1.0,
max_iterations=4,
)
self.assertGreater(flow, 0.0)
dynamic_viscosity.assert_called_once_with(self.medium, 300.0)
def test_builtin_fixed_point_evaluates_fully_rough_term_once(self) -> None:
options = {
"upstream_pressure": 2.0e6,
"downstream_pressure": 1.0e6,
"upstream_temperature": 300.0,
"resistance_length": 1.0,
"max_iterations": 4,
}
pipes = (
AmesimPnl00r("pnl_00r", self.medium, rr=1.0e-5),
AmesimPnl0001("pnl_0001", self.medium, rr=1.0e-5),
AmesimPnl0002("pnl_0002", self.medium, rr=1.0e-5),
)
for pipe in pipes:
with self.subTest(model=type(pipe).__name__):
with patch(
"app.simulation.components.amesim.flow.pipes.log10",
side_effect=log10,
) as logarithm:
flow = AmesimPnl0001._one_way_pn2pipefr_mass_flow(
pipe,
**options,
)
self.assertGreater(flow, 0.0)
fully_rough_argument = pipe.rr / 3.7
self.assertEqual(
sum(
call.args == (fully_rough_argument,)
for call in logarithm.call_args_list
),
1,
)
self.assertEqual(logarithm.call_count, 5)
def test_overridden_friction_factor_keeps_virtual_iteration_path(self) -> None:
pipe = _DelegatingFrictionPnl0001(
"custom_friction",
self.medium,
rr=1.0e-5,
)
with patch(
"app.simulation.components.amesim.flow.pipes.log10",
side_effect=log10,
) as logarithm:
flow = pipe._one_way_pn2pipefr_mass_flow(
upstream_pressure=2.0e6,
downstream_pressure=1.0e6,
upstream_temperature=300.0,
resistance_length=1.0,
max_iterations=4,
)
self.assertGreater(flow, 0.0)
self.assertEqual(len(pipe.friction_reynolds), 4)
self.assertEqual(logarithm.call_count, 8)
def test_precomputed_fully_rough_path_is_bitwise_identical_by_regime(
self,
) -> None:
for index, downstream_pressure in enumerate(
(1_999_999.999, 1_999_990.0, 1_999_900.0, 1.0e6)
):
with self.subTest(downstream_pressure=downstream_pressure):
built_in = AmesimPnl0001(
f"built_in_{index}",
self.medium,
rr=1.0e-5,
)
virtual = _DelegatingFrictionPnl0001(
f"virtual_{index}",
self.medium,
rr=1.0e-5,
)
options = {
"upstream_pressure": 2.0e6,
"downstream_pressure": downstream_pressure,
"upstream_temperature": 300.0,
"resistance_length": 1.0,
"max_iterations": 4,
}
actual = built_in._one_way_pn2pipefr_mass_flow(**options)
expected = virtual._one_way_pn2pipefr_mass_flow(**options)
self.assertEqual(actual.hex(), expected.hex())
def test_pressure_flow_residuals_do_not_force_storage_mass_balance(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium)
pipe.port_1.p = 101000.0
pipe.port_2.m_flow = -1.0e-4
props = pipe.properties()
pipe.port_1.m_flow = pipe.mass_flow(pipe.port_1.p, pipe.port_2.p, props.T)
residuals = {
residual.id.rsplit(":", 1)[1]: residual
for residual in pipe.pressure_flow_equation_residuals()
}
self.assertEqual(
set(residuals),
{"port_2_pressure_state", "port_1_pressure_flow_relation"},
)
self.assertAlmostEqual(residuals["port_2_pressure_state"].value, 0.0)
self.assertAlmostEqual(residuals["port_1_pressure_flow_relation"].value, 0.0)
def test_connection_derivative_preserves_two_port_accumulation(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium, kth=2.0, extemp=310.0)
props = pipe.properties()
pipe.port_1.m_flow = 0.2
pipe.port_2.m_flow = -0.1
derivative = pipe.state_derivative_from_ports(
{
"port_1": props.h + 1000.0,
"port_2": props.h - 1000.0,
}
)
self.assertAlmostEqual(derivative[0], 0.1)
self.assertGreater(derivative[1], 0.0)
def test_results_include_thermodynamic_and_pipe_diagnostics(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium)
pipe.port_1.p = 101000.0
values = pipe.component_result_values()
self.assertEqual(
set(values),
{"m", "U", "p", "T", "rho", "u", "h", "re", "cm", "v", "ff"},
)
def test_result_cm_excludes_pipe_flow_coefficient(self) -> None:
pipe = AmesimPnl0001(
"pnl_1",
self.medium,
diam=0.014,
le=0.3,
rr=0.045 / 14.0,
p0=2.5e6,
T0=290.0,
)
pipe.port_1.p = 1.6e6
props = pipe.properties()
flow = pipe.mass_flow(pipe.port_1.p, props.p, props.T)
reynolds = pipe.reynolds_number(flow, props.T)
friction = pipe.friction_factor(reynolds)
raw_cq_cm = (
abs(flow)
* sqrt(props.T)
/ (pipe.area * max(pipe.port_1.p, props.p))
)
flow_coefficient = sqrt(pipe.diam / (pipe.le * friction))
actual = pipe.component_result_values()["cm"]
self.assertAlmostEqual(actual, raw_cq_cm / flow_coefficient)
self.assertNotAlmostEqual(actual, raw_cq_cm)
if __name__ == "__main__":
-147
View File
@@ -1,147 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.examples.test_mql.primitives.pneumatic_lines import AmesimPnl0001Pipe
class AmesimPnl0001PipeTests(unittest.TestCase):
def setUp(self) -> None:
self.pipe = AmesimPnl0001Pipe(
name="pneumatic_96",
diameter_mm=14.0,
length_m=1.0,
relative_roughness=0.045 / 14.0,
p0=15.3e6,
T0=293.15,
)
def test_initial_state_uses_real_pipe_volume_and_two_states(self) -> None:
properties = self.pipe.properties()
self.assertAlmostEqual(self.pipe.volume, 1.539380400258999e-4)
self.assertEqual(len(self.pipe.get_state_vector()), 2)
self.assertAlmostEqual(properties.p, 15.3e6, delta=1.0e-5)
self.assertAlmostEqual(properties.T, 293.15)
self.assertAlmostEqual(
self.pipe.gas_mass_g(),
self.pipe.gas.density(15.3e6, 293.15) * self.pipe.volume * 1000.0,
places=10,
)
def test_resistance_flow_follows_pressure_gradient(self) -> None:
forward = self.pipe.resistance_mass_flow(
port_1_pressure_pa=15.31e6,
port_1_temperature_k=293.15,
)
reverse = self.pipe.resistance_mass_flow(
port_1_pressure_pa=15.29e6,
port_1_temperature_k=293.15,
)
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.01)
def test_pn2pipefr_candidate_flow_follows_pressure_gradient(self) -> None:
forward = self.pipe.pn2pipefr_mass_flow(
port_1_pressure_pa=15.31e6,
port_1_temperature_k=293.15,
port_2_pressure_pa=15.3e6,
port_2_temperature_k=293.15,
)
reverse = self.pipe.pn2pipefr_mass_flow(
port_1_pressure_pa=15.29e6,
port_1_temperature_k=293.15,
port_2_pressure_pa=15.3e6,
port_2_temperature_k=293.15,
)
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.01)
def test_calibrated_linear_conductance_overrides_darcy_flow(self) -> None:
pipe = AmesimPnl0001Pipe(
name="linear",
diameter_mm=20.0,
length_m=1.0,
relative_roughness=0.045 / 20.0,
calibrated_linear_conductance=5.5636e-6,
p0=200000.0,
T0=293.15,
)
flow = pipe.resistance_mass_flow(
port_1_pressure_pa=180000.0,
port_1_temperature_k=293.15,
)
self.assertAlmostEqual(
flow,
5.5636e-6 * (180000.0 - 200000.0) / (293.15 ** 0.5),
)
def test_diagnostics_reproduce_laminar_friction_contract(self) -> None:
diagnostics = self.pipe.diagnostics(mass_flow_kg_s=8.90603914774626e-6)
self.assertGreater(diagnostics.reynolds_number, 40.0)
self.assertLess(diagnostics.reynolds_number, 45.0)
self.assertAlmostEqual(
diagnostics.friction_factor,
64.0 / diagnostics.reynolds_number,
)
self.assertGreater(diagnostics.gas_velocity_m_s, 0.0)
self.assertGreater(diagnostics.pressure_drop_pa, 0.0)
def test_darcy_pressure_drop_for_state_uses_supplied_density_state(self) -> None:
diagnostics = self.pipe.diagnostics(mass_flow_kg_s=8.90603914774626e-6)
pressure_drop = self.pipe.darcy_pressure_drop_for_state(
mass_flow_kg_s=8.90603914774626e-6,
pressure_pa=self.pipe.properties().p,
temperature_k=self.pipe.properties().T,
)
self.assertAlmostEqual(pressure_drop, diagnostics.pressure_drop_pa)
def test_connection_derivative_preserves_mass_and_stream_direction(self) -> None:
internal = self.pipe.properties()
derivative = self.pipe.derivatives_from_connections(
port_1_m_flow=0.2,
connected_h_1=internal.h + 1000.0,
port_2_m_flow=-0.1,
connected_h_2=internal.h - 1000.0,
)
self.assertAlmostEqual(derivative.m, 0.1)
self.assertAlmostEqual(
derivative.U,
0.2 * (internal.h + 1000.0) / self.pipe.gas.gamma
- 0.1 * internal.u,
)
def test_transport_enthalpy_derivative_preserves_pn2vol_energy_basis(self) -> None:
internal = self.pipe.properties()
derivative = self.pipe.derivatives_from_transport_enthalpy_connections(
port_1_m_flow=0.2,
connected_h_1=internal.h + 1000.0,
port_2_m_flow=-0.1,
connected_h_2=internal.h - 1000.0,
)
self.assertAlmostEqual(derivative.m, 0.1)
self.assertAlmostEqual(
derivative.U,
0.2 * (internal.h + 1000.0) - 0.1 * internal.h,
)
temperature_derivative = (
derivative.U - internal.u * derivative.m
) / (self.pipe.state.m * self.pipe.gas.cv)
expected_temperature_derivative = (
0.2 * (internal.h + 1000.0 - internal.u)
- 0.1 * (internal.h - internal.u)
) / (self.pipe.state.m * self.pipe.gas.cv)
self.assertAlmostEqual(temperature_derivative, expected_temperature_derivative)
if __name__ == "__main__":
unittest.main()
@@ -15,512 +15,33 @@ class AmesimPnl0002ComponentTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = IdealGasMedium()
def test_default_create_preserves_contract(self) -> None:
pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0002"].create("pnl_2", self.medium, {})
self.assertIsInstance(pipe, AmesimPnl0002)
self.assertEqual(set(pipe.ports), {"port_1", "port_2"})
self.assertEqual(len(pipe.get_state_vector()), 2)
self.assertAlmostEqual(pipe.resistance_length, pipe.le / 2.0)
def test_center_compliance_initial_state(self) -> None:
pipe = AmesimPnl0002(
"pnl_2",
self.medium,
diam=0.02,
le=2.0,
rr=0.045 / 20.0,
p0=100000.0,
T0=293.15,
)
props = pipe.properties()
self.assertAlmostEqual(pipe.volume, 6.283185307179586e-4)
self.assertAlmostEqual(props.p, 100000.0, delta=1.0e-6)
self.assertAlmostEqual(props.T, 293.15)
def test_port_flows_enter_center_from_higher_external_pressure(self) -> None:
pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=293.15)
center = pipe.properties()
forward = pipe.port_mass_flow(101000.0, center.p, center.T)
reverse = pipe.port_mass_flow(99000.0, center.p, center.T)
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
def test_exact_repeated_pipe_flow_inversion_is_cached(self) -> None:
pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=293.15)
pipe._one_way_pn2pipefr_mass_flow.cache_clear()
first = pipe.mass_flow(15.3e6, 10.0e6, 293.15)
after_first = pipe._one_way_pn2pipefr_mass_flow.cache_info()
second = pipe.mass_flow(15.3e6, 10.0e6, 293.15)
after_second = pipe._one_way_pn2pipefr_mass_flow.cache_info()
self.assertEqual(second, first)
self.assertEqual(after_first.misses, 1)
self.assertEqual(after_second.misses, 1)
self.assertEqual(after_second.hits, 1)
def test_external_upstream_flow_uses_connected_stream_temperature(self) -> None:
pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=350.0)
center = pipe.properties()
external_pressure = 15.3e6
external_temperature = 293.15
external_h = self.medium.specific_enthalpy_at_pressure(
external_pressure,
external_temperature,
)
pipe.update_stream_outflows(
{"port_1": external_h, "port_2": center.h}
)
flow = pipe.port_mass_flow(
external_pressure,
center.p,
center.T,
port_name="port_1",
)
expected = pipe.mass_flow(
external_pressure,
center.p,
external_temperature,
)
center_temperature_flow = pipe.mass_flow(
external_pressure,
center.p,
center.T,
)
self.assertAlmostEqual(flow, expected)
self.assertNotAlmostEqual(flow, center_temperature_flow)
def test_fully_rough_limit_matches_nikuradse_colebrook_asymptote(self) -> None:
relative_roughness = 0.045 / 20.0
pipe = AmesimPnl0002(
"pnl_2",
self.medium,
diam=0.02,
le=2.0,
rr=relative_roughness,
)
expected = 1.0 / (-2.0 * log10(relative_roughness / 3.7)) ** 2
self.assertAlmostEqual(
pipe.friction_factor(1.0e12),
expected,
delta=1.0e-10,
)
def test_missing_stream_cache_preserves_center_temperature_fallback(self) -> None:
pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=350.0)
center = pipe.properties()
external_pressure = 15.3e6
flow = pipe.port_mass_flow(
external_pressure,
center.p,
center.T,
port_name="port_1",
)
self.assertAlmostEqual(
flow,
pipe.mass_flow(external_pressure, center.p, center.T),
)
def test_center_upstream_flow_keeps_center_temperature(self) -> None:
pipe = AmesimPnl0002("pnl_2", self.medium, p0=15.3e6, T0=350.0)
center = pipe.properties()
external_pressure = 1.0e5
external_h = self.medium.specific_enthalpy_at_pressure(
external_pressure,
293.15,
)
pipe.update_stream_outflows(
{"port_1": external_h, "port_2": center.h}
)
flow = pipe.port_mass_flow(
external_pressure,
center.p,
center.T,
port_name="port_1",
)
self.assertAlmostEqual(
flow,
pipe.mass_flow(external_pressure, center.p, center.T),
)
def test_pressure_flow_residuals_use_two_port_resistances(self) -> None:
pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=293.15)
center = pipe.properties()
pipe.port_1.p = 101000.0
pipe.port_2.p = 99000.0
pipe.port_1.m_flow = pipe.port_mass_flow(pipe.port_1.p, center.p, center.T)
pipe.port_2.m_flow = pipe.port_mass_flow(pipe.port_2.p, center.p, center.T)
residuals = {
residual.id.rsplit(":", 1)[1]: residual
for residual in pipe.pressure_flow_equation_residuals()
}
self.assertEqual(
set(residuals),
{"port_1_pressure_flow_relation", "port_2_pressure_flow_relation"},
)
self.assertAlmostEqual(residuals["port_1_pressure_flow_relation"].value, 0.0)
self.assertAlmostEqual(residuals["port_2_pressure_flow_relation"].value, 0.0)
def test_connection_derivative_accumulates_two_external_flows(self) -> None:
pipe = AmesimPnl0002("pnl_2", self.medium)
props = pipe.properties()
pipe.port_1.m_flow = 0.2
pipe.port_2.m_flow = -0.1
derivative = pipe.state_derivative_from_ports(
{"port_1": props.h + 1000.0, "port_2": props.h - 1000.0}
)
self.assertAlmostEqual(derivative[0], 0.1)
def test_connection_derivative_uses_energy_balanced_node_enthalpy(self) -> None:
pipe = AmesimPnl0002("pnl_2", self.medium)
props = pipe.properties()
pipe.port_1.m_flow = 0.2
pipe.port_2.m_flow = 0.1
reference_h = props.h + 10_000.0
pipe.update_flow_temperature_references(
{"port_1": reference_h, "port_2": reference_h}
)
derivative = pipe.state_derivative_from_ports(
{"port_1": props.h, "port_2": props.h}
)
self.assertAlmostEqual(derivative[0], 0.3)
self.assertAlmostEqual(derivative[1], 0.3 * props.h)
def test_result_cm_averages_bare_parameter_for_each_resistance(self) -> None:
pipe = AmesimPnl0002(
"pnl_2",
self.medium,
diam=0.02,
le=2.0,
rr=0.045 / 20.0,
p0=100000.0,
T0=350.0,
)
center = pipe.properties()
pipe.port_1.p = 130000.0
pipe.port_2.p = 80000.0
pipe.update_flow_temperature_references(
{
"port_1": self.medium.specific_enthalpy_at_pressure(
pipe.port_1.p,
250.0,
),
"port_2": self.medium.specific_enthalpy_at_pressure(
pipe.port_2.p,
450.0,
),
}
)
raw_parameters: list[float] = []
bare_parameters: list[float] = []
frictions: list[float] = []
for port_name, port in (
("port_1", pipe.port_1),
("port_2", pipe.port_2),
):
flow = pipe.port_mass_flow(
port.p,
center.p,
center.T,
port_name=port_name,
)
if flow >= 0.0:
upstream_pressure = port.p
upstream_temperature = pipe.medium.temperature_from_pressure_enthalpy(
port.p,
pipe._connected_h[port_name],
)
else:
upstream_pressure = center.p
upstream_temperature = center.T
reynolds = pipe.reynolds_number(flow, upstream_temperature)
friction = pipe.friction_factor(reynolds)
raw = (
abs(flow)
* sqrt(upstream_temperature)
/ (pipe.area * upstream_pressure)
)
flow_coefficient = sqrt(
pipe.diam / (pipe.resistance_length * friction)
)
raw_parameters.append(raw)
bare_parameters.append(raw / flow_coefficient)
frictions.append(friction)
expected = sum(bare_parameters) / 2.0
collapsed = (sum(raw_parameters) / 2.0) / sqrt(
pipe.diam
/ (pipe.resistance_length * (sum(frictions) / 2.0))
)
actual = pipe.component_result_values()["cm"]
self.assertAlmostEqual(actual, expected)
self.assertGreater(abs(actual - collapsed), 1.0e-8)
class AmesimPnl0003ComponentTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = IdealGasMedium()
def test_default_create_preserves_contract(self) -> None:
pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0003"].create("pnl_3", self.medium, {})
self.assertIsInstance(pipe, AmesimPnl0003)
self.assertEqual(set(pipe.ports), {"port_1", "port_2"})
self.assertEqual(len(pipe.get_state_vector()), 4)
def test_initial_state_uses_two_half_volume_compliances(self) -> None:
pipe = AmesimPnl0003(
"pnl_3",
self.medium,
diam=0.02,
le=0.3,
rr=0.045 / 20.0,
p1_0=15.3e6,
T1_0=293.15,
p2_0=15.3e6,
T2_0=293.15,
)
port_1 = pipe.properties_1()
port_2 = pipe.properties_2()
self.assertAlmostEqual(pipe.volume, 9.424777960769381e-5)
self.assertAlmostEqual(pipe.compliance_volume, pipe.volume / 2.0)
self.assertAlmostEqual(port_1.p, 15.3e6, delta=1.0e-5)
self.assertAlmostEqual(port_2.p, 15.3e6, delta=1.0e-5)
def test_center_resistance_flow_follows_end_pressure_gradient(self) -> None:
pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=101000.0, p2_0=100000.0)
forward = pipe.resistance_mass_flow()
pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=100000.0, p2_0=101000.0)
reverse = pipe.resistance_mass_flow()
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_exact_repeated_center_flow_inversion_is_cached(self) -> None:
pipe = AmesimPnl0003("pnl_3", self.medium)
pipe._mass_flow_for_pressure_drop.cache_clear()
first = pipe._mass_flow_for_pressure_drop(
1000.0,
density=1.2,
temperature=300.0,
)
after_first = pipe._mass_flow_for_pressure_drop.cache_info()
second = pipe._mass_flow_for_pressure_drop(
1000.0,
density=1.2,
temperature=300.0,
)
after_second = pipe._mass_flow_for_pressure_drop.cache_info()
changed_temperature = pipe._mass_flow_for_pressure_drop(
1000.0,
density=1.2,
temperature=400.0,
)
after_temperature_change = pipe._mass_flow_for_pressure_drop.cache_info()
self.assertEqual(first, second)
self.assertEqual(after_first.misses, 1)
self.assertEqual(after_second.hits, after_first.hits + 1)
self.assertNotEqual(changed_temperature, second)
self.assertEqual(
after_temperature_change.misses,
after_second.misses + 1,
)
def test_pressure_flow_residuals_bind_both_port_pressures_to_states(self) -> None:
pipe = AmesimPnl0003("pnl_3", self.medium)
pipe.properties_1()
pipe.properties_2()
residuals = {
residual.id.rsplit(":", 1)[1]: residual
for residual in pipe.pressure_flow_equation_residuals()
}
self.assertEqual(set(residuals), {"port_1_pressure_state", "port_2_pressure_state"})
self.assertAlmostEqual(residuals["port_1_pressure_state"].value, 0.0)
self.assertAlmostEqual(residuals["port_2_pressure_state"].value, 0.0)
def test_connection_derivative_conserves_external_and_center_flows(self) -> None:
pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=101000.0, p2_0=100000.0)
port_1 = pipe.properties_1()
port_2 = pipe.properties_2()
pipe.port_1.m_flow = 0.2
pipe.port_2.m_flow = -0.1
derivative = pipe.state_derivative_from_ports(
{"port_1": port_1.h + 1000.0, "port_2": port_2.h - 1000.0}
)
self.assertAlmostEqual(derivative[0] + derivative[2], 0.1)
def test_results_include_two_thermodynamic_sides_and_center_flow(self) -> None:
pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=101000.0, p2_0=100000.0)
values = pipe.component_result_values()
self.assertIn("m1", values)
self.assertIn("m2", values)
self.assertIn("dmctr", values)
self.assertIn("re", values)
def test_reported_reynolds_uses_amesim_helium_nasa_viscosity_only(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
pipe = AmesimPnl0003("pnl_3", medium, diam=0.02)
mass_flow = 0.04555349965141288
upstream_temperature = 233.92077861710192
# Frozen AMESim dense row: t=0.81 s, branch 2, D=0.02 m.
expected_amesim_reynolds = 172298.96343252173
reported = pipe.reported_reynolds_number(
mass_flow,
upstream_temperature,
)
dynamic = pipe.reynolds_number(mass_flow, upstream_temperature)
self.assertAlmostEqual(reported, expected_amesim_reynolds, delta=1.0e-9)
self.assertNotAlmostEqual(reported, dynamic, delta=1.0)
self.assertAlmostEqual(
dynamic,
4.0
* abs(mass_flow)
/ (
pi
* pipe.diam
* medium.dynamic_viscosity(upstream_temperature)
),
)
def test_result_cm_excludes_center_resistance_flow_coefficient(self) -> None:
pipe = AmesimPnl0003(
"pnl_3",
self.medium,
diam=0.02,
le=0.3,
rr=0.045 / 20.0,
p1_0=10.7e6,
T1_0=263.4,
p2_0=10.68e6,
T2_0=263.42,
)
port_1 = pipe.properties_1()
port_2 = pipe.properties_2()
flow = pipe.resistance_mass_flow()
upstream = port_1 if flow >= 0.0 else port_2
reynolds = pipe.reynolds_number(flow, upstream.T)
friction = pipe.friction_factor(reynolds)
raw_cq_cm = (
abs(flow)
* sqrt(upstream.T)
/ (pipe.area * max(port_1.p, port_2.p))
)
flow_coefficient = sqrt(pipe.diam / (pipe.le * friction))
actual = pipe.component_result_values()["cm"]
self.assertAlmostEqual(actual, raw_cq_cm / flow_coefficient)
self.assertNotAlmostEqual(actual, raw_cq_cm)
if __name__ == "__main__":
-683
View File
@@ -1,683 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.components.amesim.flow.pipes import (
AmesimPnl0001,
AmesimPnl00r,
)
from app.simulation.components.amesim.junctions.nodes import AmesimPn3Node2
from app.simulation.components.amesim.media.mediums import AmesimHeliumPengRobinsonMedium
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.registry import COMPONENT_MODEL_REGISTRY
from app.simulation.solvers.stream import StreamResolver
from app.simulation.systems.network import SimulationNetwork
class AmesimPnl00rComponentTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = IdealGasMedium()
def test_default_create_preserves_amesim_parameter_contract(self) -> None:
pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl00r"].create(
"pnl_1",
self.medium,
{},
)
self.assertIsInstance(pipe, AmesimPnl00r)
self.assertEqual(set(pipe.ports), {"port_1", "port_2"})
self.assertEqual(set(pipe.parameter_values), {"diam", "le", "rr", "gi"})
self.assertEqual(pipe.parameter_values["diam"], 0.01)
self.assertEqual(pipe.gi, 0)
def test_rejects_fractional_gas_index(self) -> None:
with self.assertRaisesRegex(ValueError, "gi must be an integer"):
COMPONENT_MODEL_REGISTRY["amesim_pnl00r"].create(
"pnl_1",
self.medium,
{"gi": 1.5},
)
def test_initial_flow_temperature_reference_preserves_default_behavior(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium)
initial_h = self.medium.specific_enthalpy(self.medium.T_ref)
self.assertEqual(
pipe._connected_h,
{"port_1": initial_h, "port_2": initial_h},
)
self.assertAlmostEqual(pipe._port_temperature("port_1"), self.medium.T_ref)
self.assertAlmostEqual(pipe._port_temperature("port_2"), self.medium.T_ref)
forward = pipe.mass_flow(501000.0, 500000.0)
reverse = pipe.mass_flow(500000.0, 501000.0)
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(forward, -reverse, delta=abs(forward) * 0.02)
def test_zero_pressure_drop_has_zero_flow_and_finite_results(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium)
pipe.port_1.p = 100000.0
pipe.port_2.p = 100000.0
pipe.port_1.m_flow = 0.0
pipe.port_2.m_flow = 0.0
residuals = {
residual.id.rsplit(":", 1)[1]: residual
for residual in pipe.pressure_flow_equation_residuals()
}
self.assertEqual(pipe.mass_flow(100000.0, 100000.0), 0.0)
self.assertEqual(residuals["mass_flow_balance"].value, 0.0)
self.assertEqual(residuals["pressure_flow_relation"].value, 0.0)
self.assertEqual(set(pipe.component_result_values()), {"re", "cm", "v", "ff"})
def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0)
forward = pipe.mass_flow(501000.0, 500000.0)
reverse = pipe.mass_flow(500000.0, 501000.0)
self.assertGreater(forward, 0.0)
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.10)
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_matches_amesim_pn2pipefr_laminar_baselines(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
pipe = AmesimPnl00r(
"pnl_1",
medium,
diam=0.014,
le=1.0,
rr=0.045 / 14.0,
)
samples = (
(
13_887_929.5734,
288.959589373,
13_887_887.6633,
288.959239289,
1.79075554200e-4,
838.404977868,
3.32483767035e-6,
0.0521569680484,
0.076335424633,
),
(
13_093_276.1461,
285.548411921,
13_093_238.0341,
285.546325432,
1.40922897107e-4,
665.123640745,
3.09743309373e-6,
0.0429212663992,
0.0962227112065,
),
(
11_690_210.3512,
272.892693485,
11_690_180.6138,
272.889527291,
7.10081729578e-5,
345.615999811,
2.3706370264e-6,
0.0230685613948,
0.185176612295,
),
)
for p_1, T_1, p_2, T_2, mass_flow, re, cm, velocity, ff in samples:
with self.subTest(pressure=p_1):
pipe.port_1.p = p_1
pipe.port_2.p = p_2
pipe.update_stream_outflows(
{
"port_1": medium.specific_enthalpy_at_pressure(p_1, T_1),
"port_2": medium.specific_enthalpy_at_pressure(p_2, T_2),
}
)
actual_mass_flow = pipe.mass_flow(p_1, p_2)
results = pipe.component_result_values()
self.assertAlmostEqual(actual_mass_flow / mass_flow, 1.0, delta=0.005)
self.assertAlmostEqual(results["re"] / re, 1.0, delta=0.01)
self.assertAlmostEqual(results["cm"] / cm, 1.0, delta=1.0e-4)
self.assertAlmostEqual(results["v"] / velocity, 1.0, delta=0.005)
self.assertAlmostEqual(results["ff"] / ff, 1.0, delta=0.01)
def test_matches_amesim_pn2pipefr_transition_baselines(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
pipe = AmesimPnl00r(
"pnl_1",
medium,
diam=0.014,
le=1.0,
rr=0.045 / 14.0,
)
# Unchanged test_mql_1.ame, pneumatic_105, ascending transition at
# t=0.2862..0.3047 s. AMESim dm1 is positive into port 1 while this
# component's port-1-to-port-2 coordinate is negative for these rows.
samples = (
(
10_631_986.90667049,
262.71180639527233,
10_632_026.310978588,
262.7092388107747,
-2.8192909824026485e-4,
1408.0846374820405,
5.030700878807581e-6,
-0.09668817162408228,
0.04545209089983021,
),
(
10_594_171.934888396,
262.32910587014925,
10_594_213.825687861,
262.3266782251236,
-3.598950492898518e-4,
1799.2599253847663,
5.723572909300966e-6,
-0.1236749841566422,
0.03590026408283139,
),
(
10_554_721.69137201,
261.9257072357455,
10_554_766.280823693,
261.92350790162175,
-4.3973197060195834e-4,
2200.69088342098,
6.529418698258151e-6,
-0.1514267390526478,
0.03111112083131272,
),
(
10_544_953.846352266,
261.82512637406904,
10_544_999.25593169,
261.82299814935993,
-4.592053720488228e-4,
2298.7460389094877,
6.783006948981891e-6,
-0.15821443006863944,
0.03074226487938153,
),
(
10_514_807.25092133,
261.5128728347395,
10_514_855.822238008,
261.5109955483938,
-5.190048780471613e-4,
2600.199197308607,
7.798343300840328e-6,
-0.17910295287093314,
0.03166638237616583,
),
(
10_432_760.829010766,
260.6482173603332,
10_432_818.817992153,
260.6472539768074,
-6.759582533840764e-4,
3394.136683451109,
1.1253055264735043e-5,
-0.23427502517225277,
0.03839469933871734,
),
)
for p_1, T_1, p_2, T_2, mass_flow, re, cm, velocity, ff in samples:
with self.subTest(reynolds=re):
pipe.port_1.p = p_1
pipe.port_2.p = p_2
pipe.update_stream_outflows(
{
"port_1": medium.specific_enthalpy_at_pressure(p_1, T_1),
"port_2": medium.specific_enthalpy_at_pressure(p_2, T_2),
}
)
actual_mass_flow = pipe.mass_flow(p_1, p_2)
results = pipe.component_result_values()
self.assertAlmostEqual(actual_mass_flow / mass_flow, 1.0, delta=0.006)
self.assertAlmostEqual(results["re"] / re, 1.0, delta=0.012)
self.assertAlmostEqual(results["cm"] / cm, 1.0, delta=1.0e-4)
self.assertAlmostEqual(results["v"] / velocity, 1.0, delta=0.006)
self.assertAlmostEqual(results["ff"] / ff, 1.0, delta=0.012)
def test_upstream_temperature_uses_same_side_connected_stream(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium)
h_1 = self.medium.specific_enthalpy(250.0)
h_2 = self.medium.specific_enthalpy(400.0)
pipe.update_stream_outflows({"port_1": h_1, "port_2": h_2})
self.assertAlmostEqual(pipe._port_temperature("port_1"), 250.0)
self.assertAlmostEqual(pipe._port_temperature("port_2"), 400.0)
self.assertEqual(pipe.port_1.h_outflow, h_2)
self.assertEqual(pipe.port_2.h_outflow, h_1)
def test_pn2pipefr_cache_keys_all_property_inputs(self) -> None:
pipe = AmesimPnl00r(
"pnl_1",
self.medium,
diam=0.014,
le=1.0,
rr=0.045 / 14.0,
)
pipe.update_flow_temperature_references(
{
"port_1": self.medium.specific_enthalpy(300.0),
"port_2": self.medium.specific_enthalpy(300.0),
}
)
AmesimPnl0001._one_way_pn2pipefr_mass_flow.cache_clear()
first = pipe.mass_flow(501000.0, 500000.0)
after_first = AmesimPnl0001._one_way_pn2pipefr_mass_flow.cache_info()
second = pipe.mass_flow(501000.0, 500000.0)
after_second = AmesimPnl0001._one_way_pn2pipefr_mass_flow.cache_info()
pipe.update_flow_temperature_references(
{
"port_1": self.medium.specific_enthalpy(400.0),
"port_2": self.medium.specific_enthalpy(300.0),
}
)
third = pipe.mass_flow(501000.0, 500000.0)
after_temperature_change = AmesimPnl0001._one_way_pn2pipefr_mass_flow.cache_info()
self.assertEqual(first, second)
self.assertEqual(after_first.misses, 1)
self.assertEqual(after_second.hits, after_first.hits + 1)
self.assertNotEqual(third, second)
self.assertEqual(
after_temperature_change.misses,
after_second.misses + 1,
)
def test_stream_outflows_are_crossed_but_flow_temperature_is_same_side(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium)
hot_h = self.medium.specific_enthalpy(420.0)
cold_h = self.medium.specific_enthalpy(240.0)
pipe.update_stream_outflows({"port_1": hot_h, "port_2": cold_h})
self.assertEqual(pipe.port_1.h_outflow, cold_h)
self.assertEqual(pipe.port_2.h_outflow, hot_h)
self.assertEqual(pipe._connected_h, {"port_1": hot_h, "port_2": cold_h})
self.assertAlmostEqual(pipe._port_temperature("port_1"), 420.0)
self.assertAlmostEqual(pipe._port_temperature("port_2"), 240.0)
warmer_h = self.medium.specific_enthalpy(460.0)
pipe.update_flow_temperature_references(
{"port_1": warmer_h, "port_2": cold_h}
)
self.assertEqual(
pipe._connected_h,
{"port_1": warmer_h, "port_2": cold_h},
)
self.assertAlmostEqual(pipe._port_temperature("port_1"), 460.0)
# Updating the pressure-flow-only reference must not change the
# zero-volume component's already propagated connector outflows.
self.assertEqual(pipe.port_1.h_outflow, cold_h)
self.assertEqual(pipe.port_2.h_outflow, hot_h)
def test_stream_closure_refreshes_same_side_cache_before_recomputed_flow(self) -> None:
hot_temperature = 420.0
cold_temperature = 240.0
source = Cylinder(
"source",
self.medium,
V=0.02,
p0=501000.0,
T0=hot_temperature,
)
pipe = AmesimPnl00r("pnl_1", self.medium)
sink = Tank(
"sink",
self.medium,
V=0.05,
p0=500000.0,
T0=cold_temperature,
)
network = SimulationNetwork("pnl00r-stream-refresh")
for component in (source, pipe, sink):
network.add_component(component)
network.connect("source", "port_b", "pnl_1", "port_1")
network.connect("pnl_1", "port_2", "sink", "port_a")
diagnostics, connected_h = StreamResolver(network).solve()
self.assertTrue(diagnostics.converged)
self.assertEqual(pipe._connected_h, connected_h["pnl_1"])
self.assertAlmostEqual(pipe._port_temperature("port_1"), hot_temperature)
self.assertAlmostEqual(pipe._port_temperature("port_2"), cold_temperature)
# The pressure-flow layer runs again after this stream closure pass.
# Its first recomputation therefore observes the freshly cached source
# enthalpy, independently of the crossed connector outflow values.
pipe.port_1.p = source.port_b.p
pipe.port_2.p = sink.port_a.p
flow = pipe.mass_flow(pipe.port_1.p, pipe.port_2.p)
self.assertGreater(flow, 0.0)
def test_node_port_2_reference_refresh_does_not_replace_crossed_outflows(
self,
) -> None:
hot = Cylinder(
"hot",
self.medium,
V=0.1,
p0=100_000.0,
T0=400.0,
)
cold = Cylinder(
"cold",
self.medium,
V=0.1,
p0=100_000.0,
T0=200.0,
)
remote = Cylinder(
"remote",
self.medium,
V=0.1,
p0=100_000.0,
T0=300.0,
)
node = AmesimPn3Node2("node")
pipe = AmesimPnl00r("pnl_1", self.medium)
node.port_1.m_flow = 1.0
node.port_2.m_flow = -1.0
node.port_3.m_flow = 0.0
network = SimulationNetwork("pnl00r-node-reference")
for component in (hot, cold, remote, node, pipe):
network.add_component(component)
network.connect("hot", "port_b", "node", "port_1")
network.connect("cold", "port_b", "node", "port_3")
network.connect("node", "port_2", "pnl_1", "port_1")
network.connect("pnl_1", "port_2", "remote", "port_b")
resolver = StreamResolver(network)
diagnostics, connected_h = resolver.solve()
references = resolver.connected_temperature_reference_enthalpies()
outflows_before = (pipe.port_1.h_outflow, pipe.port_2.h_outflow)
self.assertTrue(diagnostics.converged)
self.assertNotEqual(node.port_2.h_outflow, node.temperature_reference_h)
self.assertEqual(pipe._connected_h, connected_h["pnl_1"])
self.assertNotEqual(
connected_h["pnl_1"]["port_1"],
references["pnl_1"]["port_1"],
)
resolver.refresh_flow_temperature_references()
self.assertEqual(pipe._connected_h, references["pnl_1"])
self.assertEqual(
pipe._connected_h["port_1"],
node.temperature_reference_h,
)
self.assertEqual(
(pipe.port_1.h_outflow, pipe.port_2.h_outflow),
outflows_before,
)
def test_hot_and_cold_upstream_flow_results_and_equations_are_consistent(self) -> None:
pipe = AmesimPnl00r(
"pnl_1",
self.medium,
diam=0.014,
le=1.0,
rr=0.045 / 14.0,
)
hot_temperature = 420.0
cold_temperature = 240.0
pipe.update_stream_outflows(
{
"port_1": self.medium.specific_enthalpy(hot_temperature),
"port_2": self.medium.specific_enthalpy(cold_temperature),
}
)
reference_hot = AmesimPnl00r(
"reference_hot",
self.medium,
diam=pipe.diam,
le=pipe.le,
rr=pipe.rr,
)
reference_hot.update_flow_temperature_references(
{
"port_1": self.medium.specific_enthalpy(hot_temperature),
"port_2": self.medium.specific_enthalpy(hot_temperature),
}
)
reference_cold = AmesimPnl00r(
"reference_cold",
self.medium,
diam=pipe.diam,
le=pipe.le,
rr=pipe.rr,
)
reference_cold.update_flow_temperature_references(
{
"port_1": self.medium.specific_enthalpy(cold_temperature),
"port_2": self.medium.specific_enthalpy(cold_temperature),
}
)
cases = (
("forward_hot", 501000.0, 500000.0, hot_temperature, reference_hot),
("reverse_cold", 500000.0, 501000.0, cold_temperature, reference_cold),
)
for label, p_1, p_2, upstream_temperature, reference in cases:
with self.subTest(label=label):
pipe.port_1.p = p_1
pipe.port_2.p = p_2
reference.port_1.p = p_1
reference.port_2.p = p_2
expected_flow = reference.mass_flow(p_1, p_2)
actual_flow = pipe.mass_flow(p_1, p_2)
self.assertAlmostEqual(actual_flow, expected_flow)
pipe.port_1.m_flow = actual_flow
pipe.port_2.m_flow = -actual_flow
equation_values = pipe.pressure_flow_equation_values()
residual_values = tuple(
residual.value
for residual in pipe.pressure_flow_equation_residuals()
)
self.assertEqual(equation_values, (0.0, 0.0))
self.assertEqual(residual_values, equation_values)
results = pipe.component_result_values()
upstream_pressure = max(p_1, p_2)
density = self.medium.density(
upstream_pressure,
upstream_temperature,
)
expected_reynolds = pipe.reynolds_number(
actual_flow,
upstream_temperature,
)
friction = pipe.friction_factor(expected_reynolds)
flow_coefficient = (
pipe.diam / (pipe.le * friction)
) ** 0.5
self.assertAlmostEqual(results["re"], expected_reynolds)
self.assertAlmostEqual(
results["v"],
actual_flow / (density * pipe.area),
)
self.assertAlmostEqual(
results["cm"],
abs(actual_flow)
* upstream_temperature**0.5
/ (flow_coefficient * pipe.area * upstream_pressure),
)
self.assertAlmostEqual(
results["ff"],
min(friction, 64_000_000.0),
)
def test_friction_factor_transitions_from_laminar_to_turbulent(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium, rr=1e-5)
self.assertGreater(pipe.friction_factor(1.0e-12), 64_000_000.0)
self.assertAlmostEqual(pipe.friction_factor(100.0), 64.0 / 100.0)
self.assertGreater(pipe.friction_factor(100000.0), 0.0)
self.assertLess(pipe.friction_factor(100000.0), 0.1)
def test_reported_friction_factor_is_bounded_at_tiny_flow(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium)
pipe.mass_flow = lambda _p_1, _p_2: 1.0e-30
self.assertEqual(pipe.component_result_values()["ff"], 64_000_000.0)
def test_friction_factor_matches_amesim_smooth_to_rough_transition(self) -> None:
pipe_20mm = AmesimPnl00r(
"pnl_20mm",
self.medium,
diam=0.02,
rr=0.045 / 20.0,
)
pipe_14mm = AmesimPnl00r(
"pnl_14mm",
self.medium,
diam=0.014,
rr=0.045 / 14.0,
)
self.assertAlmostEqual(
pipe_20mm.friction_factor(56_887.5547),
0.0215740061043,
delta=1.0e-4,
)
self.assertAlmostEqual(
pipe_20mm.friction_factor(700_686.41),
0.02400535718,
delta=8.0e-5,
)
self.assertAlmostEqual(
pipe_14mm.friction_factor(726_799.66),
0.02658268645,
delta=8.0e-5,
)
def test_friction_factor_matches_amesim_transition_sweep(self) -> None:
pipe = AmesimPnl00r(
"pnl_14mm",
self.medium,
diam=0.014,
rr=0.045 / 14.0,
)
samples = (
(1408.0846374820405, 0.04545209089983021),
(1594.9147224775163, 0.04022919421391556),
(1799.2599253847663, 0.03590026408283139),
(2001.1290261016431, 0.03286496043823008),
(2200.69088342098, 0.03111112083131272),
(2298.7460389094877, 0.03074226487938153),
(2396.165041443924, 0.030707477592933765),
(2600.199197308607, 0.03166638237616583),
(2795.6649128003255, 0.03349648426341731),
(2996.892872668051, 0.035593529418766444),
(3204.685791989844, 0.037354280559382634),
(3394.136683451109, 0.03839469933871734),
)
for reynolds, expected in samples:
with self.subTest(reynolds=reynolds):
self.assertAlmostEqual(
pipe.friction_factor(reynolds) / expected,
1.0,
delta=0.0015,
)
def test_mass_flow_is_monotone_through_transition(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
pipe = AmesimPnl00r(
"pnl_14mm",
medium,
diam=0.014,
le=1.0,
rr=0.045 / 14.0,
)
mean_pressure = 10.6e6
temperature = 262.0
enthalpy = medium.specific_enthalpy_at_pressure(mean_pressure, temperature)
pipe.update_flow_temperature_references(
{"port_1": enthalpy, "port_2": enthalpy}
)
forward: list[float] = []
reverse: list[float] = []
for index in range(221):
pressure_drop = 25.0 + index * 0.25
high = mean_pressure + 0.5 * pressure_drop
low = mean_pressure - 0.5 * pressure_drop
forward.append(pipe.mass_flow(high, low))
reverse.append(-pipe.mass_flow(low, high))
self.assertLess(pipe.reynolds_number(forward[0], temperature), 1400.0)
self.assertGreater(
pipe.reynolds_number(forward[-1], temperature), 3400.0
)
self.assertTrue(all(left < right for left, right in zip(forward, forward[1:])))
for forward_flow, reverse_flow in zip(forward, reverse):
self.assertAlmostEqual(forward_flow, reverse_flow, delta=1.0e-12)
def test_friction_factor_matches_amesim_transition_regime(self) -> None:
pipe = AmesimPnl00r(
"pnl_20mm",
self.medium,
diam=0.02,
rr=0.045 / 20.0,
)
self.assertAlmostEqual(
pipe.friction_factor(3_699.80236),
0.0391257647759,
delta=4.0e-4,
)
def test_darcy_pressure_drop_uses_flow_sign(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium)
density = self.medium.density(500000.0, 300.0)
forward = pipe.darcy_pressure_drop(0.01, density=density, temperature=300.0)
reverse = pipe.darcy_pressure_drop(-0.01, density=density, temperature=300.0)
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(forward, -reverse)
if __name__ == "__main__":
unittest.main()
-50
View File
@@ -35,59 +35,9 @@ class AmesimPnor001ComponentTests(unittest.TestCase):
{"flowset": 1.5},
)
def test_zero_pressure_drop_has_zero_flow_and_finite_results(self) -> None:
orifice = AmesimPnor001("pnor_1", self.medium)
orifice.port_1.p = 100000.0
orifice.port_2.p = 100000.0
orifice.port_1.m_flow = 0.0
orifice.port_2.m_flow = 0.0
residuals = {
residual.id.rsplit(":", 1)[1]: residual
for residual in orifice.pressure_flow_equation_residuals()
}
self.assertEqual(orifice.mass_flow(100000.0, 100000.0), 0.0)
self.assertEqual(residuals["mass_flow_balance"].value, 0.0)
self.assertEqual(residuals["pressure_flow_relation"].value, 0.0)
self.assertEqual(set(orifice.component_result_values()), {"cm", "gasvel"})
def test_exact_repeated_flow_characteristics_are_cached(self) -> None:
orifice = AmesimPnor001("pnor_1", self.medium)
orifice._one_way_flow_characteristics.cache_clear()
first = orifice._one_way_flow_characteristics(
upstream_pressure=500000.0,
downstream_pressure=100000.0,
upstream_temperature=293.15,
)
after_first = orifice._one_way_flow_characteristics.cache_info()
second = orifice._one_way_flow_characteristics(
upstream_pressure=500000.0,
downstream_pressure=100000.0,
upstream_temperature=293.15,
)
def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None:
orifice = AmesimPnor001("pnor_1", self.medium)
forward = orifice.mass_flow(500000.0, 100000.0)
reverse = orifice.mass_flow(100000.0, 500000.0)
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(forward, -reverse)
def test_high_pressure_near_equal_flow_is_continuous(self) -> None:
orifice = AmesimPnor001("pnor_1", self.medium)
orifice.port_1.p = 15.3e6
orifice.port_2.p = 15.3e6
small = orifice.mass_flow(15.3e6 + 0.1, 15.3e6)
larger = orifice.mass_flow(15.3e6 + 1.0, 15.3e6)
self.assertGreater(small, 0.0)
self.assertGreater(larger, small)
def test_cv_and_kv_modes_produce_positive_equivalent_area(self) -> None:
cv_orifice = AmesimPnor001(
-16
View File
@@ -22,23 +22,7 @@ class AmesimPnpl01ComponentTests(unittest.TestCase):
self.assertEqual(set(source.ports), {"port_1"})
self.assertEqual(source.parameter_values, {})
def test_zero_flow_residual_uses_port_mass_flow(self) -> None:
source = AmesimPnpl01("plug_1")
source.port_1.m_flow = 0.125
residuals = source.pressure_flow_equation_residuals()
self.assertEqual(len(residuals), 1)
self.assertEqual(residuals[0].id, "plug_1:zero_mass_flow")
self.assertEqual(residuals[0].variables, ("plug_1.port_1.m_flow",))
self.assertEqual(residuals[0].value, 0.125)
def test_stream_outflow_tracks_connected_enthalpy_when_available(self) -> None:
source = AmesimPnpl01("plug_1")
source.update_stream_outflows({"port_1": 123.0})
self.assertEqual(source.port_1.h_outflow, 123.0)
if __name__ == "__main__":
-38
View File
@@ -144,39 +144,6 @@ class AmesimPnrp17Tests(unittest.TestCase):
),
)
def test_component_equations_match_pnrp17_geometry_and_signs(self) -> None:
medium = IdealGasMedium()
piston = AmesimPnrp17(
"piston_1",
medium,
gi=0.0,
dp=0.2,
dr=0.1,
x0=0.05,
)
piston.port_1.p = 300000.0
piston.port_2.x = piston.port_5.x = 0.3
piston.port_2.v = piston.port_5.v = 0.4
piston.port_3.x = piston.port_4.x = 0.1
piston.port_3.v = piston.port_4.v = -0.2
pressure_force = piston.pressure_force
piston.port_5.f = 10.0
piston.port_2.f = -pressure_force - piston.port_5.f
piston.port_4.f = 20.0
piston.port_3.f = pressure_force - piston.port_4.f
expected_area = pi * (0.2**2 - 0.1**2) / 4.0
self.assertAlmostEqual(piston.effective_area, expected_area)
self.assertAlmostEqual(piston.chamber_length, 0.25)
self.assertAlmostEqual(piston.chamber_volume, expected_area * 0.25)
self.assertAlmostEqual(piston.chamber_volume_flow, expected_area * 0.6)
for residual in piston.pressure_flow_equation_residuals():
self.assertAlmostEqual(residual.value, 0.0, msg=residual.id)
self.assertEqual(
piston.pneumatic_volume_outputs(),
{"port_1": (piston.chamber_volume, piston.chamber_volume_flow)},
)
def test_rod_diameter_cannot_exceed_piston_diameter(self) -> None:
with self.assertRaisesRegex(ValueError, "dr must not exceed"):
@@ -207,11 +174,6 @@ class AmesimPnrp17Tests(unittest.TestCase):
self.assertAlmostEqual(chamber_value, 0.01 + piston_value, places=10)
self.assertGreater(result["series"]["piston_mass.x"][-1], 0.0)
self.assertLess(result["series"]["cylinder_mass.x"][-1], 0.0)
self.assertGreater(result["diagnostics"]["pneumaticVolume"]["propagations"], 0)
self.assertEqual(
result["diagnostics"]["pneumaticVolume"]["last"]["outputPorts"],
["piston_1.port_1"],
)
if __name__ == "__main__":
@@ -14,20 +14,6 @@ class AmesimPnvo001FixedOpeningComponentTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = IdealGasMedium()
def test_default_create_preserves_fixed_opening_contract(self) -> None:
valve = COMPONENT_MODEL_REGISTRY["amesim_pnvo001_fixed"].create(
"valve_1",
self.medium,
{},
)
self.assertIsInstance(valve, AmesimPnvo001FixedOpening)
self.assertEqual(set(valve.ports), {"port_2", "port_3"})
self.assertEqual(
set(valve.parameter_values),
{"cq", "area0", "Cv", "Kv", "gi", "flowset", "opening"},
)
self.assertEqual(valve.opening, 1.0)
def test_rejects_fractional_flowset(self) -> None:
with self.assertRaisesRegex(ValueError, "must be one of"):
@@ -37,238 +23,14 @@ class AmesimPnvo001FixedOpeningComponentTests(unittest.TestCase):
{"flowset": 1.5},
)
def test_opening_scales_effective_area(self) -> None:
valve = AmesimPnvo001FixedOpening(
"valve_1",
self.medium,
area0=2.0e-6,
opening=0.25,
)
self.assertAlmostEqual(valve.maximum_area, 2.0e-6)
self.assertAlmostEqual(valve.effective_area, 0.5e-6)
self.assertEqual(valve.component_result_values()["xv"], 0.25)
def test_zero_opening_blocks_flow(self) -> None:
valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.0)
self.assertEqual(valve.mass_flow(500000.0, 100000.0), 0.0)
def test_closed_opening_only_zeroes_reported_gas_velocity(self) -> None:
valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.0)
valve.port_2.p = 500000.0
valve.port_3.p = 100000.0
closed = valve.component_result_values()
self.assertGreater(closed["cm"], 0.0)
self.assertEqual(closed["gasvel"], 0.0)
valve.opening = 7.0e-15
roundoff_closed = valve.component_result_values()
self.assertEqual(roundoff_closed["gasvel"], 0.0)
self.assertEqual(roundoff_closed["cm"], closed["cm"])
valve.opening = 1.0e-9
genuinely_open = valve.component_result_values()
self.assertNotEqual(genuinely_open["gasvel"], 0.0)
self.assertEqual(genuinely_open["cm"], closed["cm"])
def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None:
valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5)
forward = valve.mass_flow(500000.0, 100000.0)
reverse = valve.mass_flow(100000.0, 500000.0)
self.assertGreater(forward, 0.0)
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)
cold_h = self.medium.specific_enthalpy(200.0)
valve.update_stream_outflows({"port_2": hot_h, "port_3": cold_h})
self.assertEqual(valve.port_2.h_outflow, cold_h)
self.assertEqual(valve.port_3.h_outflow, hot_h)
self.assertAlmostEqual(
valve.mass_flow(500000.0, 100000.0),
valve._one_way_mass_flow(
upstream_pressure=500000.0,
downstream_pressure=100000.0,
upstream_temperature=600.0,
),
)
self.assertAlmostEqual(
valve.mass_flow(100000.0, 500000.0),
-valve._one_way_mass_flow(
upstream_pressure=500000.0,
downstream_pressure=100000.0,
upstream_temperature=200.0,
),
)
def test_helium_flow_uses_pressure_enthalpy_and_real_gas_factor(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
valve = AmesimPnvo001FixedOpening(
"valve_1",
medium,
cq=0.45,
area0=78.5e-6,
opening=1.0,
)
upstream_pressure = 15_201_996.778497815
downstream_pressure = 405_072.8123335606
upstream_temperature = 292.3997890954483
valve.port_2.p = upstream_pressure
valve.port_3.p = downstream_pressure
upstream_enthalpy = medium.specific_enthalpy_at_pressure(
upstream_pressure,
upstream_temperature,
)
valve.update_stream_outflows(
{
"port_2": upstream_enthalpy,
"port_3": medium.specific_enthalpy_at_pressure(
downstream_pressure,
393.46713105173205,
),
}
)
self.assertLess(upstream_enthalpy, 0.0)
self.assertAlmostEqual(
valve._upstream_temperature("port_2"),
upstream_temperature,
delta=1.0e-8,
)
self.assertAlmostEqual(
valve.mass_flow(upstream_pressure, downstream_pressure),
0.49551308906777447,
delta=1.0e-9,
)
results = valve.component_result_values()
self.assertAlmostEqual(
results["cm"],
0.015778323343746598,
delta=1.0e-11,
)
self.assertAlmostEqual(
results["gasvel"],
894.7011595213406,
delta=1.0e-8,
)
def test_helium_laminar_transition_matches_amesim_baseline(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
valve = AmesimPnvo001FixedOpening(
"valve_1",
medium,
cq=0.45,
area0=78.5e-6,
opening=1.0,
)
cases = (
# p_up [PaA], p_down [PaA], T_up [K], dm [g/s], Cm, velocity [m/s]
(
10_837_357.913884956,
10_835_428.362241976,
255.45507181057303,
9.770711291393273,
4.07922685863417e-4,
13.951480313568323,
),
(
10_735_827.842429036,
10_735_543.135671863,
254.49635939397584,
3.4711877823059916,
1.4601624857015259e-4,
4.982778680076626,
),
(
10_703_773.135050302,
10_703_621.857082237,
254.1925398502598,
1.53560321377487,
6.475023461883112e-5,
2.208065833961227,
),
(
10_682_359.261700785,
10_682_301.022976216,
253.98926908210996,
0.25626141624058846,
1.0822847918204546e-5,
0.36890236539556787,
),
(
10_678_100.351449525,
10_678_093.704329137,
253.94881208705195,
0.0033658306146710985,
1.421965896377586e-7,
0.004846389527047451,
),
)
for (
p_up,
p_down,
T_up,
expected_dm,
expected_cm,
expected_velocity,
) in cases:
with self.subTest(pressure_difference=p_up - p_down):
mass_flow_parameter, gas_velocity = (
valve._one_way_flow_characteristics(
upstream_pressure=p_up,
downstream_pressure=p_down,
upstream_temperature=T_up,
)
)
mass_flow_g_s = (
valve._one_way_mass_flow(
upstream_pressure=p_up,
downstream_pressure=p_down,
upstream_temperature=T_up,
)
* 1.0e3
)
self.assertAlmostEqual(mass_flow_g_s, expected_dm, delta=1.0e-11)
self.assertAlmostEqual(
mass_flow_parameter,
expected_cm,
delta=1.0e-15,
)
self.assertAlmostEqual(
gas_velocity,
expected_velocity,
delta=5.0e-10,
)
if __name__ == "__main__":
-2
View File
@@ -227,7 +227,6 @@ class AmesimPnvo001SignalXmlTests(unittest.TestCase):
self.assertEqual(result["series"]["step_1.out.signal"], [1.0, 1.0, 1.0])
self.assertEqual(result["series"]["valve_1.res.signal"], [1.0, 1.0, 1.0])
self.assertIn("valve_1.xv", result["series"])
self.assertGreater(result["diagnostics"]["signal"]["propagations"], 0)
def test_high_pressure_helium_step_restarts_solver_at_event(self) -> None:
xml = build_reactflow_system_xml(high_pressure_helium_step_project())
@@ -235,7 +234,6 @@ class AmesimPnvo001SignalXmlTests(unittest.TestCase):
self.assertTrue(result["success"], result["message"])
self.assertEqual(result["simulatedUntil"], 0.042)
self.assertEqual(result["diagnostics"]["signal"]["eventTimes"], [0.04])
times = result["series"]["time"]
before_event = times.index(0.038)
-196
View File
@@ -1,196 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.components.amesim.flow.orifices import AmesimPnvo001SignalOpening
from app.simulation.components.amesim.signals.sources import AmesimStep0, AmesimUd00
from app.simulation.core.medium import IdealGasMedium
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
from app.simulation.solvers.signal import SignalResolver
from app.simulation.systems.network import SimulationNetwork
class AmesimSignalComponentTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = IdealGasMedium()
def test_step0_output_switches_at_step_time(self) -> None:
step = AmesimStep0("step_1", self.medium, initial=0.2, final=0.8, time=0.5)
self.assertEqual(step.output_at(0.49), 0.2)
self.assertEqual(step.output_at(0.5), 0.8)
self.assertEqual(step.signal_output_values(0.5), {"out": 0.8})
self.assertEqual(step.signal_event_times(0.0, 1.0), (0.5,))
self.assertEqual(step.signal_event_times(0.5, 1.0), ())
def test_ud00_output_interpolates_piecewise_signal(self) -> None:
signal = AmesimUd00(
"piecewise_1",
self.medium,
tstart=0.5,
starts=(0.0, 10.0, 20.0, 0.0, 0.0, 0.0, 0.0, 0.0),
ends=(10.0, 20.0, 30.0, 0.0, 0.0, 0.0, 0.0, 0.0),
durations=(1.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0),
nstages=3,
)
self.assertAlmostEqual(signal.output_at(0.0), 0.0)
self.assertAlmostEqual(signal.output_at(1.0), 5.0)
self.assertAlmostEqual(signal.output_at(2.5), 15.0)
self.assertAlmostEqual(signal.output_at(5.0), 35.0)
self.assertEqual(signal.signal_output_values(2.5), {"out": 15.0})
self.assertEqual(signal.signal_event_times(0.0, 5.0), (0.5, 1.5, 3.5))
def test_ud00_can_cycle_active_stages(self) -> None:
signal = AmesimUd00(
"piecewise_1",
self.medium,
starts=(0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
ends=(10.0, 20.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
durations=(1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
nstages=2,
iscyclic=True,
)
self.assertAlmostEqual(signal.output_at(0.25), 2.5)
self.assertAlmostEqual(signal.output_at(1.25), 12.5)
self.assertAlmostEqual(signal.output_at(2.25), 2.5)
self.assertEqual(signal.signal_event_times(0.0, 5.0), (1.0, 2.0, 3.0, 4.0))
def test_ud00_registry_rejects_fractional_stage_controls(self) -> None:
with self.assertRaisesRegex(ValueError, "must be one of"):
COMPONENT_MODEL_REGISTRY["amesim_ud00"].create(
"piecewise_1",
self.medium,
{"nstages": 1.5},
)
for name, value in (("nstages", 9.0), ("iscyclic", 2.0)):
with self.subTest(name=name):
with self.assertRaisesRegex(
ValueError,
"must be (?:one of|at most)",
):
COMPONENT_MODEL_REGISTRY["amesim_ud00"].create(
"piecewise_1",
self.medium,
{name: value},
)
def test_pnvo001_registry_rejects_invalid_flowset_choice(self) -> None:
with self.assertRaisesRegex(ValueError, "must be one of"):
COMPONENT_MODEL_REGISTRY["amesim_pnvo001"].create(
"valve_1",
self.medium,
{"flowset": 1.5},
)
def test_pnvo001_signal_opening_reads_res_port(self) -> None:
valve = COMPONENT_MODEL_REGISTRY["amesim_pnvo001"].create(
"valve_1",
self.medium,
{"opening0": 0.25},
)
self.assertIsInstance(valve, AmesimPnvo001SignalOpening)
self.assertEqual(set(valve.ports), {"res", "port_2", "port_3"})
self.assertAlmostEqual(valve.opening, 0.25)
valve.res.signal = 1.5
self.assertAlmostEqual(valve.opening, 1.0)
valve.res.signal = -0.5
self.assertAlmostEqual(valve.opening, 0.0)
def test_pnvo001_exact_repeated_flow_characteristics_are_cached(self) -> None:
valve = AmesimPnvo001SignalOpening("valve_1", self.medium, opening0=0.25)
valve._one_way_flow_characteristics.cache_clear()
first = valve._one_way_flow_characteristics(
upstream_pressure=501000.0,
downstream_pressure=500000.0,
upstream_temperature=300.0,
)
after_first = valve._one_way_flow_characteristics.cache_info()
second = valve._one_way_flow_characteristics(
upstream_pressure=501000.0,
downstream_pressure=500000.0,
upstream_temperature=300.0,
)
after_second = valve._one_way_flow_characteristics.cache_info()
changed_pressure = valve._one_way_flow_characteristics(
upstream_pressure=502000.0,
downstream_pressure=500000.0,
upstream_temperature=300.0,
)
after_pressure_change = valve._one_way_flow_characteristics.cache_info()
self.assertEqual(first, second)
self.assertEqual(after_first.misses, 1)
self.assertEqual(after_second.hits, after_first.hits + 1)
self.assertNotEqual(changed_pressure, second)
self.assertEqual(
after_pressure_change.misses,
after_second.misses + 1,
)
def test_signal_resolver_propagates_step_to_valve_input(self) -> None:
network = SimulationNetwork("signal-smoke")
step = AmesimStep0("step_1", self.medium, initial=0.0, final=0.75, time=0.1)
valve = AmesimPnvo001SignalOpening("valve_1", self.medium, opening0=0.0)
network.add_component(step)
network.add_component(valve)
network.connect("step_1", "out", "valve_1", "res", connection_id="signal-1")
resolver = SignalResolver(network)
resolver.solve(0.2)
self.assertAlmostEqual(step.out.signal, 0.75)
self.assertAlmostEqual(valve.res.signal, 0.75)
self.assertAlmostEqual(valve.opening, 0.75)
self.assertEqual(resolver.event_times(0.0, 0.2), (0.1,))
def test_signal_output_can_fan_out_but_input_has_one_driver(self) -> None:
network = SimulationNetwork("signal-cardinality")
step_1 = AmesimStep0(
"step_1", self.medium, initial=0.0, final=0.75, time=0.1
)
step_2 = AmesimStep0(
"step_2", self.medium, initial=1.0, final=0.25, time=0.1
)
valve_1 = AmesimPnvo001SignalOpening(
"valve_1", self.medium, opening0=0.0
)
valve_2 = AmesimPnvo001SignalOpening(
"valve_2", self.medium, opening0=0.0
)
for component in (step_1, step_2, valve_1, valve_2):
network.add_component(component)
network.connect("step_1", "out", "valve_1", "res")
network.connect("step_1", "out", "valve_2", "res")
with self.assertRaisesRegex(ValueError, "already has a driver"):
network.connect("step_2", "out", "valve_1", "res")
SignalResolver(network).solve(0.2)
self.assertAlmostEqual(valve_1.res.signal, 0.75)
self.assertAlmostEqual(valve_2.res.signal, 0.75)
def test_signal_resolver_event_times_are_sorted_and_deduplicated(self) -> None:
network = SimulationNetwork("signal-events")
network.add_component(
AmesimStep0("step_2", self.medium, initial=0.0, final=1.0, time=0.2)
)
network.add_component(
AmesimStep0("step_1", self.medium, initial=1.0, final=0.0, time=0.1)
)
network.add_component(
AmesimStep0("step_3", self.medium, initial=0.0, final=1.0, time=0.2)
)
resolver = SignalResolver(network)
self.assertEqual(resolver.event_times(0.0, 0.3), (0.1, 0.2))
self.assertEqual(resolver.event_times(0.2, 0.3), ())
if __name__ == "__main__":
unittest.main()
-1
View File
@@ -114,7 +114,6 @@ class AmesimUd00XmlTests(unittest.TestCase):
self.assertEqual(result["series"]["piecewise_1.out.signal"], [1.0, 1.0, 1.0])
self.assertEqual(result["series"]["valve_1.res.signal"], [1.0, 1.0, 1.0])
self.assertIn("valve_1.xv", result["series"])
self.assertGreater(result["diagnostics"]["signal"]["propagations"], 0)
if __name__ == "__main__":
-474
View File
@@ -1,474 +0,0 @@
from __future__ import annotations
import unittest
from math import exp
from app.simulation.components.amesim.flow.pipes import AmesimPnl0001
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
from app.simulation.components.amesim.mechanical.pistons import AmesimPnrp17
from app.simulation.components.amesim.mechanical.translational import (
AmesimLstp00a,
AmesimMecmas21,
)
from app.simulation.components.amesim.storage.chambers import AmesimPnch012
from app.simulation.core.medium import IdealGasMedium
class AnalyticTangentPrimitiveTests(unittest.TestCase):
def assert_tangent_close(
self,
actual: float,
expected: float,
*,
relative: float = 5.0e-5,
absolute: float = 1.0e-8,
) -> None:
self.assertAlmostEqual(
actual,
expected,
delta=max(absolute, relative * max(abs(actual), abs(expected))),
)
def test_peng_robinson_m_u_v_linearization_matches_centered_difference(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
pressure = 15.3e6
temperature = 293.15
volume = 0.01
mass = medium.density(pressure, temperature) * volume
energy = mass * medium.specific_internal_energy_at_pressure(
pressure,
temperature,
)
linearization = medium.linearize_properties_from_mU(
mass,
energy,
volume,
(1.0, 0.0, 0.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, 1.0),
)
self.assertTrue(linearization.valid, linearization.reason)
arguments = (mass, energy, volume)
steps = (mass * 1.0e-6, abs(energy) * 1.0e-6, volume * 1.0e-6)
for direction, step in enumerate(steps):
lower = list(arguments)
upper = list(arguments)
lower[direction] -= step
upper[direction] += step
lower_props = medium.properties_from_mU(*lower)
upper_props = medium.properties_from_mU(*upper)
for field in ("p", "T", "rho", "u", "h"):
finite_difference = (
getattr(upper_props, field) - getattr(lower_props, field)
) / (2.0 * step)
tangent = getattr(linearization.tangents, field)[direction]
self.assert_tangent_close(
tangent,
finite_difference,
relative=2.0e-4,
absolute=1.0e-6,
)
def test_pnrp17_geometry_and_force_tangent_is_exact(self) -> None:
piston = AmesimPnrp17("piston", IdealGasMedium(), dp=0.2, dr=0.01, x0=0.1)
piston.port_4.x = 0.02
piston.port_5.x = 0.05
piston.port_4.v = -0.2
piston.port_5.v = 0.3
piston.port_1.p = 2.0e5
result = piston.linearize_geometry_and_force(
(1.0, 0.0),
(0.0, 2.0),
(3.0, 0.0),
(0.0, 4.0),
(5.0, 6.0),
)
area = piston.effective_area
self.assertTrue(result.valid)
self.assertEqual(result.volume_tangent, (-area, 2.0 * area))
self.assertEqual(result.volume_flow_tangent, (-3.0 * area, 4.0 * area))
self.assertEqual(result.pressure_force_tangent, (5.0 * area, 6.0 * area))
def test_pnch012_balance_tangent_matches_directional_difference(self) -> None:
chamber = AmesimPnch012(
"chamber",
IdealGasMedium(),
cvol0=0.02,
kth=3.0,
sth=0.4,
p0=2.0e5,
T0=300.0,
)
chamber.port_1.volume = 0.003
chamber.port_1.volume_flow = 2.0e-4
flows = (0.02, -0.01, 0.005, -0.004)
enthalpies = {
"port_1": 330000.0,
"port_2": 310000.0,
"port_3": 320000.0,
"port_4": 300000.0,
}
for port_name, flow in zip(
("port_1", "port_2", "port_3", "port_4"),
flows,
strict=True,
):
chamber.get_port(port_name).m_flow = flow
dm = (1.0e-5,)
dU = (2.0,)
dV = (3.0e-6,)
dVdt = (-4.0e-5,)
dq = {
"port_1": (2.0e-3,),
"port_2": (-1.0e-3,),
"port_3": (3.0e-3,),
"port_4": (-2.0e-3,),
}
dh = {
"port_1": (20.0,),
"port_2": (-10.0,),
"port_3": (30.0,),
"port_4": (-20.0,),
}
result = chamber.linearize_state_derivative(
enthalpies,
state_mass_tangent=dm,
state_energy_tangent=dU,
external_volume_tangent=dV,
external_volume_rate_tangent=dVdt,
port_mass_flow_tangents=dq,
connected_h_tangents=dh,
)
self.assertTrue(result.valid, result.reason)
original_state = chamber.get_state_vector()
original_volume = chamber.port_1.volume
original_volume_flow = chamber.port_1.volume_flow
epsilon = 1.0e-4
def evaluate(sign: float) -> list[float]:
chamber.set_state_vector(
[
original_state[0] + sign * epsilon * dm[0],
original_state[1] + sign * epsilon * dU[0],
]
)
chamber.port_1.volume = original_volume + sign * epsilon * dV[0]
chamber.port_1.volume_flow = (
original_volume_flow + sign * epsilon * dVdt[0]
)
perturbed_h = {}
for port_name in enthalpies:
port = chamber.get_port(port_name)
base_flow = flows[int(port_name[-1]) - 1]
port.m_flow = base_flow + sign * epsilon * dq[port_name][0]
perturbed_h[port_name] = (
enthalpies[port_name] + sign * epsilon * dh[port_name][0]
)
return chamber.state_derivative_from_ports(perturbed_h)
lower = evaluate(-1.0)
upper = evaluate(1.0)
chamber.set_state_vector(original_state)
chamber.port_1.volume = original_volume
chamber.port_1.volume_flow = original_volume_flow
for port_name, flow in zip(
("port_1", "port_2", "port_3", "port_4"),
flows,
strict=True,
):
chamber.get_port(port_name).m_flow = flow
for row in range(2):
finite_difference = (upper[row] - lower[row]) / (2.0 * epsilon)
self.assert_tangent_close(
result.tangents[row][0],
finite_difference,
relative=1.0e-5,
absolute=1.0e-6,
)
def test_pnl0001_local_flow_slope_and_balance_tangent(self) -> None:
medium = IdealGasMedium()
pipe = AmesimPnl0001(
"pipe",
medium,
diam=0.02,
le=0.5,
kth=2.0,
p0=2.0e5,
T0=300.0,
)
slope = pipe.linearize_mass_flow(2.2e5, 1.8e5, 300.0)
self.assertTrue(slope.valid, slope.reason)
self.assertGreater(slope.partial_p_1, 0.0)
self.assertLess(slope.partial_p_2, 0.0)
self.assertLess(slope.partial_temperature, 0.0)
boundary = pipe.linearize_mass_flow(2.0e5, 2.0e5, 300.0)
self.assertFalse(boundary.valid)
self.assertEqual(boundary.reason, "flow_direction_boundary")
pipe.port_1.m_flow = 0.02
pipe.port_2.m_flow = -0.01
connected_h = {"port_1": 330000.0, "port_2": 310000.0}
dm = (1.0e-6,)
dU = (0.3,)
dq = {"port_1": (2.0e-3,), "port_2": (-1.0e-3,)}
dh = {"port_1": (20.0,), "port_2": (-10.0,)}
result = pipe.linearize_state_derivative(
connected_h,
state_mass_tangent=dm,
state_energy_tangent=dU,
port_mass_flow_tangents=dq,
connected_h_tangents=dh,
)
self.assertTrue(result.valid, result.reason)
original_state = pipe.get_state_vector()
epsilon = 1.0e-4
def evaluate(sign: float) -> list[float]:
pipe.set_state_vector(
[
original_state[0] + sign * epsilon * dm[0],
original_state[1] + sign * epsilon * dU[0],
]
)
pipe.port_1.m_flow = 0.02 + sign * epsilon * dq["port_1"][0]
pipe.port_2.m_flow = -0.01 + sign * epsilon * dq["port_2"][0]
return pipe.state_derivative_from_ports(
{
name: value + sign * epsilon * dh[name][0]
for name, value in connected_h.items()
}
)
lower = evaluate(-1.0)
upper = evaluate(1.0)
for row in range(2):
finite_difference = (upper[row] - lower[row]) / (2.0 * epsilon)
self.assert_tangent_close(
result.tangents[row][0],
finite_difference,
relative=1.0e-5,
absolute=1.0e-6,
)
def test_lstp_fixed_mode_tangent_and_boundary_guard(self) -> None:
contact = AmesimLstp00a(
"contact",
IdealGasMedium(),
gap0=0.001,
kcont=2000.0,
rcont=10.0,
Pdis=0.0005,
)
contact.port_1.x = 0.002
contact.port_2.x = 0.0
contact.port_1.v = 0.3
contact.port_2.v = -0.1
result = contact.linearize_contact_force(
(0.4,),
(-0.2,),
(0.3,),
(-0.1,),
)
self.assertTrue(result.valid, result.reason)
epsilon = 1.0e-7
originals = (
contact.port_1.x,
contact.port_2.x,
contact.port_1.v,
contact.port_2.v,
)
def evaluate(sign: float) -> float:
contact.port_1.x = originals[0] + sign * epsilon * 0.4
contact.port_2.x = originals[1] + sign * epsilon * -0.2
contact.port_1.v = originals[2] + sign * epsilon * 0.3
contact.port_2.v = originals[3] + sign * epsilon * -0.1
return contact.contact_force
finite_difference = (evaluate(1.0) - evaluate(-1.0)) / (2.0 * epsilon)
self.assert_tangent_close(result.force_tangent[0], finite_difference)
contact.clear_causal_contact()
contact.port_1.x = contact.gap0
contact.port_2.x = 0.0
boundary = contact.linearize_contact_force(
(1.0,),
(0.0,),
(0.0,),
(0.0,),
)
self.assertFalse(boundary.valid)
self.assertEqual(boundary.reason, "contact_mode_boundary")
def test_lstp_causal_contact_retains_a_differentiable_local_mode(self) -> None:
contact = AmesimLstp00a(
"causal_contact",
IdealGasMedium(),
gap0=0.0,
kcont=3000.0,
rcont=12.0,
Pdis=0.001,
)
contact.port_1.x = 1000.0
contact.port_2.x = 1000.0
contact.port_1.v = 0.2
contact.port_2.v = -0.1
penetration = 0.002
damping_fraction = 1.0 - exp(-penetration / contact.Pdis)
force = (
contact.kcont * penetration
+ damping_fraction * contact.rcont * contact.penetration_velocity
)
contact.set_causal_contact(penetration=penetration, force=force)
result = contact.linearize_contact_force(
(0.3,),
(-0.2,),
(0.4,),
(-0.1,),
)
self.assertTrue(result.valid, result.reason)
epsilon = 1.0e-6
originals = (
contact.port_1.x,
contact.port_2.x,
contact.port_1.v,
contact.port_2.v,
)
def evaluate(sign: float) -> float:
contact.port_1.x = originals[0] + sign * epsilon * 0.3
contact.port_2.x = originals[1] + sign * epsilon * -0.2
contact.port_1.v = originals[2] + sign * epsilon * 0.4
contact.port_2.v = originals[3] + sign * epsilon * -0.1
return contact.contact_force
finite_difference = (evaluate(1.0) - evaluate(-1.0)) / (2.0 * epsilon)
self.assert_tangent_close(
result.force_tangent[0],
finite_difference,
relative=2.0e-5,
)
def test_mecmas_soft_endstop_tangents_match_centered_difference(self) -> None:
cases = (
{
"name": "lower",
"x0": -0.02,
"xmin": 0.0,
"xmax": 1.0,
},
{
"name": "upper",
"x0": 1.02,
"xmin": 0.0,
"xmax": 1.0,
},
)
for case in cases:
with self.subTest(side=case["name"]):
mass = AmesimMecmas21(
f"mass_{case['name']}",
IdealGasMedium(),
mass=2.0,
useFriction=1.0,
stoptype=2.0,
discContactOption=1.0,
Kbmin=1000.0,
Dbmin=20.0,
Pdmin=0.1,
Kbmax=1200.0,
Dbmax=30.0,
Pdmax=0.1,
v0=0.3,
x0=case["x0"],
xmin=case["xmin"],
xmax=case["xmax"],
)
mass.port_1.f = 5.0
mass.port_2.f = -1.0
result = mass.linearize_state_derivative(
(0.2,),
(-0.1,),
(-0.25,),
(0.4,),
constraint_mode="free",
)
self.assertTrue(result.valid, result.reason)
epsilon = 1.0e-7
original_v = mass.v
original_x = mass.x
def evaluate(sign: float) -> float:
mass.v = original_v + sign * epsilon * -0.25
mass.x = original_x + sign * epsilon * 0.4
mass.port_1.f = 5.0 + sign * epsilon * 0.2
mass.port_2.f = -1.0 + sign * epsilon * -0.1
return mass.unconstrained_acceleration()
finite_difference = (
evaluate(1.0) - evaluate(-1.0)
) / (2.0 * epsilon)
self.assert_tangent_close(
result.tangents[0][0],
finite_difference,
relative=1.0e-6,
)
def test_mecmas_free_and_fixed_mode_tangents(self) -> None:
mass = AmesimMecmas21(
"mass",
IdealGasMedium(),
mass=2.0,
useFriction=2.0,
rvisc=3.0,
wind=0.5,
fcoul=0.0,
stoptype=4.0,
v0=2.0,
)
mass.port_1.f = 10.0
mass.port_2.f = -2.0
result = mass.linearize_state_derivative(
(0.3,),
(-0.1,),
(0.2,),
(0.4,),
constraint_mode="free",
)
self.assertTrue(result.valid, result.reason)
epsilon = 1.0e-6
original_v = mass.v
original_x = mass.x
def evaluate(sign: float) -> float:
mass.v = original_v + sign * epsilon * 0.2
mass.x = original_x + sign * epsilon * 0.4
mass.port_1.f = 10.0 + sign * epsilon * 0.3
mass.port_2.f = -2.0 + sign * epsilon * -0.1
return mass.unconstrained_acceleration()
finite_difference = (evaluate(1.0) - evaluate(-1.0)) / (2.0 * epsilon)
self.assert_tangent_close(result.tangents[0][0], finite_difference)
self.assertEqual(result.tangents[1], (0.2,))
mass.set_constraint_motion(0.0, velocity=0.0)
fixed = mass.linearize_state_derivative(
(1.0,),
(1.0,),
(1.0,),
(1.0,),
constraint_mode="lower",
)
self.assertTrue(fixed.valid, fixed.reason)
self.assertEqual(fixed.tangents, ((0.0,), (0.0,)))
if __name__ == "__main__":
unittest.main()
-949
View File
@@ -1,949 +0,0 @@
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import sys
import tempfile
import textwrap
import unittest
from unittest.mock import patch
from app.simulation.benchmark_regression import (
DEFAULT_MANIFEST_PATH,
RegressionCaseRequest,
RegressionManifestError,
derive_simulation_xml,
evaluate_regression_golden,
load_regression_manifest,
load_regression_golden,
main,
run_bounded_child_process,
run_regression_suite,
runtime_snapshot,
source_simulation_config,
summarize_simulation_result,
)
PHYSICAL_STATE_V21_REPORT = (
DEFAULT_MANIFEST_PATH.parent
/ "runs"
/ "2026-08-18-production-physical-state-v2.1-0.2.json"
)
def _completed_result(request: RegressionCaseRequest, *, wall_seconds: float = 0.01):
signal_times = (
[0.04, 0.8]
if request.stop_time >= 1.0
else [0.04] if request.stop_time >= 0.04 else []
)
return {
"outcome": "completed",
"orchestrationWallSeconds": wall_seconds,
"worker": {
"outcome": "completed",
"wallSeconds": wall_seconds,
"lastSimulatedTime": request.stop_time,
"summary": {
"success": True,
"status": "completed",
"simulatedUntil": request.stop_time,
"seriesHealth": {
"seriesCount": 1,
"scalarCount": 2,
"nonfiniteCount": 0,
"timeStrictlyIncreasing": True,
"timeStart": 0.0,
"timeEnd": request.stop_time,
},
"checkpoints": [
{
"requestedTime": checkpoint,
"actualTime": checkpoint,
"available": True,
"stateValues": {"state.placeholder": 0.0},
}
for checkpoint in request.checkpoint_times
],
"diagnostics": {
"pressureFlow": {"maxScaledResidual": 1.0e-12},
},
"eventTrace": {
"signalEventTimes": signal_times,
"segments": [
{"startTime": start_time}
for start_time in (0.0, *signal_times)
],
"mechanicalTransitionTimes": [],
"mechanicalTransitionTimesAvailable": True,
},
"physicalContract": {
"schemaVersion": 1,
"projectionCategories": ["state"],
"checkpoints": [
{
"requestedTime": checkpoint,
"actualTime": checkpoint,
"available": True,
"stateValues": {"state.placeholder": 0.0},
}
for checkpoint in request.checkpoint_times
],
"eventTrace": {
"signalEventTimes": signal_times,
"segments": [
{"startTime": start_time}
for start_time in (0.0, *signal_times)
],
"mechanicalTransitionTimes": [],
"mechanicalTransitionTimesAvailable": True,
},
},
"outputContract": {
"schemaVersion": 1,
"sha256": "0" * 64,
"variableCount": 1,
"seriesKeyCount": 1,
"sampleCount": 2,
},
},
},
}
class RegressionManifestTests(unittest.TestCase):
def test_runtime_snapshot_records_repository_identity(self) -> None:
repository = runtime_snapshot()["repository"]
if repository["head"] is not None:
self.assertEqual(len(repository["head"]), 40)
self.assertIn(repository["dirty"], (True, False, None))
self.assertIsInstance(repository["status"], list)
def test_manifest_locks_authoritative_source_and_two_sampling_lanes(self) -> None:
manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH)
self.assertEqual(
manifest["sequence"],
["0.01s-smoke", "0.2s", "1s", "5s", "10s"],
)
self.assertTrue(manifest["source"]["xmlIsAuthoritative"])
self.assertEqual(
manifest["source"]["sha256"],
"0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b",
)
self.assertEqual(Path(manifest["_sourcePath"]).name, "test-mql-8.xml")
self.assertEqual(
manifest["source"]["companionProject"]["sha256"],
"b44bf540ccd1c293fe2af2b9b9052b540abf83961ad955a0f6a4ab40fbe0bb18",
)
self.assertFalse(
manifest["source"]["companionProject"]["executionInput"]
)
self.assertEqual(
manifest["source"]["referenceArchive"]["role"],
"authoritativePhysicalBaseline",
)
self.assertEqual(
Path(manifest["_referenceArchivePath"]).name,
"test_mql.ame",
)
self.assertEqual(manifest["historicalReports"][0]["status"], "historicalOnly")
self.assertFalse(
manifest["historicalReports"][0]["compatibleWithCurrentSource"]
)
self.assertEqual(manifest["lanes"]["production"]["samplingMode"], "source")
self.assertEqual(manifest["lanes"]["solver-only"]["sampleStep"], 0.02)
self.assertEqual(manifest["lanes"]["solver-only"]["maxStep"], 0.05)
self.assertEqual(manifest["historicalReports"][2]["status"], "historicalOnly")
self.assertFalse(
manifest["historicalReports"][2]["compatibleWithCurrentSource"]
)
physical_state_golden = manifest["_physicalStateV21Goldens"]["0.2s"][
"production"
]
self.assertEqual(physical_state_golden["approval"]["status"], "approved")
self.assertEqual(
physical_state_golden["_sha256"],
"6f0752afe8c1f7690599c2709a76d9a1342f99f726c6c8593321dc6267e175b2",
)
self.assertTrue(
physical_state_golden["amesimAlignmentAtGeneration"]["passed"]
)
self.assertEqual(
manifest["correctness"]["physicalBaselineAuthority"], "amesim"
)
self.assertTrue(manifest["correctness"]["compareAmesimOnEveryRun"])
self.assertEqual(
manifest["correctness"]["pythonGoldenRole"],
"determinismDiagnosticOnly",
)
self.assertEqual(
manifest["variants"]["0.2s"]["goldens"]["production"]["role"],
"pythonDeterminismRegression",
)
self.assertEqual(
manifest["variants"]["0.2s"]["physicalStateV21Goldens"][
"production"
]["role"],
"amesimPhysicalBaseline",
)
def test_manifest_rejects_physical_state_v21_golden_hash_mismatch(self) -> None:
manifest = json.loads(DEFAULT_MANIFEST_PATH.read_text(encoding="utf-8"))
manifest["variants"]["0.2s"]["physicalStateV21Goldens"]["production"][
"sha256"
] = "0" * 64
with tempfile.TemporaryDirectory(
dir=DEFAULT_MANIFEST_PATH.parents[4], prefix=".v21-manifest-test-"
) as temporary_directory:
manifest_path = Path(temporary_directory) / "manifest.json"
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaisesRegex(
RegressionManifestError,
"physical-state-v2.1 golden hash mismatch",
):
load_regression_manifest(manifest_path)
def test_manifest_rejects_amesim_archive_hash_mismatch(self) -> None:
manifest = json.loads(DEFAULT_MANIFEST_PATH.read_text(encoding="utf-8"))
manifest["source"]["referenceArchive"]["sha256"] = "0" * 64
with tempfile.TemporaryDirectory(
dir=DEFAULT_MANIFEST_PATH.parents[4], prefix=".amesim-manifest-test-"
) as temporary_directory:
manifest_path = Path(temporary_directory) / "manifest.json"
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaisesRegex(
RegressionManifestError,
"AMESim reference archive hash mismatch",
):
load_regression_manifest(manifest_path)
def test_manifest_rejects_amesim_golden_provenance_mismatch(self) -> None:
loaded = load_regression_manifest(DEFAULT_MANIFEST_PATH)
golden = json.loads(
json.dumps(loaded["_physicalStateV21Goldens"]["0.2s"]["production"])
)
golden["provenance"]["amesim"]["archiveSha256"] = "0" * 64
manifest = json.loads(DEFAULT_MANIFEST_PATH.read_text(encoding="utf-8"))
with tempfile.TemporaryDirectory(
dir=DEFAULT_MANIFEST_PATH.parents[4], prefix=".amesim-provenance-test-"
) as temporary_directory:
manifest_path = Path(temporary_directory) / "manifest.json"
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with patch(
"app.simulation.benchmark_regression.load_physical_state_v21_golden",
return_value=golden,
), self.assertRaisesRegex(
RegressionManifestError,
"AMESim baseline provenance",
):
load_regression_manifest(manifest_path)
def test_in_memory_derivative_does_not_change_authoritative_xml(self) -> None:
manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH)
source_path = Path(manifest["_sourcePath"])
before = source_path.read_bytes()
derived = derive_simulation_xml(
before,
stop_time=1.0,
sample_step=0.02,
max_step=0.005,
)
self.assertEqual(source_simulation_config(before)["tStop"], 10.0)
self.assertEqual(source_simulation_config(before)["sampleStep"], 0.01)
self.assertEqual(source_simulation_config(before)["maxStep"], 0.001)
self.assertEqual(source_simulation_config(derived)["tStop"], 1.0)
self.assertEqual(source_simulation_config(derived)["sampleStep"], 0.02)
self.assertEqual(source_simulation_config(derived)["maxStep"], 0.005)
self.assertEqual(source_path.read_bytes(), before)
def test_historical_pure_xml_manifest_does_not_require_companion(self) -> None:
historical_manifest_path = (
DEFAULT_MANIFEST_PATH.parent.parent
/ "test_mql_full_branches"
/ "manifest.json"
)
manifest = load_regression_manifest(historical_manifest_path)
self.assertNotIn("companionProject", manifest["source"])
self.assertIsNone(manifest["_companionPath"])
class RegressionGoldenProtocolTests(unittest.TestCase):
def test_reviewed_golden_is_provenanced_and_compared_numerically(self) -> None:
source_xml_sha256 = "1" * 64
output_contract_sha256 = "2" * 64
generated_at = "2026-08-17T00:00:00+00:00"
checkpoints = [{
"requestedTime": 0.0,
"actualTime": 0.0,
"available": True,
"stateValues": {"state.a": 1.0},
}]
summary = {
"physicalContract": {
"schemaVersion": 1,
"projectionCategories": ["state"],
"checkpoints": checkpoints,
"eventTrace": {},
},
"outputContract": {
"schemaVersion": 1,
"sha256": output_contract_sha256,
},
}
report = {
"generatedAt": generated_at,
"source": {"sha256": source_xml_sha256},
"cases": [{
"caseId": "smoke",
"lane": "solver-only",
"worker": {"summary": summary},
}],
}
with tempfile.TemporaryDirectory() as temporary_directory:
repository_root = Path(temporary_directory)
report_path = repository_root / "source-report.json"
report_path.write_text(
json.dumps(report, sort_keys=True), encoding="utf-8"
)
report_sha256 = hashlib.sha256(report_path.read_bytes()).hexdigest()
state_keys = ["state.a"]
state_layout_sha256 = hashlib.sha256(
json.dumps(
state_keys,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
golden = {
"schemaVersion": 1,
"id": "synthetic-reviewed-golden",
"caseId": "smoke",
"lane": "solver-only",
"sourceXmlSha256": source_xml_sha256,
"approval": {"status": "approved"},
"provenance": {
"sourceReport": {
"path": "source-report.json",
"sha256": report_sha256,
"generatedAt": generated_at,
"metadataCompatibility": {
"status": "current",
"differences": [],
},
}
},
"physicalLayout": {
"projectionCategories": ["state"],
"stateKeys": state_keys,
"stateKeyLayoutSha256": state_layout_sha256,
},
"tolerance": {
"relative": 0.001,
"absolute": 0.01,
"checkpointTimeAbsoluteSeconds": 1.0e-12,
},
"physicalCheckpoints": [{
"requestedTime": 0.0,
"values": [1.0],
}],
"outputContract": {"sha256": output_contract_sha256},
}
golden_path = repository_root / "golden.json"
golden_path.write_text(
json.dumps(golden, sort_keys=True), encoding="utf-8"
)
golden_sha256 = hashlib.sha256(golden_path.read_bytes()).hexdigest()
loaded = load_regression_golden(
golden_path,
expected_sha256=golden_sha256,
repository_root=repository_root,
)
passing = evaluate_regression_golden(summary, loaded)
self.assertTrue(passing["passed"])
self.assertEqual(passing["comparedValueCount"], 1)
self.assertEqual(passing["issues"], [])
changed_physical = json.loads(json.dumps(summary))
changed_physical["physicalContract"]["checkpoints"][0][
"stateValues"
]["state.a"] = 1.02
physical_failure = evaluate_regression_golden(changed_physical, loaded)
self.assertFalse(physical_failure["passed"])
self.assertIn(
"stateCheckpointGoldenValueMismatch", physical_failure["issues"]
)
changed_output = json.loads(json.dumps(summary))
changed_output["outputContract"]["sha256"] = "3" * 64
output_failure = evaluate_regression_golden(changed_output, loaded)
self.assertFalse(output_failure["passed"])
self.assertIn("outputContractMismatch", output_failure["issues"])
class BoundedChildProcessTests(unittest.TestCase):
def test_short_json_worker_completes_without_timeout(self) -> None:
script = textwrap.dedent(
"""
import json
print(json.dumps({"event": "progress", "simulatedTime": 0.1}), flush=True)
print(json.dumps({
"event": "result",
"outcome": "completed",
"lastSimulatedTime": 0.2,
"wallSeconds": 0.01,
}), flush=True)
"""
)
result = run_bounded_child_process(
[sys.executable, "-u", "-c", script],
soft_timeout_seconds=1.0,
hard_timeout_seconds=2.0,
termination_grace_seconds=0.2,
)
self.assertEqual(result["outcome"], "completed")
self.assertFalse(result["softCancelSent"])
self.assertFalse(result["hardTimeoutReached"])
self.assertEqual(result["lastSimulatedTime"], 0.2)
def test_parent_requests_soft_cancel_before_hard_timeout(self) -> None:
script = textwrap.dedent(
"""
import json
import sys
print(json.dumps({"event": "progress", "simulatedTime": 0.125}), flush=True)
for line in sys.stdin:
if line.strip() == "cancel":
print(json.dumps({
"event": "result",
"outcome": "cancelled",
"lastSimulatedTime": 0.125,
"wallSeconds": 0.1,
}), flush=True)
break
"""
)
result = run_bounded_child_process(
[sys.executable, "-u", "-c", script],
soft_timeout_seconds=0.08,
hard_timeout_seconds=1.0,
termination_grace_seconds=0.2,
)
self.assertEqual(result["outcome"], "soft_timeout")
self.assertTrue(result["softCancelSent"])
self.assertFalse(result["hardTimeoutReached"])
self.assertEqual(result["lastSimulatedTime"], 0.125)
def test_unresponsive_child_is_terminated_at_hard_timeout(self) -> None:
script = textwrap.dedent(
"""
import json
import time
print(json.dumps({"event": "progress", "simulatedTime": 0.05}), flush=True)
time.sleep(5)
"""
)
result = run_bounded_child_process(
[sys.executable, "-u", "-c", script],
soft_timeout_seconds=0.05,
hard_timeout_seconds=0.15,
termination_grace_seconds=0.1,
)
self.assertEqual(result["outcome"], "hard_timeout")
self.assertTrue(result["softCancelSent"])
self.assertTrue(result["hardTimeoutReached"])
self.assertEqual(result["lastSimulatedTime"], 0.05)
def test_child_that_closes_stdin_does_not_break_pipe_cleanup(self) -> None:
script = textwrap.dedent(
"""
import os
import time
os.close(0)
time.sleep(5)
"""
)
result = run_bounded_child_process(
[sys.executable, "-u", "-c", script],
soft_timeout_seconds=0.05,
hard_timeout_seconds=0.15,
termination_grace_seconds=0.1,
)
self.assertEqual(result["outcome"], "hard_timeout")
self.assertTrue(result["softCancelSent"])
self.assertTrue(result["hardTimeoutReached"])
class ProgressiveSuiteTests(unittest.TestCase):
@staticmethod
def _physical_state_v21_result_executor():
source_report = json.loads(
PHYSICAL_STATE_V21_REPORT.read_text(encoding="utf-8")
)
results = {case["caseId"]: case for case in source_report["cases"]}
def execute(request: RegressionCaseRequest) -> dict[str, object]:
return json.loads(json.dumps(results[request.case_id]))
return execute
def test_production_report_replays_through_active_v21_acceptance(self) -> None:
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="production",
case_ids=["0.2s"],
case_executor=self._physical_state_v21_result_executor(),
)
case = report["cases"][1]
evaluation = case["acceptance"]["physicalStateV21Golden"]
self.assertEqual(case["outcome"], "completed")
self.assertTrue(case["acceptance"]["passed"])
self.assertTrue(evaluation["configured"])
self.assertTrue(evaluation["evaluated"])
self.assertTrue(evaluation["passed"])
self.assertEqual(evaluation["baselineAuthority"], "amesim")
self.assertEqual(evaluation["metricCount"], 33)
self.assertEqual(evaluation["availableBaselineValueCount"], 27)
self.assertEqual(evaluation["unavailableBaselineValueCount"], 6)
self.assertEqual(evaluation["comparedValueCount"], 25)
self.assertEqual(evaluation["excludedValueCount"], 2)
self.assertAlmostEqual(
evaluation["maxToleranceRatio"], 0.6967416724836332
)
self.assertAlmostEqual(
evaluation["worstValue"]["relativeError"],
0.0013934852135053246,
)
self.assertTrue(
all(
"actual" in metric and "amesimBaseline" in metric
for metric in evaluation["metrics"]
)
)
self.assertEqual(evaluation["issues"], [])
self.assertTrue(evaluation["amesimAlignmentAtGenerationPassed"])
regression = case["acceptance"]["regressionGolden"]
self.assertEqual(regression["role"], "determinismDiagnosticOnly")
self.assertFalse(regression["affectsPhysicalCorrectness"])
def test_v21_conservation_drift_fails_runner_acceptance(self) -> None:
execute_source = self._physical_state_v21_result_executor()
def drifted(request: RegressionCaseRequest) -> dict[str, object]:
result = execute_source(request)
if request.case_id == "0.2s":
result["worker"]["summary"]["physicalStateV21"]["checkpoints"][-1][
"values"
]["conservation.p4node2_8.massBalance"] = 2.0e-9
return result
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="production",
case_ids=["0.2s"],
case_executor=drifted,
)
case = report["cases"][1]
evaluation = case["acceptance"]["physicalStateV21Golden"]
self.assertEqual(case["outcome"], "correctness_failed")
self.assertFalse(case["acceptance"]["passed"])
self.assertIn(
"physicalStateV21LocalInvariantMismatch",
case["acceptance"]["issues"],
)
self.assertFalse(evaluation["passed"])
self.assertEqual(
evaluation["localInvariants"]["worstValue"]["key"],
"conservation.p4node2_8.massBalance",
)
def test_python_state_drift_is_diagnostic_when_amesim_gate_passes(self) -> None:
execute_source = self._physical_state_v21_result_executor()
def drifted(request: RegressionCaseRequest) -> dict[str, object]:
result = execute_source(request)
if request.case_id == "0.2s":
values = result["worker"]["summary"]["physicalContract"][
"checkpoints"
][-1]["stateValues"]
first_key = next(iter(values))
values[first_key] += 1.0
return result
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="production",
case_ids=["0.2s"],
case_executor=drifted,
)
case = report["cases"][1]
diagnostic = case["acceptance"]["regressionGolden"]
self.assertEqual(case["outcome"], "completed")
self.assertTrue(case["acceptance"]["passed"])
self.assertFalse(diagnostic["passed"])
self.assertIn("stateCheckpointGoldenValueMismatch", diagnostic["issues"])
self.assertEqual(diagnostic["acceptanceIssues"], [])
self.assertTrue(case["acceptance"]["physicalStateV21Golden"]["passed"])
def test_amesim_baseline_drift_is_the_physical_acceptance_gate(self) -> None:
execute_source = self._physical_state_v21_result_executor()
def drifted(request: RegressionCaseRequest) -> dict[str, object]:
result = execute_source(request)
if request.case_id == "0.2s":
result["worker"]["summary"]["physicalStateV21"]["checkpoints"][-1][
"values"
]["pressure.pnch012_8.absolute"] += 1.0e6
return result
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="production",
case_ids=["0.2s"],
case_executor=drifted,
)
case = report["cases"][1]
evaluation = case["acceptance"]["physicalStateV21Golden"]
self.assertEqual(case["outcome"], "correctness_failed")
self.assertFalse(case["acceptance"]["passed"])
self.assertIn("amesimReferenceToleranceMismatch", case["acceptance"]["issues"])
self.assertFalse(evaluation["passed"])
self.assertEqual(evaluation["worstValue"]["amesimBaseline"], 3625053.2009413484)
def test_requesting_late_case_also_runs_every_predecessor(self) -> None:
calls: list[str] = []
def complete(request: RegressionCaseRequest) -> dict[str, object]:
calls.append(request.case_id)
return _completed_result(request)
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_ids=["5s"],
case_executor=complete,
)
self.assertEqual(calls, ["0.01s-smoke", "0.2s", "1s", "5s"])
self.assertEqual(
[case["outcome"] for case in report["cases"]],
["completed", "completed", "completed", "completed"],
)
def test_manifest_pins_solver_modes_in_every_request(self) -> None:
seen: list[dict[str, str]] = []
def complete(request: RegressionCaseRequest) -> dict[str, object]:
seen.append(dict(request.environment_overrides))
return _completed_result(request)
run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_ids=["0.01s-smoke"],
case_executor=complete,
)
self.assertEqual(
seen,
[{
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": "1",
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": "1",
"SIMULATION_CAUSAL_FAST_PATH": "1",
"SIMULATION_MECHANICAL_ATOL_MODE": "legacy",
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
"SIMULATIONAPP_PROPERTY_CACHE": "on",
}],
)
def test_empty_explicit_case_list_is_rejected(self) -> None:
with self.assertRaisesRegex(
RegressionManifestError,
"No regression variants were selected",
):
run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_ids=[],
case_executor=_completed_result,
)
def test_missing_signal_segment_fails_acceptance(self) -> None:
def missing_segment(request: RegressionCaseRequest) -> dict[str, object]:
result = _completed_result(request)
result["worker"]["summary"]["eventTrace"]["segments"] = [
{"startTime": 0.0}
]
return result
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_ids=["0.2s"],
case_executor=missing_segment,
)
self.assertEqual(report["cases"][1]["outcome"], "correctness_failed")
self.assertIn(
"signalEventSegmentMissing",
report["cases"][1]["acceptance"]["issues"],
)
def test_deferred_suite_returns_nonzero_incomplete_exit_status(self) -> None:
report = {"cases": [
{"outcome": "completed"},
{"outcome": "deferred"},
]}
with patch(
"app.simulation.benchmark_regression.run_regression_suite",
return_value=report,
), patch("builtins.print"):
exit_code = main([])
self.assertEqual(exit_code, 2)
def test_nonfinite_completed_case_fails_acceptance_and_defers_longer_runs(
self,
) -> None:
calls: list[str] = []
def nonfinite_first(request: RegressionCaseRequest) -> dict[str, object]:
calls.append(request.case_id)
result = _completed_result(request)
result["worker"]["summary"]["seriesHealth"]["nonfiniteCount"] = 1
return result
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_executor=nonfinite_first,
)
self.assertEqual(calls, ["0.01s-smoke"])
self.assertEqual(report["cases"][0]["outcome"], "correctness_failed")
self.assertEqual(
report["cases"][0]["acceptance"]["issues"],
["nonfiniteSeries"],
)
self.assertTrue(
all(case["outcome"] == "deferred" for case in report["cases"][1:])
)
def test_failed_predecessor_defers_every_longer_horizon(self) -> None:
calls: list[str] = []
def fail_first(request: RegressionCaseRequest) -> dict[str, object]:
calls.append(request.case_id)
return {"outcome": "error", "worker": None}
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_executor=fail_first,
)
self.assertEqual(calls, ["0.01s-smoke"])
self.assertEqual(report["cases"][0]["outcome"], "error")
self.assertEqual(
[case["outcome"] for case in report["cases"][1:]],
["deferred", "deferred", "deferred", "deferred"],
)
self.assertTrue(
all(
case["reason"] == "predecessorDidNotComplete"
for case in report["cases"][1:]
)
)
def test_prediction_over_budget_defers_before_launching_next_case(self) -> None:
calls: list[str] = []
def expensive_short_case(request: RegressionCaseRequest) -> dict[str, object]:
calls.append(request.case_id)
return _completed_result(request, wall_seconds=200.0)
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_executor=expensive_short_case,
)
self.assertEqual(calls, ["0.01s-smoke", "0.2s"])
self.assertIsNone(report["cases"][1]["predictedWallSeconds"])
third = report["cases"][2]
self.assertEqual(third["outcome"], "deferred")
self.assertEqual(third["reason"], "predictedWallExceedsSoftBudget")
self.assertEqual(third["predictedWallSeconds"], 1500.0)
def test_lanes_pass_distinct_sampling_to_case_executor(self) -> None:
seen: list[tuple[str, float, float]] = []
def complete(request: RegressionCaseRequest) -> dict[str, object]:
seen.append((request.lane, request.sample_step, request.max_step))
return _completed_result(request)
run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_ids=["0.01s-smoke"],
case_executor=complete,
)
run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="production",
case_ids=["0.01s-smoke"],
case_executor=complete,
)
self.assertEqual(
seen,
[("solver-only", 0.02, 0.05), ("production", 0.01, 0.001)],
)
class ResultSummaryTests(unittest.TestCase):
def test_summary_preserves_diagnostics_and_available_event_trace(self) -> None:
result = {
"success": True,
"status": "completed",
"partial": False,
"message": "done",
"simulatedUntil": 0.2,
"requestedStopTime": 0.2,
"variables": [
{"key": "tank.m", "category": "state"},
{"key": "tank.p", "category": "thermodynamic"},
],
"series": {
"time": [0.0, 0.2],
"tank.m": [1.0, 0.9],
"tank.p": [2.0, 1.5],
},
"final": {"tank.m": 0.9, "tank.p": 1.5},
"diagnostics": {
"stateCount": 1,
"signal": {"eventTimes": [0.04]},
"integration": {
"totals": {
"stateTransitionCount": 2,
"solverStartCount": 3,
},
"segments": [
{
"startTime": 0.0,
"requestedStopTime": 0.2,
"simulatedUntil": 0.2,
"stateTransitionCount": 2,
"stateTransitionTimes": [0.11, 0.17],
"solverStartCount": 3,
"recoverableRetryCount": 0,
}
],
},
},
}
summary = summarize_simulation_result(
result,
checkpoint_times=(0.0, 0.2),
sample_step=0.2,
)
self.assertEqual(summary["diagnostics"], result["diagnostics"])
self.assertEqual(summary["eventTrace"]["signalEventTimes"], [0.04])
self.assertEqual(summary["eventTrace"]["stateTransitionCount"], 2)
self.assertTrue(
summary["eventTrace"]["mechanicalTransitionTimesAvailable"]
)
self.assertEqual(
summary["eventTrace"]["mechanicalTransitionTimes"],
[0.11, 0.17],
)
self.assertEqual(
summary["checkpoints"][1]["stateValues"], {"tank.m": 0.9}
)
self.assertEqual(
summary["physicalContract"]["checkpoints"], summary["checkpoints"]
)
self.assertEqual(
summary["physicalContract"]["eventTrace"], summary["eventTrace"]
)
self.assertFalse(summary["outputContract"]["containsPhysicalValues"])
self.assertEqual(summary["seriesHealth"]["nonfiniteCount"], 0)
changed_values = json.loads(json.dumps(result))
changed_values["series"]["tank.m"] = [99.0, -99.0]
changed_values["series"]["tank.p"] = [-2.0, 1000.0]
value_changed_summary = summarize_simulation_result(
changed_values,
checkpoint_times=(0.0, 0.2),
sample_step=0.2,
)
self.assertEqual(
value_changed_summary["outputContract"]["sha256"],
summary["outputContract"]["sha256"],
)
changed_metadata = json.loads(json.dumps(result))
changed_metadata["variables"][0]["unit"] = "kg"
metadata_changed_summary = summarize_simulation_result(
changed_metadata,
checkpoint_times=(0.0, 0.2),
sample_step=0.2,
)
self.assertNotEqual(
metadata_changed_summary["outputContract"]["sha256"],
summary["outputContract"]["sha256"],
)
def test_missing_mechanical_transition_times_are_reported_unavailable(
self,
) -> None:
summary = summarize_simulation_result(
{
"diagnostics": {
"integration": {
"totals": {"stateTransitionCount": 1},
"segments": [{
"startTime": 0.0,
"stateTransitionCount": 1,
}],
},
},
},
checkpoint_times=(),
sample_step=0.1,
)
self.assertFalse(
summary["eventTrace"]["mechanicalTransitionTimesAvailable"]
)
if __name__ == "__main__":
unittest.main()
-411
View File
@@ -1,411 +0,0 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
import unittest
from app.main import compile_reactflow_network, compile_system_xml_network
from app.simulation.solvers.causal_ir import (
CausalIROpcode,
compile_causal_numeric_ir,
)
from app.simulation.systems.generic import GenericFluidSystem
from app.system_xml import validate_system_xml_document
from tests.test_amesim_mechanical_xml import zero_force_mass_project
from tests.test_amesim_pnl0001_xml import amesim_pnl0001_project
from tests.test_amesim_pnvo001_signal_xml import high_pressure_helium_step_project
from tests.test_generic_system_xml_simulation import chain_project
TARGET_XML = Path("tests/data/test-mql-8.xml")
class _SyntheticDirectReaderError(Exception):
pass
def _system(project) -> GenericFluidSystem:
return GenericFluidSystem(compile_reactflow_network(project))
class CausalNumericIRTests(unittest.TestCase):
def _assert_direct_reader_exception_is_controlled(
self,
exception: Exception,
) -> None:
system = _system(amesim_pnl0001_project())
solver = system.pressure_flow_solver
compilation = compile_causal_numeric_ir(solver)
self.assertTrue(compilation.supported, compilation.fallback_reason)
assert compilation.ir is not None
ir = compilation.ir
direct_evaluation = next(
evaluation
for stage in ir.program.effort_stages
for evaluation in stage.evaluations
if evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL
)
evaluators = list(ir.bindings.evaluators)
victim = solver.unknowns[-1]
before = tuple(unknown.read() for unknown in solver.unknowns)
def failing_reader() -> float:
victim.write(victim.read() + 123.0)
raise exception
evaluators[direct_evaluation.evaluator_slot] = failing_reader
failing_ir = replace(
ir,
bindings=replace(ir.bindings, evaluators=tuple(evaluators)),
)
result = failing_ir.execute(
failing_ir.create_workspace(),
transactional=True,
)
self.assertFalse(result.success)
self.assertEqual(
result.fallback_reason,
f"effortEvaluationFailed:{type(exception).__name__}",
)
self.assertTrue(result.rolled_back)
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
before,
)
def test_direct_reader_key_error_returns_controlled_failure(self) -> None:
self._assert_direct_reader_exception_is_controlled(
KeyError("synthetic direct-reader key failure")
)
def test_direct_reader_custom_exception_returns_controlled_failure(
self,
) -> None:
self._assert_direct_reader_exception_is_controlled(
_SyntheticDirectReaderError("synthetic direct-reader failure")
)
def test_direct_reader_memory_error_remains_fatal(self) -> None:
system = _system(amesim_pnl0001_project())
compilation = compile_causal_numeric_ir(system.pressure_flow_solver)
self.assertTrue(compilation.supported, compilation.fallback_reason)
assert compilation.ir is not None
ir = compilation.ir
direct_evaluation = next(
evaluation
for stage in ir.program.effort_stages
for evaluation in stage.evaluations
if evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL
)
evaluators = list(ir.bindings.evaluators)
def failing_reader() -> float:
raise MemoryError("synthetic direct-reader allocation failure")
evaluators[direct_evaluation.evaluator_slot] = failing_reader
failing_ir = replace(
ir,
bindings=replace(ir.bindings, evaluators=tuple(evaluators)),
)
with self.assertRaisesRegex(MemoryError, "allocation failure"):
failing_ir.execute(
failing_ir.create_workspace(),
transactional=True,
)
def test_structure_signature_is_stable_and_excludes_bindings(self) -> None:
first = compile_causal_numeric_ir(
_system(zero_force_mass_project()).pressure_flow_solver
)
second = compile_causal_numeric_ir(
_system(zero_force_mass_project()).pressure_flow_solver
)
self.assertTrue(first.supported)
self.assertTrue(second.supported)
assert first.ir is not None and second.ir is not None
self.assertEqual(
first.ir.program.structural_signature,
second.ir.program.structural_signature,
)
self.assertEqual(
first.ir.program.structural_signature,
first.ir.program.calculate_structural_signature(),
)
self.assertEqual(len(first.ir.program.structural_signature), 64)
changed_binding = replace(
first.ir,
bindings=replace(
first.ir.bindings,
evaluators=tuple(
(lambda evaluate=evaluate: evaluate())
for evaluate in first.ir.bindings.evaluators
),
),
)
self.assertEqual(
changed_binding.program.structural_signature,
first.ir.program.structural_signature,
)
def test_reference_interpreter_matches_existing_object_plan_bitwise(self) -> None:
system = _system(high_pressure_helium_step_project())
state = system.initial_state_vector()
system.rhs(0.041, state)
solver = system.pressure_flow_solver
compilation = compile_causal_numeric_ir(solver)
self.assertTrue(compilation.supported, compilation.fallback_reason)
assert compilation.ir is not None
before = tuple(unknown.read() for unknown in solver.unknowns)
self.assertTrue(solver._execute_causal_effort_plan(("p",)))
for unknown in solver._explicit_flow_unknowns_by_variables[
frozenset(("f", "m_flow"))
]:
unknown.write(0.0)
expected_flow_stages: list[tuple[float, ...]] = []
for stage in solver._explicit_flow_plan:
values = solver._evaluate_explicit_flow_stage(stage)
expected_flow_stages.append(values)
for assignment, value in zip(stage.assignments, values):
assignment.unknown.write(value)
expected = tuple(unknown.read() for unknown in solver.unknowns)
for unknown, value in zip(solver.unknowns, before):
unknown.write(value)
workspace = compilation.ir.create_workspace()
observed: list[tuple[str, int, tuple[int, ...], tuple[float, ...]]] = []
result = compilation.ir.execute(
workspace,
stage_observer=lambda phase, index, slots, values: observed.append(
(phase, index, slots, values)
),
)
actual = tuple(unknown.read() for unknown in solver.unknowns)
self.assertTrue(result.success, result.fallback_reason)
self.assertEqual(actual, expected)
self.assertEqual(
result.effort_assignment_count,
len(compilation.ir.program.effort_stages[0].operations),
)
self.assertEqual(
result.flow_assignment_count,
compilation.ir.program.flow_assignment_count,
)
self.assertEqual(observed[0][0], "effort:p")
self.assertEqual(
sum(item[0] == "flow" for item in observed),
len(compilation.ir.program.flow_stages),
)
self.assertEqual(
tuple(item[3] for item in observed if item[0] == "flow"),
tuple(expected_flow_stages),
)
def test_workspace_is_reused_and_normal_execution_does_not_snapshot(self) -> None:
system = _system(zero_force_mass_project())
system.rhs(0.0, system.initial_state_vector())
compilation = compile_causal_numeric_ir(system.pressure_flow_solver)
assert compilation.ir is not None
workspace = compilation.ir.create_workspace()
array_ids = (
id(workspace.canonical_values),
id(workspace.effort_residuals),
id(workspace.flow_values),
id(workspace.transaction_values),
)
workspace.transaction_values.fill(float("nan"))
first = compilation.ir.execute(workspace)
second = compilation.ir.execute(workspace)
self.assertTrue(first.success)
self.assertTrue(second.success)
self.assertEqual(
array_ids,
(
id(workspace.canonical_values),
id(workspace.effort_residuals),
id(workspace.flow_values),
id(workspace.transaction_values),
),
)
self.assertTrue(
all(value != value for value in workspace.transaction_values)
)
def test_transactional_audit_rolls_back_partial_flow_failure(self) -> None:
system = _system(zero_force_mass_project())
system.rhs(0.0, system.initial_state_vector())
solver = system.pressure_flow_solver
compilation = compile_causal_numeric_ir(solver)
assert compilation.ir is not None
ir = compilation.ir
before = tuple(unknown.read() for unknown in solver.unknowns)
operation = ir.program.flow_stages[0].operations[0]
evaluators = list(ir.bindings.evaluators)
original = evaluators[operation.evaluator_slot]
if operation.opcode is CausalIROpcode.FLOW_DIRECT:
evaluators[operation.evaluator_slot] = lambda: float("nan")
else:
equation_index = operation.equation_indices[0]
def one_nonfinite_component():
values = list(original())
values[equation_index] = float("nan")
return tuple(values)
evaluators[operation.evaluator_slot] = one_nonfinite_component
failing_ir = replace(
ir,
bindings=replace(ir.bindings, evaluators=tuple(evaluators)),
)
result = failing_ir.execute(
failing_ir.create_workspace(),
transactional=True,
)
self.assertFalse(result.success)
self.assertEqual(result.fallback_reason, "nonFiniteFlowAssignment")
self.assertTrue(result.rolled_back)
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
before,
)
def test_unsupported_plan_returns_existing_proof_reason(self) -> None:
solver = _system(chain_project()).pressure_flow_solver
compilation = compile_causal_numeric_ir(solver)
self.assertFalse(compilation.supported)
self.assertIsNone(compilation.ir)
self.assertEqual(
compilation.fallback_reason,
solver._causal_fast_path_fallback_reason,
)
class TargetCausalNumericIRStructureTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
report = validate_system_xml_document(TARGET_XML.read_bytes())
assert report.valid and report.document is not None
cls.system = GenericFluidSystem(
compile_system_xml_network(report.document)
)
cls.compilation = compile_causal_numeric_ir(
cls.system.pressure_flow_solver
)
def test_target_has_expected_canonical_and_compatibility_coordinates(
self,
) -> None:
self.assertTrue(
self.compilation.supported,
self.compilation.fallback_reason,
)
assert self.compilation.ir is not None
program = self.compilation.ir.program
self.assertEqual(len(program.compatibility_slots), 776)
self.assertEqual(len(program.canonical_slots), 452)
self.assertEqual(program.effort_group_count, 116)
self.assertEqual(program.flow_assignment_count, 336)
self.assertEqual(program.effort_scatter_count, 440)
self.assertEqual(program.eliminated_effort_replica_count, 324)
self.assertEqual(
tuple(slot.slot for slot in program.canonical_slots),
tuple(range(452)),
)
def test_target_batches_efforts_and_preserves_flow_stage_layout(self) -> None:
assert self.compilation.ir is not None
program = self.compilation.ir.program
pressure_stage = next(
stage for stage in program.effort_stages if stage.variable == "p"
)
self.assertEqual(len(pressure_stage.operations), 76)
self.assertEqual(len(pressure_stage.evaluations), 40)
self.assertEqual(
sum(
evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL
for evaluation in pressure_stage.evaluations
),
20,
)
self.assertEqual(
sum(
evaluation.opcode
is CausalIROpcode.EFFORT_COMPONENT_RESIDUAL
for evaluation in pressure_stage.evaluations
),
20,
)
self.assertEqual(
self.system.pressure_flow_solver.causal_execution_diagnostics()[
"directEffortAnchorCount"
],
20,
)
self.assertEqual(
[len(stage.target_slots) for stage in program.flow_stages],
[114, 134, 49, 33, 5, 1],
)
def test_target_preserves_exact_direct_sum_assignment_counts(self) -> None:
solver = self.system.pressure_flow_solver
self.assertEqual(
solver.causal_execution_diagnostics()[
"directSumFlowAssignmentCount"
],
38,
)
component_equation_ids = frozenset(
equation.id
for evaluation in solver._component_equation_plan
for equation in evaluation.templates
)
secondary = (
self.system._thermofluid_closure_plan.secondary_block_solvers[0]
)
secondary_direct_count = sum(
assignment.evaluate is not None
and assignment.equation_id in component_equation_ids
for stage in secondary._selected_explicit_flow_plan
for assignment in stage.assignments
)
self.assertEqual(secondary_direct_count, 36)
def test_target_reference_execution_matches_all_compatibility_slots(self) -> None:
assert self.compilation.ir is not None
solver = self.system.pressure_flow_solver
self.system.rhs(0.0, self.system.initial_state_vector())
before = tuple(unknown.read() for unknown in solver.unknowns)
self.assertTrue(solver._execute_causal_effort_plan(("p",)))
self.assertIsNone(solver._execute_compiled_causal_flow_plan())
expected = tuple(unknown.read() for unknown in solver.unknowns)
for unknown, value in zip(solver.unknowns, before):
unknown.write(value)
result = self.compilation.ir.execute(
self.compilation.ir.create_workspace()
)
self.assertTrue(result.success, result.fallback_reason)
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
expected,
)
if __name__ == "__main__":
unittest.main()
+3 -39
View File
@@ -9,7 +9,6 @@ from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.base import Component
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition, PortState, PortVariableDefinition
from app.simulation.examples.testmodel.system import TestModelSystem
from app.simulation.systems.network import SimulationNetwork
from tests.test_system_xml_protocol import physical_connection_project
@@ -108,7 +107,7 @@ class ComponentInterfaceTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "variable contracts do not match"):
network.connect("cylinder_1", "port_b", "incompatible_1", "port_a")
def test_connection_equations_are_executable_and_endpoint_neutral(self) -> None:
def test_connection_equations_declare_endpoint_neutral_constraints(self) -> None:
medium = IdealGasMedium()
network = SimulationNetwork("residual-check")
cylinder = Cylinder("cylinder_1", medium)
@@ -124,13 +123,12 @@ class ComponentInterfaceTests(unittest.TestCase):
residuals = {
residual.variables[0].rsplit(".", 1)[-1]: residual
for residual in network.connection_equation_residuals()
for residual in network.connection_equation_definitions()
}
self.assertEqual(set(residuals), {"p", "m_flow"})
self.assertEqual(residuals["p"].relation, "equal")
self.assertEqual(residuals["m_flow"].relation, "sumToZero")
self.assertEqual(residuals["p"].value, 0.0)
self.assertEqual(residuals["m_flow"].value, 0.0)
self.assertFalse(hasattr(residuals["p"], "value"))
def test_tee_ports_are_physical_and_bidirectional(self) -> None:
tee = Tee("tee_1")
@@ -146,41 +144,7 @@ class ComponentInterfaceTests(unittest.TestCase):
)
)
def test_existing_testmodel_topology_still_builds(self) -> None:
system = TestModelSystem()
self.assertEqual(len(system.network.components), 8)
self.assertEqual(len(system.network.connections), 8)
self.assertTrue(
all(connection.kind == "physical" for connection in system.network.connections)
)
self.assertEqual(len(system.initial_state_vector()), 8)
def test_testmodel_snapshot_satisfies_acausal_connection_equations(self) -> None:
system = TestModelSystem()
state = system.consistent_initial_state_vector()
system.snapshot(state)
residuals = system.network.pressure_flow_equation_residuals()
self.assertEqual(len(residuals), 4 * len(system.network.connections))
self.assertEqual(len(system.network.pressure_flow_unknowns()), len(residuals))
for residual in residuals:
tolerance = 1e-6
self.assertLessEqual(
abs(residual.value),
tolerance,
msg=f"{residual.id} residual is {residual.value}",
)
upstream_tee = system.mytee
self.assertAlmostEqual(
upstream_tee.port_in.m_flow
+ upstream_tee.port_out1.m_flow
+ upstream_tee.port_out2.m_flow,
0.0,
places=12,
)
self.assertAlmostEqual(upstream_tee.port_in.p, upstream_tee.port_out1.p)
self.assertAlmostEqual(upstream_tee.port_in.p, upstream_tee.port_out2.p)
if __name__ == "__main__":
-10
View File
@@ -9,7 +9,6 @@ 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.core.ports import PortVariableDefinition
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
@@ -21,7 +20,6 @@ class ComponentMetadataTests(unittest.TestCase):
return (
Cylinder("cylinder_1", self.medium),
Tank("tank_1", self.medium),
Pipe("pipe_dynamic_1", self.medium),
ResistivePipe("pipe_1", self.medium),
Orifice("orifice_1"),
Tee("tee_1"),
@@ -86,17 +84,9 @@ class ComponentMetadataTests(unittest.TestCase):
def test_result_metadata_exactly_describes_each_exposed_value(self) -> None:
for component in self.component_instances():
with self.subTest(component=component.name):
values = component.result_values()
metadata = component.result_variable_metadata()
keys = [variable.key for variable in metadata]
self.assertEqual(len(keys), len(set(keys)))
self.assertEqual(
set(values),
{
variable.key.removeprefix(f"{component.name}.")
for variable in metadata
},
)
self.assertTrue(all(variable.label for variable in metadata))
self.assertTrue(all(variable.quantity for variable in metadata))
-218
View File
@@ -1,218 +0,0 @@
from __future__ import annotations
from math import exp
import unittest
from app.simulation.components.amesim.mechanical.translational import (
AmesimF000,
AmesimLstp00a,
AmesimMecmas21,
)
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 _PressureCoupledMechanicalLoad(AlgebraicComponent):
PORTS = (
PortDefinition.mechanical_translational("mechanical"),
PortDefinition.pneumatic("pneumatic"),
)
def __init__(
self,
name: str,
*,
initial_force: float,
solved_force: float,
displacement: float,
) -> None:
super().__init__(name=name)
self.solved_force = float(solved_force)
self.mechanical = self.register_declared_port("mechanical")
self.pneumatic = self.register_declared_port("pneumatic")
self.mechanical.x = float(displacement)
self.pneumatic.p = float(initial_force)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:velocity_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.mechanical.v",),
role="effort",
value=self.mechanical.v,
),
EquationResidual(
id=f"{self.name}:pressure_force",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.mechanical.f",
f"{self.name}.pneumatic.p",
),
role="flow",
value=self.mechanical.f + self.pneumatic.p,
),
EquationResidual(
id=f"{self.name}:pressure_closure",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.pneumatic.p",),
role="effort",
value=self.pneumatic.p - self.solved_force,
),
EquationResidual(
id=f"{self.name}:zero_mass_flow",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.pneumatic.m_flow",),
role="flow",
value=self.pneumatic.m_flow,
),
)
class _PrescribedMechanicalLoad(AlgebraicComponent):
PORTS = (PortDefinition.mechanical_translational("port_1"),)
def __init__(
self,
name: str,
*,
force: float,
displacement: float,
velocity: float,
) -> None:
super().__init__(name=name)
self.force = float(force)
self.velocity = float(velocity)
self.port_1 = self.register_declared_port("port_1")
self.port_1.x = float(displacement)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:velocity_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_1.v",),
role="effort",
value=self.port_1.v - self.velocity,
),
EquationResidual(
id=f"{self.name}:force",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_1.f",),
role="flow",
value=self.port_1.f - self.force,
),
)
class ContactSolverCausalizationTests(unittest.TestCase):
def test_nonlinear_binding_tracks_sub_ulp_force_change(self) -> None:
medium = IdealGasMedium()
load = _PressureCoupledMechanicalLoad(
"load",
initial_force=40.0,
solved_force=41.0,
displacement=1.0e9,
)
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=1.0,
useFriction=1.0,
stoptype=4.0,
x0=1.0e9,
v0=0.0,
)
zero = AmesimF000("zero")
network = SimulationNetwork("sub-ulp-dynamic-contact-binding")
for component in (load, contact, mass, zero):
network.add_component(component)
network.connect("load", "mechanical", "contact", "port_1")
network.connect("contact", "port_2", "mass", "port_1")
network.connect("mass", "port_2", "zero", "port_1")
self.assertTrue(network.pressure_flow_structure_dict()["isSquare"])
diagnostics = PressureFlowSolver(network).solve()
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)
self.assertAlmostEqual(contact.penetration, 4.1e-10, delta=1.0e-14)
def test_negative_contact_binding_uses_nearest_feasible_root(self) -> None:
medium = IdealGasMedium()
expected_force = 10.0 - 20.0 * (1.0 - exp(-1.0))
load = _PrescribedMechanicalLoad(
"load",
force=-expected_force,
displacement=0.0,
velocity=0.0,
)
contact = AmesimLstp00a(
"contact",
medium,
gap0=0.0,
kcont=100.0,
rcont=10.0,
Pdis=0.1,
discContactOption=1.0,
)
mass = AmesimMecmas21(
"mass",
medium,
mass=1.0,
useFriction=1.0,
stoptype=4.0,
x0=-0.08,
v0=2.0,
)
zero = AmesimF000("zero")
network = SimulationNetwork("negative-contact-force-binding")
for component in (load, contact, mass, zero):
network.add_component(component)
network.connect("load", "port_1", "contact", "port_1")
network.connect("contact", "port_2", "mass", "port_1")
network.connect("mass", "port_2", "zero", "port_1")
self.assertTrue(network.pressure_flow_structure_dict()["isSquare"])
diagnostics = PressureFlowSolver(network).solve()
self.assertTrue(diagnostics.success, diagnostics.message)
self.assertLess(contact.contact_force, 0.0)
self.assertAlmostEqual(contact.contact_force, expected_force, places=10)
self.assertAlmostEqual(contact.penetration, 0.1, places=10)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large. Load diff
-4
View File
@@ -25,9 +25,7 @@ VERIFY_ENVIRONMENT_VARIABLE = "SYSTEM_SIMULATION_VERIFY_LOCKED_ENV"
REFERENCE_DIRECT_VERSIONS = {
"fastapi": "0.141.1",
"lxml": "6.1.1",
"numpy": "2.5.2",
"pydantic": "2.13.4",
"scipy": "1.18.0",
"uvicorn": "0.52.3",
}
REFERENCE_LOCKED_VERSIONS = {
@@ -40,12 +38,10 @@ REFERENCE_LOCKED_VERSIONS = {
"httptools": "0.8.0",
"idna": "3.18",
"lxml": "6.1.1",
"numpy": "2.5.2",
"pydantic": "2.13.4",
"pydantic-core": "2.46.4",
"python-dotenv": "1.2.3",
"pyyaml": "6.0.3",
"scipy": "1.18.0",
"starlette": "1.6.0",
"typing-extensions": "4.16.0",
"typing-inspection": "0.4.4",
-306
View File
@@ -1,306 +0,0 @@
from __future__ import annotations
from collections.abc import Mapping
import os
from types import SimpleNamespace
import unittest
from unittest.mock import patch
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,
_requested_ode_jacobian_mode,
)
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 OdeJacobianModeTests(unittest.TestCase):
def test_scipy_is_the_safe_default(self) -> None:
with patch.dict(os.environ, {}, clear=True):
self.assertEqual(_requested_ode_jacobian_mode(), "scipy")
def test_optimized_and_hybrid_require_explicit_selection(self) -> None:
for value, expected in (("optimized", "optimized"), ("hybrid", "hybrid")):
with self.subTest(value=value), patch.dict(
os.environ,
{"SIMULATION_ODE_JACOBIAN_MODE": value},
):
self.assertEqual(_requested_ode_jacobian_mode(), expected)
def test_invalid_mode_is_rejected(self) -> None:
with patch.dict(
os.environ,
{"SIMULATION_ODE_JACOBIAN_MODE": "unknown"},
), self.assertRaisesRegex(ValueError, "must be 'optimized'"):
_requested_ode_jacobian_mode()
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()
+24 -220
View File
@@ -26,14 +26,11 @@ from app.main import (
)
from app.simulation.components.experimental.flow.resistive_pipe import ResistivePipe
from app.simulation.registry import get_component_model_spec
from app.simulation.solvers.solver import (
from app.simulation.config import (
SolveIVPConfig,
SolverActivityTracker,
)
from app.simulation.systems.generic import (
GenericFluidSystem,
SimulationPreparationError,
)
from app.simulation.native_codegen.runner import simulate_native
from tests.test_system_xml_protocol import physical_port
@@ -250,6 +247,26 @@ def xml_request(body: bytes) -> Request:
class GenericSystemXmlSimulationTests(unittest.TestCase):
def test_native_chain_conserves_mass_energy_and_edge_order(self):
results = []
for reverse in (False, True):
result = simulate_native(compile_reactflow_network(chain_project(reverse_edges=reverse)), SolveIVPConfig(t_stop=.01, max_step=.001), sample_step=.005)
self.assertTrue(result.success)
self.assertEqual(result.diagnostics['backend'], 'native-c')
self.assertLess(result.final['cylinder_1.p'], 500000)
self.assertGreater(result.final['tank_1.p'], 100000)
for field, tolerance in (('m', 1e-12), ('U', 1e-6)):
totals = [a+b for a,b in zip(result.series['cylinder_1.'+field], result.series['tank_1.'+field])]
self.assertLess(max(totals)-min(totals), tolerance)
results.append(result)
self.assertAlmostEqual(results[0].final['tank_1.p'], results[1].final['tank_1.p'], places=7)
def test_native_branched_topology_conserves_flow(self):
result = simulate_native(compile_reactflow_network(branched_project()), SolveIVPConfig(t_stop=.005, max_step=.001), sample_step=.005)
self.assertTrue(result.success)
self.assertAlmostEqual(result.final['pipe_1.port_a.m_flow'], result.final['pipe_2.port_a.m_flow'], places=10)
self.assertGreater(result.final['tank_1.p'], 100000)
def test_xml_pipe_compiles_to_quasi_steady_resistance(self) -> None:
network = compile_reactflow_network(chain_project())
@@ -259,166 +276,8 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
self.assertEqual(structure["equationCount"], 12)
self.assertTrue(structure["isSquare"])
def test_generic_chain_simulation_conserves_mass_and_moves_pressures(self) -> None:
network = compile_reactflow_network(chain_project())
progress: list[tuple[float, str]] = []
activity_tracker = SolverActivityTracker()
system = GenericFluidSystem(network)
result = system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=0.01,
method="BDF",
max_step=0.001,
),
sample_step=0.005,
progress_callback=lambda value, phase: progress.append((value, phase)),
activity_tracker=activity_tracker,
)
self.assertTrue(result.success)
self.assertEqual(progress[0], (0.0, "initializing"))
self.assertEqual(progress[-1], (1.0, "complete"))
self.assertTrue(
all(
current[0] <= following[0]
for current, following in zip(progress, progress[1:])
)
)
self.assertIn("integrating", {phase for _, phase in progress})
self.assertIn("postprocessing", {phase for _, phase in progress})
activity = activity_tracker.snapshot()
self.assertEqual(activity.activity_kind, "complete")
self.assertGreater(activity.rhs_call_count, 0)
self.assertGreater(activity.accepted_step_sequence, 0)
self.assertEqual(activity.accepted_time, 0.01)
self.assertGreater(activity.thermofluid_closure_count, 0)
self.assertEqual(
result.diagnostics["activity"],
activity.as_dict(),
)
self.assertIsNone(system._activity_tracker)
self.assertEqual(
result.diagnostics["integration"][
"mechanicalAbsoluteTolerance"
]["mode"],
"legacy",
)
self.assertLess(result.series["cylinder_1.p"][-1], 500000.0)
self.assertGreater(result.series["tank_1.p"][-1], 100000.0)
total_mass = [
cylinder + tank
for cylinder, tank in zip(
result.series["cylinder_1.m"],
result.series["tank_1.m"],
)
]
self.assertLess(max(total_mass) - min(total_mass), 1e-12)
total_energy = [
cylinder + tank
for cylinder, tank in zip(
result.series["cylinder_1.U"],
result.series["tank_1.U"],
)
]
self.assertLess(max(total_energy) - min(total_energy), 1e-6)
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_activity_tracker_scope_is_restored_after_simulation_error(self) -> None:
system = GenericFluidSystem(compile_reactflow_network(chain_project()))
previous_tracker = SolverActivityTracker()
active_tracker = SolverActivityTracker()
system._activity_tracker = previous_tracker
with (
patch.object(
system,
"_simulate",
side_effect=RuntimeError("synthetic simulation failure"),
),
self.assertRaisesRegex(RuntimeError, "synthetic simulation failure"),
):
system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=0.01,
method="BDF",
max_step=0.001,
),
sample_step=0.005,
activity_tracker=active_tracker,
)
self.assertIs(system._activity_tracker, previous_tracker)
def test_cancelled_simulation_returns_accepted_partial_samples(self) -> None:
cancel_event = threading.Event()
def request_cancel_after_progress(progress: float, phase: str) -> None:
if phase == "integrating" and progress >= 0.2:
cancel_event.set()
with patch.dict(
os.environ,
{"SIMULATION_ODE_JACOBIAN_MODE": "optimized"},
):
result = GenericFluidSystem(
compile_reactflow_network(chain_project())
).simulate(
SolveIVPConfig(t_stop=0.05, method="BDF", max_step=0.001),
sample_step=0.005,
progress_callback=request_cancel_after_progress,
cancel_check=cancel_event.is_set,
)
self.assertFalse(result.success)
self.assertEqual(result.status, "cancelled")
self.assertGreaterEqual(result.diagnostics["sampleCount"], 2)
integration = result.diagnostics["integration"]
self.assertEqual(integration["method"], "BDF")
self.assertEqual(integration["segmentCount"], len(integration["segments"]))
self.assertEqual(
integration["totals"]["nfev"],
sum(segment["nfev"] for segment in integration["segments"]),
)
sparsity = integration["jacobianSparsity"]
self.assertGreater(sparsity["nonzeroCount"], 0)
self.assertGreater(sparsity["colorGroupCount"], 0)
self.assertEqual(
integration["jacobian"]["mode"],
"scipySparseFiniteDifference",
)
self.assertEqual(
integration["jacobian"]["fallbackReason"],
"denseStateDependencyPattern",
)
self.assertEqual(
integration["totals"]["finiteDifferenceRhsEstimate"],
sum(
segment["finiteDifferenceRhsEstimate"]
for segment in integration["segments"]
),
)
self.assertGreater(result.simulated_until, 0.0)
self.assertLess(result.simulated_until, 0.05)
self.assertEqual(
len(result.series["time"]),
len(result.series["cylinder_1.p"]),
)
def test_task_registry_distinguishes_user_stop_and_stalled_stop(self) -> None:
for reason, expected_status in (("user", "stopped"), ("stalled", "stalled")):
@@ -446,63 +305,8 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
self.assertEqual(snapshot["cancelReason"], reason)
self.assertEqual(snapshot["result"]["series"]["time"], [0.0, 0.1])
def test_physical_edge_order_does_not_change_simulation(self) -> None:
forward = GenericFluidSystem(
compile_reactflow_network(chain_project())
).simulate(
SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001),
sample_step=0.005,
)
reverse = GenericFluidSystem(
compile_reactflow_network(chain_project(reverse_edges=True))
).simulate(
SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001),
sample_step=0.005,
)
self.assertAlmostEqual(
forward.final["tank_1.p"],
reverse.final["tank_1.p"],
places=7,
)
def test_branched_topology_is_solved_without_fixed_testmodel_closure(self) -> None:
network = compile_reactflow_network(branched_project())
result = GenericFluidSystem(network).simulate(
SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001),
sample_step=0.005,
)
self.assertTrue(result.success)
self.assertAlmostEqual(
result.final["pipe_1.port_a.m_flow"],
result.final["pipe_2.port_a.m_flow"],
places=10,
)
self.assertGreater(result.final["tank_1.p"], 100000.0)
def test_directly_coupled_storage_components_are_rejected(self) -> None:
project = chain_project()
project.nodes = [project.nodes[0], project.nodes[-1]]
project.edges = [
ReactFlowEdgePayload(
**physical_edge(
"edge-1",
"cylinder_1",
"port_b",
"tank_1",
"port_a",
)
)
]
with self.assertRaises(SimulationPreparationError) as caught:
GenericFluidSystem(compile_reactflow_network(project))
self.assertIn(
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
{issue.code for issue in caught.exception.issues},
)
def test_raw_system_xml_runs_through_generic_simulation_endpoint(self) -> None:
xml = build_reactflow_system_xml(chain_project())
@@ -582,7 +386,7 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
project = branched_project()
project.simulation.t_stop = 2.0
project.simulation.step = 0.02
project.simulation.max_step = 0.01
project.simulation.max_step = 0.000001
xml = build_reactflow_system_xml(project)
simulation_id = f"test-{uuid4().hex}"
task = _register_simulation_task(simulation_id)
@@ -704,7 +508,7 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
self.assertEqual(caught.exception.status_code, 422)
self.assertIn(
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
"SIMULATION_EXECUTION_FAILED",
{
issue["code"]
for issue in caught.exception.detail["issues"]
@@ -1,58 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.pipes import AmesimPnl0001, AmesimPnl0003
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import GenericFluidSystem, simulation_preparation_issues
from app.simulation.systems.network import SimulationNetwork
class IdealPneumaticStorageCouplingTests(unittest.TestCase):
def build_network(self) -> SimulationNetwork:
medium = IdealGasMedium()
network = SimulationNetwork("ideal-pipe-compliance-coupling")
for component in (
AmesimPnpl01("closed_1"),
AmesimPnl0001("pnl0001", medium, p0=15.3e6, T0=293.15),
AmesimPnl0003(
"pnl0003",
medium,
p1_0=15.3e6,
T1_0=293.15,
p2_0=15.3e6,
T2_0=293.15,
),
AmesimPnpl01("closed_2"),
):
network.add_component(component)
network.connect("closed_1", "port_1", "pnl0001", "port_1")
network.connect("pnl0001", "port_2", "pnl0003", "port_1")
network.connect("pnl0003", "port_2", "closed_2", "port_1")
return network
def test_supported_amesim_pipe_compliances_are_not_rejected(self) -> None:
issues = simulation_preparation_issues(self.build_network())
self.assertNotIn(
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
{issue.code for issue in issues},
)
def test_coupled_pipe_compliances_run_without_fictitious_resistance(self) -> None:
system = GenericFluidSystem(self.build_network())
result = system.simulate(
SolveIVPConfig(t_stop=0.001, method="BDF", max_step=0.0002),
sample_step=0.001,
)
self.assertTrue(result.success)
self.assertAlmostEqual(result.final["pnl0001.p"], 15.3e6, delta=1.0e-4)
self.assertAlmostEqual(result.final["pnl0003.p1"], 15.3e6, delta=1.0e-4)
if __name__ == "__main__":
unittest.main()
-333
View File
@@ -1,333 +0,0 @@
from __future__ import annotations
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
import unittest
from unittest.mock import patch
from app.simulation.benchmark_regression import (
DEFAULT_MANIFEST_PATH,
RegressionCaseRequest,
RegressionManifestError,
)
from app.simulation.max_step_matrix import main, run_max_step_matrix
def _fake_completed(
request: RegressionCaseRequest,
*,
prefix_bias: float = 0.0,
) -> dict[str, object]:
signal_times = [value for value in (0.04, 0.8) if value <= request.stop_time]
checkpoints = [
{
"requestedTime": checkpoint,
"actualTime": checkpoint,
"available": True,
"stateValues": {
"state.a": checkpoint + prefix_bias,
"state.b": 2.0 * checkpoint,
},
}
for checkpoint in request.checkpoint_times
]
event_trace = {
"signalEventTimes": signal_times,
"stateTransitionCount": 0,
"mechanicalTransitionTimes": [],
"mechanicalTransitionTimesAvailable": True,
}
return {
"outcome": "completed",
"orchestrationWallSeconds": 0.01,
"worker": {
"outcome": "completed",
"wallSeconds": 0.01,
"summary": {
"success": True,
"status": "completed",
"simulatedUntil": request.stop_time,
"checkpoints": checkpoints,
"physicalContract": {
"schemaVersion": 1,
"projectionCategories": ["state"],
"checkpoints": checkpoints,
"eventTrace": event_trace,
},
"eventTrace": event_trace,
"diagnostics": {
"pressureFlow": {"maxScaledResidual": 1.0e-12},
"integration": {
"totals": {
"nfev": int(100 * request.stop_time / request.max_step),
"njev": 4,
"nlu": 8,
"acceptedStepCount": int(
request.stop_time / request.max_step
),
"solverStartCount": len(signal_times) + 1,
"stateTransitionCount": 0,
"recoverableRetryCount": 0,
}
},
},
},
},
}
def _fake_failure(request: RegressionCaseRequest) -> dict[str, object]:
return {
"outcome": "failed",
"worker": {
"outcome": "failed",
"summary": {
"success": False,
"status": "failed",
"simulatedUntil": 0.75 * request.stop_time,
},
},
}
class MaxStepMatrixTests(unittest.TestCase):
def test_single_cell_passes_when_no_comparison_is_required(self) -> None:
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s",),
max_steps=(0.02,),
expected_projection_count=2,
case_executor=_fake_completed,
)
comparisons = report["comparisons"]
self.assertEqual(comparisons["sameHorizonAcrossMaxSteps"], [])
self.assertEqual(comparisons["sameMaxStepAcrossHorizons"], [])
self.assertEqual(comparisons["evaluatedCount"], 0)
self.assertTrue(comparisons["passed"])
self.assertTrue(report["acceptance"]["passed"])
with patch(
"app.simulation.max_step_matrix.run_max_step_matrix",
return_value=report,
), redirect_stdout(StringIO()):
exit_code = main(["--horizon", "1s", "--max-step", "0.02"])
self.assertEqual(exit_code, 0)
def test_multiple_cells_still_fail_when_comparison_is_unavailable(self) -> None:
def execute(request: RegressionCaseRequest) -> dict[str, object]:
if request.max_step == 0.05:
return _fake_failure(request)
return _fake_completed(request)
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s",),
max_steps=(0.01, 0.05),
expected_projection_count=2,
case_executor=execute,
)
comparisons = report["comparisons"]
records = comparisons["sameHorizonAcrossMaxSteps"]
self.assertEqual(len(records), 1)
self.assertFalse(records[0]["evaluated"])
self.assertEqual(records[0]["reason"], "oneOrBothCasesDidNotComplete")
self.assertFalse(comparisons["passed"])
self.assertFalse(report["acceptance"]["passed"])
with patch(
"app.simulation.max_step_matrix.run_max_step_matrix",
return_value=report,
), redirect_stdout(StringIO()):
exit_code = main(
[
"--horizon",
"1s",
"--max-step",
"0.01",
"--max-step",
"0.05",
]
)
self.assertEqual(exit_code, 1)
def test_custom_horizon_and_common_prefix_matrix(self) -> None:
source = Path("tests/data/test-mql-8.xml")
source_before = source.read_bytes()
requests: list[RegressionCaseRequest] = []
def execute(request: RegressionCaseRequest) -> dict[str, object]:
requests.append(request)
return _fake_completed(request)
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s", 2.0),
max_steps=(0.01, 0.05),
additional_checkpoint_times=(1.08, 1.10),
expected_projection_count=2,
case_executor=execute,
)
self.assertEqual(source.read_bytes(), source_before)
self.assertEqual(len(requests), 4)
self.assertEqual(
[(request.stop_time, request.max_step) for request in requests],
[(1.0, 0.01), (1.0, 0.05), (2.0, 0.01), (2.0, 0.05)],
)
custom_requests = [request for request in requests if request.stop_time == 2.0]
self.assertEqual(
custom_requests[0].checkpoint_times,
(0.0, 0.04, 0.8, 1.0, 1.08, 1.1, 2.0),
)
self.assertEqual(
report["configuration"]["horizons"][1]["horizonCaseId"],
"2s-custom",
)
self.assertEqual(
len(report["comparisons"]["sameHorizonAcrossMaxSteps"]), 2
)
self.assertEqual(
len(report["comparisons"]["sameMaxStepAcrossHorizons"]), 2
)
self.assertTrue(report["comparisons"]["passed"])
self.assertTrue(report["acceptance"]["passed"])
def test_failed_tier_defers_every_later_cell(self) -> None:
requests: list[RegressionCaseRequest] = []
def execute(request: RegressionCaseRequest) -> dict[str, object]:
requests.append(request)
if request.max_step == 0.05:
return _fake_failure(request)
return _fake_completed(request)
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s", "5s", "10s"),
max_steps=(0.01, 0.05),
expected_projection_count=2,
case_executor=execute,
)
self.assertEqual(len(requests), 2)
self.assertEqual(report["acceptance"]["deferredCellCount"], 4)
self.assertEqual(
[case["outcome"] for case in report["cases"][2:]],
["deferred"] * 4,
)
self.assertEqual(
report["tierDecisions"][1]["reason"], "previousTierDidNotPass"
)
def test_tstop_dependent_prefix_is_reported(self) -> None:
def execute(request: RegressionCaseRequest) -> dict[str, object]:
return _fake_completed(
request,
prefix_bias=0.1 if request.stop_time > 1.0 else 0.0,
)
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s", 2.0),
max_steps=(0.01, 0.05),
expected_projection_count=2,
stop_after_failed_tier=False,
case_executor=execute,
)
prefix_comparisons = report["comparisons"][
"sameMaxStepAcrossHorizons"
]
self.assertEqual(len(prefix_comparisons), 2)
self.assertTrue(all(item["evaluated"] for item in prefix_comparisons))
self.assertTrue(all(not item["passed"] for item in prefix_comparisons))
self.assertGreater(
prefix_comparisons[0]["stateProjection"]["valueMismatchCount"], 0
)
def test_cross_horizon_comparison_excludes_short_terminal(self) -> None:
def execute(request: RegressionCaseRequest) -> dict[str, object]:
result = _fake_completed(request)
if request.stop_time <= 1.0:
checkpoints = result["worker"]["summary"]["physicalContract"][
"checkpoints"
]
for checkpoint in checkpoints:
if checkpoint["requestedTime"] == request.stop_time:
checkpoint["stateValues"]["state.a"] += 1.0
return result
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s", 2.0),
max_steps=(0.01,),
expected_projection_count=2,
case_executor=execute,
)
comparison = report["comparisons"]["sameMaxStepAcrossHorizons"][0]
self.assertTrue(comparison["strictPrefix"])
self.assertTrue(comparison["passed"])
self.assertNotIn(
1.0,
comparison["stateProjection"]["commonCheckpointTimes"],
)
def test_same_horizon_comparison_keeps_terminal_checkpoint(self) -> None:
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s",),
max_steps=(0.01, 0.05),
expected_projection_count=2,
case_executor=_fake_completed,
)
comparison = report["comparisons"]["sameHorizonAcrossMaxSteps"][0]
self.assertFalse(comparison["strictPrefix"])
self.assertIn(
1.0,
comparison["stateProjection"]["commonCheckpointTimes"],
)
def test_horizons_must_increase_and_timeout_override_is_bounded(self) -> None:
with self.assertRaisesRegex(
RegressionManifestError, "strictly increasing"
):
run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=(2.0, "1s"),
max_steps=(0.01, 0.05),
expected_projection_count=2,
case_executor=_fake_completed,
)
def test_off_grid_checkpoint_is_rejected_before_execution(self) -> None:
with self.assertRaisesRegex(
RegressionManifestError,
"not represented by the .* output grid",
):
run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s",),
max_steps=(0.01,),
additional_checkpoint_times=(0.0489,),
expected_projection_count=2,
case_executor=_fake_completed,
)
with self.assertRaisesRegex(RegressionManifestError, "must exceed"):
run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=(2.0,),
max_steps=(0.01, 0.05),
soft_timeout_seconds=20.0,
hard_timeout_seconds=10.0,
expected_projection_count=2,
case_executor=_fake_completed,
)
if __name__ == "__main__":
unittest.main()
@@ -1,716 +0,0 @@
from __future__ import annotations
from math import exp, isfinite
import unittest
from app.simulation.components.amesim.mechanical.translational import (
AmesimF000,
AmesimForc,
AmesimLstp00a,
AmesimMecmas21,
)
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.solvers.mechanical import MechanicalConstraintGroup
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import GenericFluidSystem
from app.simulation.systems.network import SimulationNetwork
class _AnchoredMechanicalForce(AlgebraicComponent):
"""Test boundary whose modest force must survive unrelated large scales."""
PORTS = (PortDefinition.mechanical_translational("port_1"),)
def __init__(self, name: str, force: float) -> None:
super().__init__(name=name)
self.force = float(force)
self.port_1 = self.register_declared_port("port_1")
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:x_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_1.x",),
role="effort",
value=self.port_1.x,
),
EquationResidual(
id=f"{self.name}:v_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_1.v",),
role="effort",
value=self.port_1.v,
),
EquationResidual(
id=f"{self.name}:force_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_1.f",),
role="flow",
value=self.port_1.f - self.force,
),
)
class _RigidMechanicalLink(AlgebraicComponent):
"""Massless link whose position constraint supplies the rigid coordinate."""
PORTS = (
PortDefinition.mechanical_translational("port_1"),
PortDefinition.mechanical_translational("port_2"),
)
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.port_1 = self.register_declared_port("port_1")
self.port_2 = self.register_declared_port("port_2")
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:x_equal",
owner="component",
owner_id=self.name,
relation="equal",
variables=(f"{self.name}.port_1.x", f"{self.name}.port_2.x"),
role="effort",
value=self.port_1.x - self.port_2.x,
),
EquationResidual(
id=f"{self.name}:v_equal",
owner="component",
owner_id=self.name,
relation="equal",
variables=(f"{self.name}.port_1.v", f"{self.name}.port_2.v"),
role="effort",
value=self.port_1.v - self.port_2.v,
),
)
def _single_mass_system(
applied_force: float,
*,
stoptype: float = 4.0,
x0: float = 0.0,
xmin: float = -1.0,
xmax: float = 1.0,
) -> tuple[GenericFluidSystem, AmesimMecmas21]:
medium = IdealGasMedium()
source = AmesimForc("force")
source.res.signal = applied_force
mass = AmesimMecmas21(
"mass",
medium,
mass=2.0,
useFriction=1.0,
stoptype=stoptype,
x0=x0,
v0=0.0,
xmin=xmin,
xmax=xmax,
)
zero = AmesimF000("zero")
network = SimulationNetwork("single-mass-causalization")
for component in (source, mass, zero):
network.add_component(component)
network.connect("force", "port_2", "mass", "port_1")
network.connect("mass", "port_2", "zero", "port_1")
return GenericFluidSystem(network), mass
def _high_stiffness_contact_impact_system() -> GenericFluidSystem:
"""Return the reduced mechanical core of the four-branch stop impact.
The production model has four 50 kg piston groups coupled through LSTP00A
contacts to one 170000 kg support group. When the support reaches its
ideal upper stop, its velocity is reset while each contact still carries
the finite, extremely fast LSTP damping transient. One branch is enough
to retain that stiffness and state-transition interaction in a fast test.
"""
medium = IdealGasMedium()
zero_left = AmesimF000("zero_left")
moving_mass = AmesimMecmas21(
"moving_mass",
medium,
mass=50.0,
useFriction=1.0,
stoptype=4.0,
x0=0.0090025,
v0=1.0,
)
contact = AmesimLstp00a(
"contact",
medium,
gap0=0.0,
kcont=1.0e11,
rcont=1.0e11,
Pdis=1.0e-7,
discContactOption=1.0,
)
support_mass = AmesimMecmas21(
"support_mass",
medium,
mass=170000.0,
useFriction=1.0,
stoptype=1.0,
xmin=0.0,
xmax=0.01,
x0=0.009,
v0=1.0,
)
zero_right = AmesimF000("zero_right")
network = SimulationNetwork("high-stiffness-contact-impact")
for component in (
zero_left,
moving_mass,
contact,
support_mass,
zero_right,
):
network.add_component(component)
network.connect("zero_left", "port_1", "moving_mass", "port_1")
network.connect("moving_mass", "port_2", "contact", "port_1")
network.connect("contact", "port_2", "support_mass", "port_1")
network.connect("support_mass", "port_2", "zero_right", "port_1")
return GenericFluidSystem(network)
class MechanicalSolverCausalizationTests(unittest.TestCase):
def test_high_stiffness_lstp_survives_ideal_stop_transition(self) -> None:
system = _high_stiffness_contact_impact_system()
result = system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=0.002,
max_step=0.002,
method="BDF",
rtol=1.0e-6,
),
sample_step=0.00025,
)
self.assertTrue(result.success, result.message)
totals = result.diagnostics["integration"]["totals"]
self.assertEqual(totals["stateTransitionCount"], 1)
self.assertEqual(totals["solverStartCount"], 2)
self.assertEqual(totals["recoverableRetryCount"], 0)
self.assertAlmostEqual(result.series["support_mass.x"][-1], 0.01)
self.assertAlmostEqual(result.series["support_mass.v"][-1], 0.0, delta=1.0e-9)
self.assertAlmostEqual(result.series["moving_mass.v"][-1], 0.0, delta=1.0e-4)
self.assertGreater(max(result.series["contact.force"]), 1.0e10)
for values in result.series.values():
self.assertTrue(all(isfinite(value) for value in values))
def test_lstp_contact_uses_exponential_damping_ramp_and_negative_force_option(
self,
) -> None:
medium = IdealGasMedium()
contact = AmesimLstp00a(
"contact",
medium,
gap0=0.0,
kcont=100.0,
rcont=10.0,
Pdis=0.1,
discContactOption=1.0,
)
contact.port_1.x = 0.1
contact.port_2.x = 0.0
contact.port_1.v = -2.0
contact.port_2.v = 0.0
expected = 10.0 - 20.0 * (1.0 - exp(-1.0))
self.assertAlmostEqual(contact.contact_force, expected, places=12)
clipped = AmesimLstp00a(
"clipped_contact",
medium,
gap0=0.0,
kcont=100.0,
rcont=10.0,
Pdis=0.1,
discContactOption=2.0,
)
clipped.port_1.x = contact.port_1.x
clipped.port_2.x = contact.port_2.x
clipped.port_1.v = contact.port_1.v
clipped.port_2.v = contact.port_2.v
self.assertEqual(clipped.contact_force, 0.0)
contact.set_causal_contact(penetration=0.1, force=expected)
self.assertAlmostEqual(contact.contact_force, expected, places=12)
clipped.set_causal_contact(penetration=0.1, force=expected)
self.assertEqual(clipped.contact_force, 0.0)
def test_causal_contact_survives_unrelated_nonlinear_fallback(self) -> None:
medium = IdealGasMedium()
source = AmesimForc("contact_force")
source.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")
unrelated = _AnchoredMechanicalForce("unrelated", 7.0)
network = SimulationNetwork("causal-contact-with-nonlinear-fallback")
for component in (source, contact, mass, zero, unrelated):
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")
diagnostics = PressureFlowSolver(network).solve()
self.assertTrue(diagnostics.success, diagnostics.message)
self.assertGreater(diagnostics.evaluations, 0)
self.assertAlmostEqual(contact.penetration, 4.0e-10, places=20)
self.assertAlmostEqual(contact.contact_force, 40.0, places=8)
self.assertAlmostEqual(unrelated.port_1.f, 7.0, places=8)
def test_elastic_mass_endstop_applies_contact_force_option(self) -> None:
medium = IdealGasMedium()
parameters = {
"mass": 2.0,
"useFriction": 1.0,
"stoptype": 2.0,
"x0": 0.1,
"xmax": 0.0,
"Kbmax": 100.0,
"Dbmax": 10.0,
"Pdmax": 0.01,
"v0": -2.0,
}
negative_allowed = AmesimMecmas21(
"negative_allowed",
medium,
discContactOption=1.0,
**parameters,
)
clipped = AmesimMecmas21(
"clipped",
medium,
discContactOption=2.0,
**parameters,
)
self.assertAlmostEqual(negative_allowed._upper_limit_force(), -10.0)
self.assertAlmostEqual(negative_allowed.acceleration(), 5.0)
self.assertEqual(clipped._upper_limit_force(), 0.0)
self.assertEqual(clipped.acceleration(), 0.0)
def test_large_explicit_force_does_not_mask_small_local_force_residual(self) -> None:
medium = IdealGasMedium()
source = AmesimForc("large_force")
source.res.signal = 1.0e17
mass = AmesimMecmas21(
"large_mass",
medium,
mass=90_000.0,
useFriction=1.0,
)
zero = AmesimF000("large_zero")
local_force = _AnchoredMechanicalForce("local_force", 40.0)
network = SimulationNetwork("large-and-local-force-scales")
for component in (source, mass, zero, local_force):
network.add_component(component)
network.connect("large_force", "port_2", "large_mass", "port_1")
network.connect("large_mass", "port_2", "large_zero", "port_1")
diagnostics = PressureFlowSolver(network).solve()
self.assertTrue(diagnostics.success)
self.assertEqual(source.port_2.f, -1.0e17)
self.assertEqual(mass.port_1.f, 1.0e17)
self.assertAlmostEqual(local_force.port_1.f, 40.0, places=9)
residuals = {
equation.id: equation.value
for equation in network.pressure_flow_equation_residuals()
}
self.assertLess(abs(residuals["local_force:force_state"]), 1.0e-9)
def test_rigidly_connected_masses_share_state_and_acceleration(self) -> None:
medium = IdealGasMedium()
source = AmesimForc("force")
source.res.signal = 100.0
first_mass = AmesimMecmas21(
"first_mass",
medium,
mass=2.0,
useFriction=1.0,
x0=0.25,
v0=0.5,
)
second_mass = AmesimMecmas21(
"second_mass",
medium,
mass=3.0,
useFriction=1.0,
x0=0.25,
v0=0.5,
)
link = _RigidMechanicalLink("rigid_link")
zero = AmesimF000("zero")
network = SimulationNetwork("rigid-mass-group")
for component in (source, first_mass, link, second_mass, zero):
network.add_component(component)
network.connect("force", "port_2", "first_mass", "port_1")
network.connect("first_mass", "port_2", "rigid_link", "port_1")
network.connect("rigid_link", "port_2", "second_mass", "port_1")
network.connect("second_mass", "port_2", "zero", "port_1")
system = GenericFluidSystem(network)
initial_state = system.consistent_initial_state_vector()
derivatives = system.rhs(0.0, initial_state)
self.assertEqual(len(initial_state), 2)
self.assertEqual(initial_state, [0.5, 0.25])
self.assertAlmostEqual(derivatives[0], 20.0, places=12)
self.assertAlmostEqual(first_mass.acceleration(), 20.0, places=12)
self.assertAlmostEqual(second_mass.acceleration(), 20.0, places=12)
result = system.simulate(
SolveIVPConfig(t_start=0.0, t_stop=0.01, max_step=0.001),
sample_step=0.005,
)
self.assertTrue(result.success, result.message)
self.assertEqual(result.series["first_mass.x"], result.series["second_mass.x"])
self.assertEqual(result.series["first_mass.v"], result.series["second_mass.v"])
self.assertEqual(result.series["first_mass.a"], result.series["second_mass.a"])
for acceleration in result.series["first_mass.a"]:
self.assertAlmostEqual(acceleration, 20.0, places=9)
def test_ideal_upper_stop_locks_mass_under_outward_force(self) -> None:
system, _mass = _single_mass_system(
100.0,
stoptype=1.0,
x0=0.0,
xmin=-1.0,
xmax=0.0,
)
result = system.simulate(
SolveIVPConfig(t_start=0.0, t_stop=0.01, max_step=0.001),
sample_step=0.005,
)
self.assertTrue(result.success, result.message)
for value in result.series["mass.x"]:
self.assertAlmostEqual(value, 0.0, places=12)
for value in result.series["mass.v"]:
self.assertAlmostEqual(value, 0.0, places=12)
for value in result.series["mass.a"]:
self.assertAlmostEqual(value, 0.0, places=12)
# Ideal-contact reaction is an internal constraint force. AMESim's
# Fmax output is reserved for the elastic (stoptype=2) endstop.
self.assertEqual(result.series["mass.Fmax"], [0.0, 0.0, 0.0])
def test_ideal_upper_stop_releases_mass_under_inward_force(self) -> None:
system, _mass = _single_mass_system(
-100.0,
stoptype=1.0,
x0=0.0,
xmin=-1.0,
xmax=0.0,
)
result = system.simulate(
SolveIVPConfig(t_start=0.0, t_stop=0.01, max_step=0.001),
sample_step=0.005,
)
self.assertTrue(result.success, result.message)
self.assertAlmostEqual(result.series["mass.a"][0], -50.0, places=12)
self.assertEqual(result.series["mass.Fmax"], [0.0, 0.0, 0.0])
self.assertLess(result.series["mass.v"][-1], 0.0)
self.assertLess(result.series["mass.x"][-1], 0.0)
def test_ideal_upper_stop_releases_subthreshold_inward_velocity(self) -> None:
system, mass = _single_mass_system(
100.0,
stoptype=1.0,
x0=0.0,
xmin=-1.0,
xmax=0.0,
)
mass.v = -0.5 * mass.dvel
mass.refresh_thermodynamic_ports()
initial_state = system.consistent_initial_state_vector()
derivatives = system.rhs(0.0, initial_state)
self.assertAlmostEqual(derivatives[0], 50.0, places=12)
self.assertAlmostEqual(derivatives[1], -0.5 * mass.dvel, places=18)
result = system.simulate(
SolveIVPConfig(t_start=0.0, t_stop=1.0e-6, max_step=1.0e-6),
sample_step=5.0e-9,
)
self.assertTrue(result.success, result.message)
self.assertAlmostEqual(result.series["mass.v"][0], -0.5 * mass.dvel)
self.assertLess(min(result.series["mass.x"]), 0.0)
self.assertLessEqual(max(result.series["mass.x"]), 1.0e-15)
self.assertAlmostEqual(result.series["mass.x"][-1], 0.0, delta=5.0e-15)
self.assertAlmostEqual(result.series["mass.v"][-1], 0.0, delta=1.1e-12)
def test_ideal_upper_stop_ignores_jacobian_scale_inward_velocity_noise(self) -> None:
system, mass = _single_mass_system(
100.0,
stoptype=1.0,
x0=0.0,
xmin=-1.0,
xmax=0.0,
)
initial_state = system.consistent_initial_state_vector()
perturbed_state = [-1.0e-14, initial_state[1]]
self.assertEqual(system.rhs(0.0, initial_state), [0.0, 0.0])
self.assertEqual(system.rhs(0.0, perturbed_state), [0.0, 0.0])
self.assertEqual(mass.v, -1.0e-14)
def test_ideal_lower_stop_ignores_jacobian_scale_outward_velocity_noise(self) -> None:
system, mass = _single_mass_system(
-100.0,
stoptype=1.0,
x0=0.0,
xmin=0.0,
xmax=1.0,
)
initial_state = system.consistent_initial_state_vector()
perturbed_state = [1.0e-14, initial_state[1]]
self.assertEqual(system.rhs(0.0, initial_state), [0.0, 0.0])
self.assertEqual(system.rhs(0.0, perturbed_state), [0.0, 0.0])
self.assertEqual(mass.v, 1.0e-14)
def test_ideal_stop_rejects_initial_position_outside_limits(self) -> None:
system, _mass = _single_mass_system(
100.0,
stoptype=1.0,
x0=0.01,
xmin=-1.0,
xmax=0.0,
)
with self.assertRaisesRegex(ValueError, "outside the discrete endstop limits"):
system.consistent_initial_state_vector()
def test_rhs_trial_state_does_not_commit_ideal_stop_mode(self) -> None:
system, _mass = _single_mass_system(
100.0,
stoptype=1.0,
x0=0.0,
xmin=-1.0,
xmax=0.01,
)
initial_state = system.consistent_initial_state_vector()
group = system.mechanical_state_reducer.groups[0]
committed_mode = group.mode
trial_derivatives = system.rhs(0.0, [0.0, 0.02])
self.assertEqual(trial_derivatives, [0.0, 0.0])
self.assertEqual(group.mode, committed_mode)
accepted_derivatives = system.rhs(0.0, initial_state)
self.assertEqual(group.mode, committed_mode)
self.assertAlmostEqual(accepted_derivatives[0], 50.0, places=12)
self.assertAlmostEqual(accepted_derivatives[1], 0.0, places=12)
def test_ideal_upper_stop_projects_outward_velocity_at_step_start(self) -> None:
system, mass = _single_mass_system(
100.0,
stoptype=1.0,
x0=0.01,
xmin=-1.0,
xmax=0.01,
)
mass.v = 1.0
mass.refresh_thermodynamic_ports()
result = system.simulate(
SolveIVPConfig(t_start=0.0, t_stop=0.005, max_step=0.001),
sample_step=0.001,
)
self.assertTrue(result.success, result.message)
for value in result.series["mass.x"]:
self.assertAlmostEqual(value, 0.01, places=12)
for value in result.series["mass.v"]:
self.assertAlmostEqual(value, 0.0, places=12)
for value in result.series["mass.a"]:
self.assertAlmostEqual(value, 0.0, places=12)
def test_ideal_stop_ignores_zero_velocity_roundoff_inside_boundary_band(self) -> None:
system, _mass = _single_mass_system(
0.0,
stoptype=1.0,
x0=0.0,
xmin=0.0,
xmax=1.0,
)
reducer = system.mechanical_state_reducer
previous_state = [0.0, 2.0e-32]
current_state = [0.0, -1.0e-32]
def dense_state(time: float) -> list[float]:
fraction = float(time)
return [
0.0,
previous_state[1]
+ fraction * (current_state[1] - previous_state[1]),
]
transition = reducer.state_transition(
0.0,
previous_state,
1.0,
current_state,
dense_state,
)
self.assertIsNone(transition)
def test_ideal_upper_stop_projects_a_high_speed_impact(self) -> None:
system, _mass = _single_mass_system(
100.0,
stoptype=1.0,
x0=0.0,
xmin=-1.0,
xmax=0.01,
)
result = system.simulate(
SolveIVPConfig(t_start=0.0, t_stop=0.04, max_step=0.01),
sample_step=0.005,
)
self.assertTrue(result.success, result.message)
self.assertLessEqual(max(result.series["mass.x"]), 0.01 + 1.0e-12)
after_impact = [
index
for index, time in enumerate(result.series["time"])
if time >= 0.02 - 1.0e-10
]
self.assertTrue(after_impact)
for index in after_impact:
self.assertAlmostEqual(result.series["mass.x"][index], 0.01, places=12)
self.assertAlmostEqual(result.series["mass.v"][index], 0.0, places=12)
self.assertAlmostEqual(result.series["mass.a"][index], 0.0, places=12)
def test_restitution_upper_stop_rebounds_a_high_speed_impact(self) -> None:
system, mass = _single_mass_system(
0.0,
stoptype=3.0,
x0=-0.01,
xmin=-1.0,
xmax=0.0,
)
mass.v = 2.0
mass.restdvel = 0.1
mass.restcoeff = 0.25
mass.refresh_thermodynamic_ports()
result = system.simulate(
SolveIVPConfig(t_start=0.0, t_stop=0.012, max_step=0.01),
sample_step=0.002,
)
self.assertTrue(result.success, result.message)
self.assertLessEqual(max(result.series["mass.x"]), 1.0e-12)
rebound_indices = [
index
for index, velocity in enumerate(result.series["mass.v"])
if velocity < 0.0
]
self.assertTrue(rebound_indices)
for index in rebound_indices:
self.assertAlmostEqual(result.series["mass.v"][index], -0.5, places=12)
self.assertAlmostEqual(result.series["mass.x"][-1], -0.0035, places=10)
def test_restitution_upper_stop_locks_at_velocity_threshold(self) -> None:
system, mass = _single_mass_system(
100.0,
stoptype=3.0,
x0=0.0,
xmin=-1.0,
xmax=0.0,
)
mass.restdvel = 0.1
mass.restcoeff = 0.8
mass.v = mass.restdvel
mass.refresh_thermodynamic_ports()
result = system.simulate(
SolveIVPConfig(t_start=0.0, t_stop=0.01, max_step=0.001),
sample_step=0.005,
)
self.assertTrue(result.success, result.message)
for value in result.series["mass.x"]:
self.assertAlmostEqual(value, 0.0, places=12)
for value in result.series["mass.v"]:
self.assertAlmostEqual(value, 0.0, places=12)
for value in result.series["mass.a"]:
self.assertAlmostEqual(value, 0.0, places=12)
def test_plastic_stop_wins_over_restitution_at_shared_boundary(self) -> None:
medium = IdealGasMedium()
plastic = AmesimMecmas21(
"plastic",
medium,
stoptype=1.0,
xmin=-1.0,
xmax=0.0,
useFriction=1.0,
)
restitution = AmesimMecmas21(
"restitution",
medium,
stoptype=3.0,
xmin=-1.0,
xmax=0.0,
restdvel=0.1,
restcoeff=0.8,
useFriction=1.0,
)
group = MechanicalConstraintGroup((plastic, restitution))
self.assertEqual(group.impact_velocity("upper", 2.0), 0.0)
if __name__ == "__main__":
unittest.main()
-177
View File
@@ -1,177 +0,0 @@
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import unittest
from app.main import compile_system_xml_network
from app.simulation.benchmark_regression import (
evaluate_regression_golden,
load_regression_manifest,
run_regression_suite,
source_simulation_config,
)
from app.simulation.systems.generic import GenericFluidSystem
from app.system_xml import validate_system_xml_document
MANIFEST_PATH = Path(
"tests/baselines/simulation/test_mql_full_branches/manifest.json"
)
LONG_RUN_ENVIRONMENT = "RUN_TEST_MQL_FULL_BRANCHES_LONG_REGRESSION"
class MqlFullBranchesStaticRegressionTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.manifest = load_regression_manifest(MANIFEST_PATH)
cls.source_path = Path(cls.manifest["_sourcePath"])
cls.source_payload = cls.source_path.read_bytes()
cls.validation = validate_system_xml_document(cls.source_payload)
def test_authoritative_xml_hash_and_simulation_settings_are_fixed(self) -> None:
source = self.manifest["source"]
self.assertEqual(
hashlib.sha256(self.source_payload).hexdigest(), source["sha256"]
)
self.assertEqual(len(self.source_payload), source["bytes"])
self.assertEqual(
source_simulation_config(self.source_payload), source["simulation"]
)
def test_structure_snapshot_covers_the_historical_solver_shape(self) -> None:
self.assertTrue(self.validation.valid, self.validation.as_dict())
assert self.validation.document is not None
network = compile_system_xml_network(self.validation.document)
system = GenericFluidSystem(network)
expected = self.manifest["structure"]
pressure_flow = network.pressure_flow_structure_dict()
causal_execution = system.pressure_flow_solver.causal_execution_diagnostics()
jacobian = system.jacobian_sparsity_diagnostics()
self.assertEqual(len(network.components), expected["componentCount"])
self.assertEqual(len(network.connections), expected["connectionCount"])
self.assertEqual(
len(network.dynamic_components()), expected["dynamicComponentCount"]
)
self.assertEqual(
sum(component.state_size for component in network.dynamic_components()),
expected["stateCount"],
)
self.assertEqual(
len(network.result_variable_metadata()), expected["resultVariableCount"]
)
self.assertEqual(
pressure_flow["unknownCount"], expected["pressureFlowUnknownCount"]
)
self.assertEqual(
pressure_flow["equationCount"], expected["pressureFlowEquationCount"]
)
self.assertEqual(pressure_flow["isSquare"], expected["pressureFlowIsSquare"])
for key in (
"logicalEffortCoordinateCount",
"eliminatedEffortAliasCount",
"canonicalCoordinateCount",
"compatibilityScatterCount",
):
self.assertEqual(causal_execution[key], expected[key])
self.assertEqual(jacobian["nonzeroCount"], expected["jacobianNonzeroCount"])
self.assertEqual(
jacobian["colorGroupCount"], expected["jacobianColorGroupCount"]
)
self.assertEqual(
system.mechanical_state_reducer.has_state_events,
expected["hasMechanicalStateEvents"],
)
def test_three_production_repeats_replay_approved_goldens_exactly(self) -> None:
repository_root = Path(self.manifest["_repositoryRoot"])
evidence = self.manifest["acceptanceEvidence"]
self.assertEqual(evidence["status"], "approved")
self.assertEqual(evidence["lane"], "production")
self.assertEqual(evidence["repeatCount"], 3)
self.assertTrue(evidence["physicalValuesIdenticalAcrossRepeats"])
expected_counts = {"0.81s": 480, "2.10s": 720}
stable_signatures: dict[str, object] = {}
for report_reference in evidence["reports"]:
report_path = repository_root / report_reference["path"]
payload = report_path.read_bytes()
self.assertEqual(len(payload), report_reference["bytes"])
self.assertEqual(
hashlib.sha256(payload).hexdigest(),
report_reference["sha256"],
)
report = json.loads(payload)
self.assertEqual(report["lane"], "production")
self.assertEqual(
report["source"]["sha256"], self.manifest["source"]["sha256"]
)
for case_id in self.manifest["sequence"]:
case = next(
item for item in report["cases"] if item["caseId"] == case_id
)
self.assertEqual(case["outcome"], "completed")
self.assertTrue(case["acceptance"]["passed"])
summary = case["worker"]["summary"]
golden = self.manifest["_goldens"][case_id]["production"]
audit = evaluate_regression_golden(summary, golden)
self.assertTrue(audit["passed"], audit)
self.assertEqual(
audit["comparedValueCount"], expected_counts[case_id]
)
self.assertEqual(audit["maxAbsoluteError"], 0.0)
self.assertEqual(audit["maxToleranceRatio"], 0.0)
self.assertEqual(audit["issues"], [])
diagnostics = dict(summary["diagnostics"])
diagnostics.pop("performance", None)
signature = {
"physicalContract": summary["physicalContract"],
"eventTrace": summary["eventTrace"],
"outputContract": summary["outputContract"],
"diagnosticsWithoutPerformance": diagnostics,
}
if case_id not in stable_signatures:
stable_signatures[case_id] = signature
else:
self.assertEqual(stable_signatures[case_id], signature)
def test_both_horizons_have_the_expected_signal_schedule(self) -> None:
assert self.validation.document is not None
system = GenericFluidSystem(
compile_system_xml_network(self.validation.document)
)
for case_id in self.manifest["sequence"]:
variant = self.manifest["variants"][case_id]
actual = system.signal_resolver.event_times(
0.0, float(variant["stopTime"])
)
self.assertEqual(actual, tuple(variant["expectedSignalEventTimes"]))
@unittest.skipUnless(
os.getenv(LONG_RUN_ENVIRONMENT, "").strip().lower() in {"1", "true", "yes"},
f"Set {LONG_RUN_ENVIRONMENT}=1 to run bounded 0.81/2.10 s integration.",
)
class MqlFullBranchesLongRegressionTests(unittest.TestCase):
def test_both_horizons_complete_within_their_safety_budgets(self) -> None:
report = run_regression_suite(MANIFEST_PATH, lane="production")
outcomes = [case["outcome"] for case in report["cases"]]
self.assertEqual(outcomes, ["completed", "completed"], report["cases"])
self.assertTrue(
all(
case["acceptance"]["regressionGolden"]["passed"]
for case in report["cases"]
),
report["cases"],
)
if __name__ == "__main__":
unittest.main()
+113
View File
@@ -0,0 +1,113 @@
"""Native catalog regression against frozen, independent Python results."""
import json
import subprocess
import unittest
import numpy as np
from app.simulation.native_codegen.extended import catalog_contracts, compile_extended_program
from app.simulation.native_codegen.compiler import compile_native_program
from app.simulation.native_codegen.build import build_native, toolchain
from app.simulation.systems.network import SimulationNetwork
from app.simulation.components.amesim.media.mediums import AmesimHeliumPengRobinsonMedium
from tests.native_reference import reference_data, reference_network
class Circuit:
def __init__(self, medium=None):
self.medium=medium or AmesimHeliumPengRobinsonMedium()
self.net=SimulationNetwork('native_catalog')
def add(self, kind, name, **params):
cls=catalog_contracts()[kind]
values={p.name:p.default for p in cls.PARAMETERS};values.update(params)
c=cls.create(name=name,medium=self.medium,parameters=values)
self.net.add_component(c)
return c
def connect(self,a,ap,b,bp):
self.net.connect(a.name,ap,b.name,bp)
def chamber(self,name,**params):
return self.add('amesim_pnch023',name,**params)
def seal(self):
connected={e.key for edge in self.net.connections for e in edge.endpoints}
for c in list(self.net.components.values()):
for p in c.active_port_definitions:
if p.domain=='pneumatic' and (c.name,p.name) not in connected:
cap=self.add('amesim_pnpl01','cap_'+str(len(self.net.components)))
self.connect(c,p.name,cap,'port_1')
return self.net
class NativeCatalogTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
try:
toolchain()
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
raise unittest.SkipTest(f'Native toolchain unavailable: {exc}')
def test_frozen_python_reference(self):
data = reference_data()
for index, case in enumerate(data['cases']):
with self.subTest(network=index):
net = reference_network(case)
# Exercise the optimized compiler as well as the general catalog.
programs = [compile_extended_program(net)]
if index >= len(data['cases']) - 3:
programs.append(compile_native_program(net))
for program in programs:
build = build_native(program)
state_order = [case['stateKeys'].index(key) for key in program.state_keys]
output_order = [case['outputKeys'].index(v.key) for v in program.variables]
initial = json.loads(subprocess.run([str(build.executable), '--init'], capture_output=True, text=True, check=True, timeout=15).stdout)
np.testing.assert_allclose(initial, [case['initialState'][i] for i in state_order], rtol=data['rtol'], atol=data['atol'])
inputs = [' '.join(format(x, '.17g') for x in (probe['time'], *[probe['state'][i] for i in state_order])) for probe in case['probes']]
result = subprocess.run([str(build.executable), '--probe'], input='\n'.join(inputs)+'\n', capture_output=True, text=True, check=True, timeout=30)
rows = [json.loads(line) for line in result.stdout.splitlines()]
self.assertEqual(len(rows), len(inputs))
for row, probe in zip(rows, case['probes']):
self.assertTrue(row['success'])
np.testing.assert_allclose(row['rhs'], [probe['rhs'][i] for i in state_order], rtol=data['rtol'], atol=data['atol'], err_msg=f'network {index} RHS')
np.testing.assert_allclose(row['outputs'], [probe['outputs'][i] for i in output_order], rtol=data['rtol'], atol=data['atol'], err_msg=f'network {index} outputs')
def test_entire_registered_catalog_is_covered(self):
from app.simulation.native_codegen.contracts import SUPPORTED_VERSIONS
self.assertEqual(len(catalog_contracts()), 27)
self.assertEqual({key: cls.MODEL_VERSION for key, cls in catalog_contracts().items()}, SUPPORTED_VERSIONS)
def test_four_rigidly_connected_masses_obey_newtons_law(self):
b = Circuit()
node = b.add('amesim_lmechn1', 'node', v1=3)
for i, name in enumerate((p.name for p in node.active_port_definitions), start=1):
mass = b.add('amesim_mecmas21', 'm'+str(i), mass=i, stoptype=4, useFriction=1)
force = b.add('amesim_forc', 'f'+str(i))
signal = b.add('amesim_step0', 's'+str(i), initial=i*i, final=i*i, time=1)
b.connect(node, name, mass, 'port_1')
b.connect(mass, 'port_2', force, 'port_2')
b.connect(force, 'res', signal, 'out')
build = build_native(compile_extended_program(b.net))
initial = json.loads(subprocess.run([str(build.executable), '--init'], capture_output=True, text=True, check=True).stdout)
self.assertEqual(len(initial), 2)
row = json.loads(subprocess.run([str(build.executable), '--probe'], input='0 '+' '.join(map(str, initial))+'\n', capture_output=True, text=True, check=True).stdout)
self.assertTrue(row['success'])
np.testing.assert_allclose(row['rhs'], [3, 0], atol=1e-12)
def test_restitution_and_algebraic_models_execute_both_solvers(self):
import tempfile
from pathlib import Path
from app.simulation.config import SolveIVPConfig
from app.simulation.native_codegen.runner import execute_native
b=Circuit();m=b.add('amesim_mecmas21','mass',mass=1,stoptype=3,xmin=0,xmax=1,x0=.9,v0=1,restcoeff=.5,restdvel=.001,useFriction=1)
for name in ('port_1','port_2'):
free=b.add('amesim_f000','free_'+name);b.connect(m,name,free,'port_1')
build=build_native(compile_extended_program(b.net))
signal=Circuit();signal.add('amesim_step0','step',time=.1,initial=2,final=4)
algebraic=build_native(compile_extended_program(signal.net))
with tempfile.TemporaryDirectory() as tmp:
for method in ('RK45','BDF'):
cfg=SolveIVPConfig(method=method,t_stop=.4,max_step=.02,rtol=1e-7)
result=execute_native(build,cfg,.02,run_dir=Path(tmp)/method)
self.assertTrue(result['success'])
np.testing.assert_allclose(result['finalState'],[-.5,.85],atol=2e-6,rtol=1e-6)
result=execute_native(algebraic,cfg,.02,run_dir=Path(tmp)/(method+'-signal'))
self.assertTrue(result['success'])
self.assertEqual(result['series']['step.y'][-1],4)
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import replace
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import numpy as np
from app.main import compile_system_xml_network, run_system_xml_simulation
from app.simulation.backends import simulation_config
from app.simulation.native_codegen.build import build_native, toolchain
from app.simulation.native_codegen.compiler import NativeCapabilityError, compile_native_program
from app.simulation.native_codegen.input import arithmetic_value, load_input, project_xml
from app.simulation.native_codegen.runner import execute_native
from app.simulation.config import SolverActivityTracker
from app.system_xml import validate_system_xml_document
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "tests/data/native-skill-test.xml"
def network(xml=None):
doc = validate_system_xml_document(xml or FIXTURE.read_bytes()).document
return compile_system_xml_network(doc)
class NativeInputTests(unittest.TestCase):
def test_arithmetic_is_bounded_and_does_not_execute_code(self):
self.assertEqual(arithmetic_value("3.14*10**2/4"), 78.5)
for bad in ("__import__('os')", "a.b", "[1][0]", "2**100000000", "1/0"):
with self.assertRaises((ValueError, ZeroDivisionError)):
arithmetic_value(bad)
def test_ir_has_complete_state_and_output_mapping(self):
program = compile_native_program(network())
self.assertEqual(len(program.state_keys), 12)
self.assertEqual(len(program.variables), 175)
self.assertNotIn("PyObject", program.source)
self.assertNotIn("amesim_mecmas21_5", program.source.split("int model_eval")[1])
def test_unknown_component_is_rejected_before_build(self):
n = network()
original = n.components["amesim_mecmas21_2"]
original.__class__ = type('CustomUnportedMass', (type(original),), {})
with self.assertRaisesRegex(NativeCapabilityError, "no native contract"):
compile_native_program(n)
class NativeExecutionTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
try:
toolchain()
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
raise unittest.SkipTest(f"Native toolchain unavailable: {exc}")
cls.temporary = tempfile.TemporaryDirectory(prefix="native-tests-")
cls.root = Path(cls.temporary.name)
cls.document = validate_system_xml_document(FIXTURE.read_bytes()).document
cls.program = compile_native_program(network())
cls.build = build_native(cls.program)
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def test_cache_and_standalone_executable_without_python_path(self):
cached = build_native(self.program)
self.assertTrue(cached.cache_hit)
env = {**os.environ, "PATH": str(Path(os.environ["SystemRoot"]) / "System32")}
r = subprocess.run([str(cached.executable), "--init"], env=env, cwd=self.root,
capture_output=True, text=True, check=True)
self.assertEqual(len(json.loads(r.stdout)), 12)
def test_both_integrators_complete_and_respect_max_step(self):
for method in ("RK45", "BDF"):
config = replace(simulation_config(self.document.simulation), method=method, t_stop=.1)
data = execute_native(self.build, config, .02, run_dir=self.root / method)
self.assertTrue(data["success"])
self.assertEqual(data["simulatedUntil"], .1)
self.assertLessEqual(data["maxAcceptedStep"], config.max_step+1e-14)
self.assertEqual(set(data["series"]), {"time", *(v.key for v in self.program.variables)})
self.assertTrue(all(np.isfinite(v).all() for v in map(np.asarray, data["series"].values())))
def test_cancellation_returns_partial_accepted_state(self):
config = replace(simulation_config(self.document.simulation), max_step=1e-6)
tracker = SolverActivityTracker()
data = execute_native(self.build, config, .02, run_dir=self.root / "cancel",
cancel_check=lambda: True, activity_tracker=tracker)
self.assertEqual(data["status"], "cancelled")
self.assertLess(data["simulatedUntil"], config.t_stop)
self.assertEqual(data["series"]["time"][-1], data["simulatedUntil"])
def test_xml_api_defaults_to_native_and_preserves_result_contract(self):
xml = FIXTURE.read_bytes().replace(b'tStop="10"', b'tStop="0.1"')
with patch.dict(os.environ):
os.environ.pop("SIMULATION_NUMERIC_ENGINE", None)
data = run_system_xml_simulation(xml)
self.assertTrue(data["success"])
self.assertEqual(data["diagnostics"]["backend"], "native-c")
self.assertEqual(data["diagnostics"]["stateCount"], 12)
self.assertEqual(data["diagnostics"]["sampleCount"], len(data["series"]["time"]))
self.assertEqual(len(data["variables"]), 175)
self.assertEqual(data["series"]["time"][-1], .1)
if __name__ == "__main__":
unittest.main()
+39
View File
@@ -0,0 +1,39 @@
"""The retired numerical backend must not become a hidden runtime dependency."""
import os
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import patch
from app.simulation.backends import numeric_engine_name
class NativeOnlyBackendTests(unittest.TestCase):
def test_python_backend_is_rejected_explicitly(self):
with patch.dict(os.environ, {'SIMULATION_NUMERIC_ENGINE': 'python'}):
with self.assertRaisesRegex(ValueError, 'removed'):
numeric_engine_name()
self.assertEqual(numeric_engine_name('native-c'), 'native')
def test_api_and_compiler_import_without_numpy_or_scipy(self):
code = '''
import importlib.abc
import sys
from pathlib import Path
class NoNumericalPython(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname.split('.')[0] in ('numpy', 'scipy'):
raise AssertionError('Retired numerical dependency imported: '+fullname)
sys.meta_path.insert(0, NoNumericalPython())
from app.main import compile_system_xml_network
from app.simulation.native_codegen.compiler import compile_native_program
from app.system_xml import validate_system_xml_document
doc = validate_system_xml_document(Path('tests/data/native-skill-test.xml').read_bytes()).document
program = compile_native_program(compile_system_xml_network(doc))
assert len(program.state_keys) == 12
assert len(program.variables) == 175
assert not any(name.startswith('app.simulation.solvers.') for name in sys.modules)
'''
subprocess.run([sys.executable, '-c', code], cwd=Path(__file__).resolve().parents[1],
capture_output=True, text=True, check=True, timeout=30)
-110
View File
@@ -1,110 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.core.peng_robinson import AIR_PR, HELIUM_PR, NITROGEN_PR, PengRobinsonFluid
class PengRobinsonTest(unittest.TestCase):
def test_helium_near_ideal_at_atmospheric_condition(self) -> None:
z = HELIUM_PR.compressibility_factor(101_325.0, 300.0)
density = HELIUM_PR.density(101_325.0, 300.0)
self.assertAlmostEqual(z, 1.0, delta=1.0e-3)
self.assertAlmostEqual(density, 0.1625, delta=0.002)
def test_helium_density_pressure_round_trip(self) -> None:
pressure = 15.3e6
temperature = 293.15
density = HELIUM_PR.density(pressure, temperature)
self.assertGreater(HELIUM_PR.compressibility_factor(pressure, temperature), 1.0)
self.assertAlmostEqual(
density,
24.114225444477153,
delta=1.0e-12,
)
self.assertAlmostEqual(
HELIUM_PR.pressure_from_density(temperature, density),
pressure,
delta=pressure * 1.0e-12,
)
def test_helium_residual_enthalpy_is_small_at_atmosphere(self) -> None:
self.assertAlmostEqual(
HELIUM_PR.residual_specific_enthalpy(101_300.0, 298.15),
25.3556466754,
delta=0.01,
)
def test_helium_residual_enthalpy_captures_high_pressure_departure(self) -> None:
self.assertAlmostEqual(
HELIUM_PR.residual_specific_enthalpy(13_839_965.0, 287.7322),
12788.0457342,
delta=0.1,
)
def test_residual_isochoric_heat_capacity_is_internal_energy_derivative(
self,
) -> None:
temperature = 292.3997890954483
density = 24.02697515640726
temperature_step = 1.0e-3
numerical_derivative = (
HELIUM_PR.residual_specific_internal_energy_at_density(
temperature + temperature_step,
density,
)
- HELIUM_PR.residual_specific_internal_energy_at_density(
temperature - temperature_step,
density,
)
) / (2.0 * temperature_step)
self.assertAlmostEqual(
HELIUM_PR.residual_isochoric_heat_capacity_at_density(
temperature,
density,
),
numerical_derivative,
delta=1.0e-6,
)
def test_air_reference_remains_available_for_other_models(self) -> None:
z = AIR_PR.compressibility_factor(101_325.0, 300.0)
density = AIR_PR.density(101_325.0, 300.0)
self.assertAlmostEqual(z, 1.0, delta=1.0e-3)
self.assertAlmostEqual(density, 1.177, delta=0.01)
def test_nitrogen_can_return_multiple_roots(self) -> None:
roots = NITROGEN_PR.compressibility_roots(1.0e6, 100.0)
self.assertEqual(len(roots), 3)
self.assertLess(roots[0], roots[-1])
self.assertEqual(NITROGEN_PR.compressibility_factor(1.0e6, 100.0, phase="liquid"), roots[0])
self.assertEqual(NITROGEN_PR.compressibility_factor(1.0e6, 100.0, phase="vapor"), roots[-1])
def test_custom_fluid_can_be_defined_explicitly(self) -> None:
fluid = PengRobinsonFluid(
name="custom_helium",
molar_mass=0.004002602,
critical_temperature=5.1953,
critical_pressure=227_460.0,
acentric_factor=-0.382,
)
self.assertAlmostEqual(fluid.specific_gas_constant, 2077.3, delta=0.5)
self.assertAlmostEqual(fluid.compressibility_factor(1.0e6, 298.15), HELIUM_PR.compressibility_factor(1.0e6, 298.15))
def test_rejects_invalid_states(self) -> None:
with self.assertRaises(ValueError):
HELIUM_PR.compressibility_factor(0.0, 300.0)
with self.assertRaises(ValueError):
HELIUM_PR.density(101_325.0, 0.0)
with self.assertRaises(ValueError):
HELIUM_PR.pressure_from_density(300.0, -1.0)
if __name__ == "__main__":
unittest.main()
-70
View File
@@ -1,70 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.benchmark_performance import (
_duration_summary,
_load_factory_xml,
_named_value,
_parse_arguments,
_serialize_result_event,
)
def sample_xml_factory() -> bytes:
return b"<System/>"
class PerformanceBenchmarkToolTests(unittest.TestCase):
def test_named_value_requires_an_explicit_name(self) -> None:
self.assertEqual(
_named_value("chain=tests.example:project", option="--factory"),
("chain", "tests.example:project"),
)
with self.assertRaisesRegex(ValueError, "NAME=VALUE"):
_named_value("tests.example:project", option="--factory")
def test_duration_summary_reports_repeatable_order_statistics(self) -> None:
summary = _duration_summary([5.0, 1.0, 3.0, 2.0, 4.0])
self.assertEqual(summary["minimumMs"], 1.0)
self.assertEqual(summary["medianMs"], 3.0)
self.assertEqual(summary["p95Ms"], 5.0)
self.assertEqual(summary["maximumMs"], 5.0)
def test_factory_loader_accepts_a_bytes_factory(self) -> None:
self.assertEqual(
_load_factory_xml(
"tests.test_performance_benchmark:sample_xml_factory"
),
b"<System/>",
)
def test_result_event_serialization_uses_ndjson_shape(self) -> None:
payload = _serialize_result_event(
{
"success": True,
"status": "completed",
"simulatedUntil": 1.0,
"requestedStopTime": 1.0,
}
)
self.assertTrue(payload.endswith(b"\n"))
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()
-487
View File
@@ -1,487 +0,0 @@
from __future__ import annotations
import copy
import hashlib
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from app.simulation.benchmark_regression import summarize_simulation_result
from app.simulation.physical_state_v21 import (
PHYSICAL_STATE_V21_ID,
PhysicalStateV21Error,
approve_candidate_golden,
assess_report_against_amesim,
build_amesim_reference,
build_candidate_golden,
compare_contract_to_amesim,
evaluate_physical_state_v21,
load_approved_golden,
project_physical_state_v21,
write_candidate_golden,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
AMESIM_ARCHIVE = REPO_ROOT / "AmesimModels" / "test_mql.ame"
CANDIDATE_REPORT = (
REPO_ROOT
/ "tests/baselines/simulation/test_mql_8/runs"
/ "2026-08-18-production-amesim-aligned-v1-candidate-0.2.json"
)
def _variable(key: str, quantity: str, unit: str, category: str) -> dict[str, str]:
return {"key": key, "quantity": quantity, "unit": unit, "category": category}
def _synthetic_result() -> dict[str, object]:
times = [0.0, 0.04, 0.2]
variables: list[dict[str, str]] = []
series: dict[str, list[float]] = {"time": times}
def add(
key: str,
values: list[float],
quantity: str,
unit: str,
category: str,
) -> None:
variables.append(_variable(key, quantity, unit, category))
series[key] = values
add("amesim_pnch012_8.p", [100_000.0, 200_000.0, 300_000.0], "pressure", "Pa", "thermodynamic")
add("amesim_pnl0001_20.p", [100_000.0, 210_000.0, 310_000.0], "pressure", "Pa", "thermodynamic")
add("amesim_pnl0001_20.port_1.m_flow", [-0.1, -0.2, -0.3], "mass_flow", "kg/s", "port")
add("amesim_pnch012_8.port_1.m_flow", [0.1, 0.2, 0.3], "mass_flow", "kg/s", "port")
add("amesim_pnvo001_5.port_2.m_flow", [-0.2, -0.3, -0.4], "mass_flow", "kg/s", "port")
add("amesim_pnvo001_5.port_3.m_flow", [0.2, 0.3, 0.4], "mass_flow", "kg/s", "port")
for port, values in enumerate(
([0.4, 0.5, 0.6], [-0.1, -0.2, -0.3], [-0.2, -0.2, -0.2], [-0.1, -0.1, -0.1]),
start=1,
):
add(f"amesim_p4node2_8.port_{port}.m_flow", list(values), "mass_flow", "kg/s", "port")
add("amesim_pnvo001_5.xv", [0.0, 1.0, 1.0], "dimensionless", "", "derived")
add("amesim_mecmas21_9.x", [0.0, 0.0, 0.0], "length", "m", "state")
add("amesim_mecmas21_9.v", [0.0, 0.0, 0.0], "velocity", "m/s", "state")
add("amesim_mecmas21_9.port_1.f", [1.0, 1.0, 1.0], "force", "N", "port")
add("amesim_mecmas21_9.port_2.f", [2.0, 2.0, 2.0], "force", "N", "port")
add("amesim_mecmas21_10.x", [0.0, 0.0, 0.0], "length", "m", "state")
add("amesim_mecmas21_10.v", [0.0, 0.0, 0.0], "velocity", "m/s", "state")
add("amesim_mecmas21_10.port_1.f", [-2.0, -2.0, -2.0], "force", "N", "port")
add("amesim_mecmas21_10.port_2.f", [-1.0, -1.0, -1.0], "force", "N", "port")
add("storage_a.m", [1.0, 2.0, 3.0], "mass", "kg", "state")
add("storage_b.m", [4.0, 3.0, 2.0], "mass", "kg", "state")
return {
"success": True,
"status": "completed",
"partial": False,
"simulatedUntil": 0.2,
"requestedStopTime": 0.2,
"variables": variables,
"series": series,
"final": {key: values[-1] for key, values in series.items() if key != "time"},
"diagnostics": {},
}
class PhysicalStateProjectionTests(unittest.TestCase):
def test_projects_all_categories_with_units_and_strict_balances(self) -> None:
contract = project_physical_state_v21(
_synthetic_result(), checkpoint_times=(0.0, 0.04, 0.2), sample_step=0.04
)
self.assertEqual(contract["id"], PHYSICAL_STATE_V21_ID)
self.assertEqual(
contract["projectionCategories"],
["pressure", "massFlow", "conservation", "discreteMode"],
)
self.assertEqual(len(contract["layout"]["projectionKeys"]), 11)
endpoint = contract["checkpoints"][-1]["values"]
self.assertEqual(endpoint["conservation.totalStoredGasMass"], 5.0)
self.assertAlmostEqual(endpoint["conservation.p4node2_8.massBalance"], 0.0)
self.assertAlmostEqual(endpoint["conservation.pnvo001_5.massBalance"], 0.0)
self.assertAlmostEqual(
endpoint["conservation.pnl0001_20_pnch012_8.connectionMassBalance"], 0.0
)
self.assertEqual(endpoint["discrete.pnvo001_5.openMode"], 1.0)
self.assertEqual(endpoint["discrete.mecmas21_9.endstopMode"], 1.0)
self.assertEqual(endpoint["discrete.mecmas21_10.endstopMode"], -1.0)
pressure = contract["layout"]["projections"][0]["amesim"]
self.assertEqual(pressure["offset"], 101_300.0)
flow = contract["layout"]["projections"][2]["amesim"]
self.assertEqual(flow["scale"], -1.0e-3)
def test_fails_closed_on_wrong_source_unit(self) -> None:
result = _synthetic_result()
next(
variable
for variable in result["variables"]
if variable["key"] == "amesim_pnch012_8.p"
)["unit"] = "bar"
with self.assertRaisesRegex(PhysicalStateV21Error, "Unexpected metadata"):
project_physical_state_v21(result, checkpoint_times=(0.2,), sample_step=0.2)
def test_benchmark_summary_attaches_v21_only_to_matching_result(self) -> None:
summary = summarize_simulation_result(
_synthetic_result(), checkpoint_times=(0.0, 0.2), sample_step=0.2
)
self.assertEqual(summary["physicalStateV21"]["id"], PHYSICAL_STATE_V21_ID)
class AmesimReferenceTests(unittest.TestCase):
def test_reference_records_archive_hash_units_signs_and_known_endpoint(self) -> None:
reference = build_amesim_reference(AMESIM_ARCHIVE, checkpoint_times=(0.2,))
values = reference["checkpoints"][0]["values"]
self.assertAlmostEqual(values["pressure.pnch012_8.absolute"], 3_625_053.2009413484)
self.assertAlmostEqual(
values["massFlow.pnl0001_20.port_1.intoComponent"],
-0.3728806429214546,
)
self.assertEqual(values["discrete.mecmas21_9.endstopMode"], 1.0)
self.assertEqual(values["discrete.mecmas21_10.endstopMode"], -1.0)
provenance = reference["provenance"]
self.assertEqual(provenance["archivePath"], "AmesimModels/test_mql.ame")
self.assertEqual(len(provenance["archiveSha256"]), 64)
self.assertEqual(
[member["name"] for member in provenance["members"]],
["test_mql_.var", "test_mql_.results"],
)
def test_current_candidate_endpoint_passes_explicit_amesim_alignment_profile(self) -> None:
assessment = assess_report_against_amesim(
CANDIDATE_REPORT, archive_path=AMESIM_ARCHIVE
)
comparison = assessment["comparison"]
self.assertEqual(assessment["coverage"], "legacyEndpointOnly")
self.assertTrue(comparison["passed"])
self.assertEqual(comparison["comparedValueCount"], 9)
self.assertEqual(comparison["metricCount"], 11)
self.assertEqual(comparison["availableBaselineValueCount"], 9)
self.assertEqual(comparison["unavailableBaselineValueCount"], 2)
self.assertEqual(len(comparison["metrics"]), 11)
self.assertTrue(
all(
{
"requestedTime",
"key",
"category",
"actual",
"amesimBaseline",
"absoluteError",
"relativeError",
"evaluated",
"passed",
}
<= set(metric)
for metric in comparison["metrics"]
)
)
self.assertEqual(
comparison["worstValue"]["key"],
"massFlow.pnl0001_20.port_1.intoComponent",
)
self.assertAlmostEqual(
comparison["worstValue"]["relativeError"],
0.0013934852135053246,
)
self.assertEqual(
comparison["toleranceProfile"]["byCategory"]["discreteMode"]["relative"],
0.0,
)
def test_signal_jump_mass_flows_record_errors_but_are_not_evaluated(self) -> None:
contract = project_physical_state_v21(
_synthetic_result(), checkpoint_times=(0.0, 0.04, 0.2), sample_step=0.04
)
reference_checkpoints = []
for checkpoint in contract["checkpoints"]:
values = dict(checkpoint["values"])
values["conservation.p4node2_8.massBalance"] = None
values[
"conservation.pnl0001_20_pnch012_8.connectionMassBalance"
] = None
if checkpoint["requestedTime"] == 0.04:
values["massFlow.pnl0001_20.port_1.intoComponent"] += 100.0
values["massFlow.pnvo001_5.port_2.intoComponent"] -= 100.0
reference_checkpoints.append(
{"requestedTime": checkpoint["requestedTime"], "values": values}
)
comparison = compare_contract_to_amesim(
contract, {"checkpoints": reference_checkpoints}
)
excluded = [
metric
for metric in comparison["metrics"]
if metric["requestedTime"] == 0.04 and metric["category"] == "massFlow"
]
self.assertTrue(comparison["passed"])
self.assertEqual(comparison["metricCount"], 33)
self.assertEqual(comparison["availableBaselineValueCount"], 27)
self.assertEqual(comparison["unavailableBaselineValueCount"], 6)
self.assertEqual(comparison["comparedValueCount"], 25)
self.assertEqual(comparison["excludedValueCount"], 2)
self.assertEqual(len(comparison["metrics"]), 33)
self.assertEqual(len(excluded), 2)
self.assertTrue(all(metric["evaluated"] is False for metric in excluded))
self.assertTrue(all(metric["passed"] is None for metric in excluded))
self.assertTrue(all(metric["absoluteError"] > 99.0 for metric in excluded))
self.assertTrue(
all("LeftRightLimit" in metric["exclusionReason"] for metric in excluded)
)
self.assertNotIn(comparison["worstValue"], excluded)
unavailable = [
metric
for metric in comparison["metrics"]
if metric["amesimBaseline"] is None
]
self.assertEqual(len(unavailable), 6)
self.assertTrue(all(metric["evaluated"] is False for metric in unavailable))
self.assertTrue(all(metric["absoluteError"] is None for metric in unavailable))
self.assertTrue(all(metric["relativeError"] is None for metric in unavailable))
def test_relative_error_uses_the_amesim_baseline_denominator(self) -> None:
contract = project_physical_state_v21(
_synthetic_result(), checkpoint_times=(0.2,), sample_step=0.2
)
values = dict(contract["checkpoints"][0]["values"])
key = "pressure.pnch012_8.absolute"
values[key] = 0.5 * values[key]
comparison = compare_contract_to_amesim(
contract,
{"checkpoints": [{"requestedTime": 0.2, "values": values}]},
)
metric = next(item for item in comparison["metrics"] if item["key"] == key)
expected = abs(metric["actual"] - metric["amesimBaseline"]) / abs(
metric["amesimBaseline"]
)
self.assertAlmostEqual(metric["relativeError"], expected)
self.assertAlmostEqual(metric["relativeErrorPercent"], 100.0 * expected)
self.assertTrue(metric["relativeErrorDefined"])
def test_zero_amesim_baseline_has_undefined_relative_error(self) -> None:
contract = project_physical_state_v21(
_synthetic_result(), checkpoint_times=(0.2,), sample_step=0.2
)
values = dict(contract["checkpoints"][0]["values"])
key = "massFlow.pnl0001_20.port_1.intoComponent"
values[key] = -0.0
comparison = compare_contract_to_amesim(
contract,
{"checkpoints": [{"requestedTime": 0.2, "values": values}]},
)
metric = next(item for item in comparison["metrics"] if item["key"] == key)
self.assertIsNone(metric["relativeError"])
self.assertIsNone(metric["relativeErrorPercent"])
self.assertFalse(metric["relativeErrorDefined"])
self.assertEqual(metric["relativeErrorReason"], "zeroAmesimBaseline")
self.assertEqual(metric["comparisonBasis"], "absoluteNearZero")
self.assertEqual(
json.loads(json.dumps(comparison, allow_nan=False))["baselineAuthority"],
"amesim",
)
class ReviewedGoldenProtocolTests(unittest.TestCase):
def _report_and_reference(self):
contract = project_physical_state_v21(
_synthetic_result(), checkpoint_times=(0.0, 0.04, 0.2), sample_step=0.04
)
report = {
"generatedAt": "2026-08-18T00:00:00+00:00",
"source": {"sha256": "a" * 64},
"cases": [{
"caseId": "0.2s",
"lane": "production",
"worker": {"summary": {"physicalStateV21": contract}},
}],
}
keys = contract["layout"]["projectionKeys"]
reference_checkpoints = []
for checkpoint in contract["checkpoints"]:
reference_checkpoints.append({
"requestedTime": checkpoint["requestedTime"],
"values": {key: checkpoint["values"][key] for key in keys},
})
reference = {
"schemaVersion": 1,
"provenance": {
"archivePath": "AmesimModels/synthetic.ame",
"archiveBytes": 2,
"archiveSha256": "b" * 64,
"members": [
{"name": "synthetic.var", "bytes": 1, "sha256": "c" * 64},
{
"name": "synthetic.results",
"bytes": 1,
"sha256": "d" * 64,
},
],
},
"storedMassDataPaths": ["mgas@synthetic"],
"checkpoints": reference_checkpoints,
}
return contract, report, reference
def test_candidate_requires_exact_hash_and_is_approved_to_a_new_file(self) -> None:
contract, report, reference = self._report_and_reference()
with tempfile.TemporaryDirectory(
dir=REPO_ROOT, prefix=".physical-state-v21-test-"
) as temporary_directory:
root = Path(temporary_directory)
report_path = root / "report.json"
report_path.write_text(json.dumps(report), encoding="utf-8")
candidate_path = root / "candidate.json"
approved_path = root / "approved.json"
with patch(
"app.simulation.physical_state_v21.build_amesim_reference",
return_value=reference,
):
write_candidate_golden(report_path, candidate_path)
candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
local_report = candidate["provenance"]["localSourceReport"]
self.assertFalse(Path(local_report["path"]).is_absolute())
self.assertEqual(local_report["bytes"], report_path.stat().st_size)
candidate_sha = hashlib.sha256(candidate_path.read_bytes()).hexdigest()
with self.assertRaisesRegex(PhysicalStateV21Error, "SHA mismatch"):
approve_candidate_golden(
candidate_path,
approved_path,
expected_candidate_sha256="0" * 64,
reviewed_by="reviewer",
note="reviewed",
)
approve_candidate_golden(
candidate_path,
approved_path,
expected_candidate_sha256=candidate_sha,
reviewed_by="reviewer",
note="units, signs, and errors reviewed",
)
golden = load_approved_golden(approved_path)
self.assertFalse(Path(golden["approval"]["candidatePath"]).is_absolute())
golden["checkpoints"][-1]["values"][0] += 1.0e9
self.assertTrue(evaluate_physical_state_v21(contract, golden)["passed"])
changed = copy.deepcopy(contract)
changed["checkpoints"][-1]["values"]["pressure.pnch012_8.absolute"] += 1000.0
failure = evaluate_physical_state_v21(changed, golden)
self.assertFalse(failure["passed"])
self.assertIn("amesimReferenceToleranceMismatch", failure["issues"])
def test_approval_rejects_failed_or_tampered_alignment(self) -> None:
_contract, report, reference = self._report_and_reference()
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
report_path = root / "report.json"
report_path.write_text(json.dumps(report), encoding="utf-8")
original_path = root / "candidate.json"
with patch(
"app.simulation.physical_state_v21.build_amesim_reference",
return_value=reference,
):
write_candidate_golden(report_path, original_path)
original = json.loads(original_path.read_text(encoding="utf-8"))
failed_alignment = copy.deepcopy(original)
failed_alignment["amesimAlignmentAtGeneration"]["passed"] = False
failed_path = root / "failed-alignment.json"
failed_path.write_text(json.dumps(failed_alignment), encoding="utf-8")
with self.assertRaisesRegex(
PhysicalStateV21Error, "AMESim alignment at generation"
):
approve_candidate_golden(
failed_path,
root / "failed-approved.json",
expected_candidate_sha256=hashlib.sha256(
failed_path.read_bytes()
).hexdigest(),
reviewed_by="reviewer",
note="must not override a failed alignment",
)
failed_metric = copy.deepcopy(original)
evaluated_metric = next(
metric
for metric in failed_metric["amesimAlignmentAtGeneration"]["metrics"]
if metric["evaluated"]
)
evaluated_metric["passed"] = False
failed_metric_path = root / "failed-metric.json"
failed_metric_path.write_text(json.dumps(failed_metric), encoding="utf-8")
with self.assertRaisesRegex(PhysicalStateV21Error, "evaluated metric"):
approve_candidate_golden(
failed_metric_path,
root / "failed-metric-approved.json",
expected_candidate_sha256=hashlib.sha256(
failed_metric_path.read_bytes()
).hexdigest(),
reviewed_by="reviewer",
note="must not override a failed metric",
)
def test_approval_rejects_local_conservation_residual(self) -> None:
_contract, report, reference = self._report_and_reference()
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
report_path = root / "report.json"
report_path.write_text(json.dumps(report), encoding="utf-8")
original_path = root / "candidate.json"
with patch(
"app.simulation.physical_state_v21.build_amesim_reference",
return_value=reference,
):
write_candidate_golden(report_path, original_path)
candidate = json.loads(original_path.read_text(encoding="utf-8"))
key = "conservation.p4node2_8.massBalance"
value_index = candidate["layout"]["projectionKeys"].index(key)
candidate["checkpoints"][1]["values"][value_index] = 2.0e-9
tampered_path = root / "conservation-residual.json"
tampered_path.write_text(json.dumps(candidate), encoding="utf-8")
with self.assertRaisesRegex(
PhysicalStateV21Error, "local conservation residual"
):
approve_candidate_golden(
tampered_path,
root / "residual-approved.json",
expected_candidate_sha256=hashlib.sha256(
tampered_path.read_bytes()
).hexdigest(),
reviewed_by="reviewer",
note="must not override a local conservation failure",
)
def test_candidate_writer_refuses_overwrite_and_unapproved_file_is_rejected(self) -> None:
_contract, report, reference = self._report_and_reference()
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
report_path = root / "report.json"
report_path.write_text(json.dumps(report), encoding="utf-8")
output = root / "candidate.json"
with patch(
"app.simulation.physical_state_v21.build_amesim_reference",
return_value=reference,
):
write_candidate_golden(report_path, output)
with self.assertRaisesRegex(PhysicalStateV21Error, "overwrite"):
write_candidate_golden(report_path, output)
with self.assertRaisesRegex(PhysicalStateV21Error, "approval.status"):
load_approved_golden(output)
if __name__ == "__main__":
unittest.main()
-108
View File
@@ -1,108 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
from app.simulation.core.medium import IdealGasMedium
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.pnl0002_replay import (
Pnl0002ReplayPaths,
replay_pnl0002_amesim_states,
)
class Pnl0002ReplayTests(unittest.TestCase):
def setUp(self) -> None:
self.pipe = AmesimPnl0002(
"pneumatic_83",
IdealGasMedium(),
diam=0.02,
le=2.0,
rr=0.00225,
)
self.paths = Pnl0002ReplayPaths(
center_pressure="center_p",
center_temperature="center_T",
port_1_pressure="port_1_p",
port_1_temperature="port_1_T",
port_1_mass_flow="port_1_dm",
port_2_pressure="port_2_p",
port_2_temperature="port_2_T",
port_2_mass_flow="port_2_dm",
reynolds="re",
friction_factor="ff",
)
def _matching_results(self) -> AmesimResults:
center_pressure = 300000.0
center_temperature = 320.0
port_1_pressure = 500000.0
port_1_temperature = 330.0
port_2_pressure = 100000.0
port_2_temperature = 310.0
flow_1 = self.pipe.mass_flow(
port_1_pressure,
center_pressure,
port_1_temperature,
)
flow_2 = self.pipe.mass_flow(
port_2_pressure,
center_pressure,
center_temperature,
)
reynolds_1 = self.pipe.reynolds_number(flow_1, port_1_temperature)
reynolds_2 = self.pipe.reynolds_number(flow_2, center_temperature)
friction = 0.5 * (
self.pipe.friction_factor(reynolds_1)
+ self.pipe.friction_factor(reynolds_2)
)
return AmesimResults(
times=(0.0,),
variables=(),
saved_variable_indices=(),
series_by_data_path={
"center_p": (center_pressure,),
"center_T": (center_temperature,),
"port_1_p": (port_1_pressure,),
"port_1_T": (port_1_temperature,),
"port_1_dm": (flow_1 / -1.0e-3,),
"port_2_p": (port_2_pressure,),
"port_2_T": (port_2_temperature,),
"port_2_dm": (flow_2 / -1.0e-3,),
"re": (0.5 * (reynolds_1 + reynolds_2),),
"ff": (friction,),
},
final_values_by_data_path={},
)
def test_matching_saved_states_replay_without_integration(self) -> None:
initial_state = self.pipe.get_state_vector()
report = replay_pnl0002_amesim_states(
self.pipe,
self._matching_results(),
self.paths,
)
self.assertEqual(report["mode"], "amesim-state-replay-no-integration")
self.assertEqual(report["pointCount"], 1)
self.assertEqual(self.pipe.get_state_vector(), initial_state)
row = report["rows"][0]
self.assertAlmostEqual(row["port1MassFlowError"], 0.0, places=15)
self.assertAlmostEqual(row["port2MassFlowError"], 0.0, places=15)
self.assertAlmostEqual(row["reynoldsError"], 0.0, places=12)
self.assertAlmostEqual(row["frictionFactorError"], 0.0, places=15)
def test_missing_saved_variable_has_actionable_error(self) -> None:
results = self._matching_results()
results.series_by_data_path.pop("ff")
with self.assertRaisesRegex(
ValueError,
"AMESim replay variable is not saved: ff",
):
replay_pnl0002_amesim_states(self.pipe, results, self.paths)
if __name__ == "__main__":
unittest.main()
@@ -1,890 +0,0 @@
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from app.main import compile_reactflow_network
from app.simulation.components.amesim.flow.orifices import (
AmesimPnvo001SignalOpening,
)
from app.simulation.components.amesim.flow.pipes import AmesimPnl0001
from app.simulation.registry import ComponentModelSpec, get_component_model_spec
from app.simulation.solvers.algebraic import (
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE,
CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE,
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE,
CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE,
PressureFlowSolver,
)
from app.simulation.systems.generic import GenericFluidSystem
from tests.test_amesim_mechanical_xml import zero_force_mass_project
from tests.test_amesim_pnl0001_xml import amesim_pnl0001_project
from tests.test_amesim_pnvo001_signal_xml import (
high_pressure_helium_step_project,
)
from tests.test_generic_system_xml_simulation import chain_project
class _UntrustedPnvo001(AmesimPnvo001SignalOpening):
# A module-name check alone would incorrectly trust this changed subclass.
__module__ = AmesimPnvo001SignalOpening.__module__
pressure_flow_callback_count = 0
def pressure_flow_equation_values(self) -> tuple[float, ...]:
self.pressure_flow_callback_count += 1
return super().pressure_flow_equation_values()
class _InheritedDirectReaderPnl0001(AmesimPnl0001):
"""A changed concrete class that does not repeat the scalar promise."""
class _NonfiniteDirectReaderPnl0001(AmesimPnl0001):
__module__ = AmesimPnl0001.__module__
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.full_equation_evaluation_count = 0
self.direct_equation_evaluation_count = 0
def pressure_flow_equation_values(self) -> tuple[float, ...]:
self.full_equation_evaluation_count += 1
return super().pressure_flow_equation_values()
def pressure_flow_equation_value_readers(self):
def nonfinite_pressure_state_residual() -> float:
self.direct_equation_evaluation_count += 1
return float("nan")
return {
f"{self.name}:port_2_pressure_state": (
nonfinite_pressure_state_residual
),
}
def _system(
project,
*,
executor_v2: bool | None = False,
coordinate_kernel: bool | None = None,
direct_sum_assignments: bool | None = None,
direct_equation_readers: bool | None = None,
) -> GenericFluidSystem:
environment = {}
if executor_v2 is not None:
environment[CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE] = (
"1" if executor_v2 else "0"
)
if coordinate_kernel is not None:
environment[CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE] = (
"1" if coordinate_kernel else "0"
)
if direct_sum_assignments is not None:
environment[CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE] = (
"1" if direct_sum_assignments else "0"
)
if direct_equation_readers is not None:
environment[CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE] = (
"1" if direct_equation_readers else "0"
)
with patch.dict(
os.environ,
environment,
):
if executor_v2 is None:
os.environ.pop(CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE, None)
if coordinate_kernel is None:
os.environ.pop(
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
None,
)
if direct_sum_assignments is None:
os.environ.pop(
CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE,
None,
)
if direct_equation_readers is None:
os.environ.pop(
CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE,
None,
)
return GenericFluidSystem(compile_reactflow_network(project))
def _system_with_pnl0001_class(component_class, **options) -> GenericFluidSystem:
original_spec = get_component_model_spec("amesim_pnl0001")
custom_spec = ComponentModelSpec(
component_class=component_class,
library=original_spec.library,
)
def custom_spec_lookup(model_type: str):
if model_type == "amesim_pnl0001":
return custom_spec
return get_component_model_spec(model_type)
with patch(
"app.simulation.registry.get_component_model_spec",
side_effect=custom_spec_lookup,
):
return _system(amesim_pnl0001_project(), **options)
class PressureFlowCausalExecutionTests(unittest.TestCase):
def test_direct_equation_reader_is_default_on_and_bitwise_optional(
self,
) -> None:
direct = _system(
amesim_pnl0001_project(),
executor_v2=True,
direct_equation_readers=True,
)
callback = _system(
amesim_pnl0001_project(),
executor_v2=True,
direct_equation_readers=False,
)
direct_state = direct.initial_state_vector()
callback_state = callback.initial_state_vector()
for time in (0.0, 0.001):
self.assertEqual(
direct.rhs(time, direct_state),
callback.rhs(time, callback_state),
)
self.assertEqual(
tuple(
unknown.read()
for unknown in direct.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in callback.pressure_flow_solver.unknowns
),
)
direct_diagnostics = (
direct.pressure_flow_solver.causal_execution_diagnostics()
)
callback_diagnostics = (
callback.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(direct_diagnostics["directEquationReadersConfigured"])
self.assertEqual(direct_diagnostics["directEffortAnchorCount"], 1)
self.assertFalse(
callback_diagnostics["directEquationReadersConfigured"]
)
self.assertEqual(callback_diagnostics["directEffortAnchorCount"], 0)
def test_inherited_direct_equation_reader_does_not_opt_in_subclass(
self,
) -> None:
system = _system_with_pnl0001_class(
_InheritedDirectReaderPnl0001,
direct_equation_readers=True,
)
pipe = system.network.components["pnl_1"]
solver = system.pressure_flow_solver
self.assertIsInstance(pipe, _InheritedDirectReaderPnl0001)
self.assertEqual(
solver.causal_execution_diagnostics()["directEffortAnchorCount"],
0,
)
anchor = next(
anchor
for group in solver._effort_groups["p"]
for anchor in group.anchors
if anchor.equation_id == "pnl_1:port_2_pressure_state"
)
self.assertIsNone(anchor.causal_evaluate)
def test_nonfinite_direct_effort_reader_uses_authoritative_fallback(
self,
) -> None:
system = _system_with_pnl0001_class(
_NonfiniteDirectReaderPnl0001,
executor_v2=True,
direct_equation_readers=True,
)
pipe = system.network.components["pnl_1"]
solver = system.pressure_flow_solver
self.assertIsInstance(pipe, _NonfiniteDirectReaderPnl0001)
self.assertEqual(
solver.causal_execution_diagnostics()["directEffortAnchorCount"],
1,
)
derivative = system.rhs(0.0, system.initial_state_vector())
self.assertTrue(all(value == value for value in derivative))
diagnostics = solver.causal_execution_diagnostics()
self.assertFalse(diagnostics["enabled"])
self.assertEqual(
diagnostics["disabledReason"],
"nonFiniteCausalEffortAnchor",
)
self.assertGreaterEqual(diagnostics["legacyFallbackCount"], 1)
self.assertGreater(pipe.direct_equation_evaluation_count, 0)
self.assertGreater(pipe.full_equation_evaluation_count, 0)
pipe.direct_equation_evaluation_count = 0
pipe.full_equation_evaluation_count = 0
solver.propagate_equal_efforts(("p",))
self.assertEqual(pipe.direct_equation_evaluation_count, 0)
self.assertEqual(pipe.full_equation_evaluation_count, 1)
def test_direct_sum_keeps_same_module_subclass_on_callback_plan(self) -> None:
original_spec = get_component_model_spec("amesim_pnvo001")
custom_spec = ComponentModelSpec(
component_class=_UntrustedPnvo001,
library=original_spec.library,
)
def custom_spec_lookup(model_type: str):
if model_type == "amesim_pnvo001":
return custom_spec
return get_component_model_spec(model_type)
with patch(
"app.simulation.registry.get_component_model_spec",
side_effect=custom_spec_lookup,
):
system = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=True,
)
solver = system.pressure_flow_solver
valve = system.network.components["valve_1"]
self.assertIsInstance(valve, _UntrustedPnvo001)
assignment = next(
assignment
for stage in solver._explicit_flow_plan
for assignment in stage.assignments
if assignment.equation_id == "valve_1:mass_flow_balance"
)
self.assertIsNone(assignment.evaluate)
self.assertIs(assignment.component, valve)
self.assertEqual(
solver.causal_execution_diagnostics()[
"directSumFlowAssignmentCount"
],
0,
)
valve.pressure_flow_callback_count = 0
system.rhs(0.041, system.initial_state_vector())
self.assertGreater(valve.pressure_flow_callback_count, 0)
def test_direct_sum_assignments_match_disabled_path_bitwise(self) -> None:
direct = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=True,
)
callback = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=False,
)
direct_state = direct.initial_state_vector()
callback_state = callback.initial_state_vector()
for time in (0.0, 0.041, 0.8):
self.assertEqual(
direct.rhs(time, direct_state),
callback.rhs(time, callback_state),
)
self.assertEqual(
tuple(
unknown.read()
for unknown in direct.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in callback.pressure_flow_solver.unknowns
),
)
direct_diagnostics = (
direct.pressure_flow_solver.causal_execution_diagnostics()
)
callback_diagnostics = (
callback.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(
direct_diagnostics["directSumAssignmentsConfigured"]
)
self.assertFalse(
callback_diagnostics["directSumAssignmentsConfigured"]
)
self.assertGreater(
direct_diagnostics["directSumFlowAssignmentCount"],
0,
)
self.assertEqual(
callback_diagnostics["directSumFlowAssignmentCount"],
0,
)
self.assertEqual(
direct_diagnostics["compiledFlowAssignmentCount"],
callback_diagnostics["compiledFlowAssignmentCount"],
)
self.assertLess(
direct_diagnostics["directSumFlowAssignmentCount"],
direct_diagnostics["compiledFlowAssignmentCount"],
)
for key in (
"fastSolveCount",
"fullResidualAuditCount",
"auditFailureCount",
"legacyFallbackCount",
):
self.assertEqual(direct_diagnostics[key], callback_diagnostics[key])
self.assertGreater(direct_diagnostics["fastSolveCount"], 0)
self.assertEqual(direct_diagnostics["auditFailureCount"], 0)
self.assertEqual(direct_diagnostics["legacyFallbackCount"], 0)
def test_direct_sum_reader_rejection_uses_safe_component_fallback(
self,
) -> None:
callback = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=False,
)
with patch.object(
PressureFlowSolver,
"_sum_to_zero_flow_target_reader",
side_effect=ValueError("synthetic unsupported sum"),
):
fallback = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=True,
)
callback_state = callback.initial_state_vector()
fallback_state = fallback.initial_state_vector()
for time in (0.0, 0.041, 0.8):
self.assertEqual(
fallback.rhs(time, fallback_state),
callback.rhs(time, callback_state),
)
self.assertEqual(
tuple(
unknown.read()
for unknown in fallback.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in callback.pressure_flow_solver.unknowns
),
)
diagnostics = (
fallback.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(diagnostics["directSumAssignmentsConfigured"])
self.assertEqual(diagnostics["directSumFlowAssignmentCount"], 0)
self.assertTrue(diagnostics["eligible"])
self.assertTrue(diagnostics["enabled"])
self.assertGreater(diagnostics["fastSolveCount"], 0)
self.assertEqual(diagnostics["auditFailureCount"], 0)
self.assertEqual(diagnostics["legacyFallbackCount"], 0)
def test_compiled_v2_is_enabled_by_default_and_can_be_disabled(self) -> None:
default = _system(zero_force_mass_project(), executor_v2=None)
disabled = _system(zero_force_mass_project(), executor_v2=False)
enabled = _system(zero_force_mass_project(), executor_v2=True)
self.assertTrue(
default.pressure_flow_solver.causal_execution_diagnostics()[
"executorV2Configured"
]
)
self.assertFalse(
disabled.pressure_flow_solver.causal_execution_diagnostics()[
"executorV2Configured"
]
)
self.assertTrue(
enabled.pressure_flow_solver.causal_execution_diagnostics()[
"executorV2Configured"
]
)
def test_coordinate_kernel_is_default_on_and_independently_disabled(
self,
) -> None:
default = _system(
zero_force_mass_project(),
executor_v2=True,
)
disabled = _system(
zero_force_mass_project(),
executor_v2=True,
coordinate_kernel=False,
)
default_diagnostics = (
default.pressure_flow_solver.causal_execution_diagnostics()
)
disabled_diagnostics = (
disabled.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(default_diagnostics["coordinateKernelConfigured"])
self.assertTrue(default_diagnostics["coordinateKernelEnabled"])
self.assertFalse(disabled_diagnostics["coordinateKernelConfigured"])
self.assertFalse(disabled_diagnostics["coordinateKernelEnabled"])
def test_compiled_v2_matches_v1_bitwise_without_target_names(self) -> None:
compiled = _system(
high_pressure_helium_step_project(),
executor_v2=True,
)
v1 = _system(high_pressure_helium_step_project())
compiled_state = compiled.initial_state_vector()
v1_state = v1.initial_state_vector()
for time in (0.0, 0.041, 0.8):
self.assertEqual(
compiled.rhs(time, compiled_state),
v1.rhs(time, v1_state),
)
self.assertEqual(
tuple(
unknown.read()
for unknown in compiled.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in v1.pressure_flow_solver.unknowns
),
)
diagnostics = (
compiled.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertGreater(diagnostics["executorV2FastSolveCount"], 0)
self.assertGreater(diagnostics["coordinateKernelFastSolveCount"], 0)
self.assertEqual(
diagnostics["compiledAssignmentCount"],
len(compiled.pressure_flow_solver.unknowns),
)
self.assertEqual(
diagnostics["executorV2RuntimeValidationFailureCount"],
0,
)
self.assertEqual(
diagnostics["canonicalCoordinateCount"],
diagnostics["logicalEffortCoordinateCount"]
+ diagnostics["compiledFlowAssignmentCount"],
)
self.assertEqual(
diagnostics["eliminatedEffortAliasCount"],
diagnostics["compiledEffortUnknownCount"]
- diagnostics["logicalEffortCoordinateCount"],
)
def test_compiled_v2_fast_solve_skips_legacy_seed_scan_and_scales(
self,
) -> None:
system = _system(zero_force_mass_project(), executor_v2=True)
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
with patch.object(
solver,
"_solve_explicit_flow_unknowns",
wraps=solver._solve_explicit_flow_unknowns,
) as legacy_seed, patch.object(
solver,
"_pressure_flow_equation_values",
wraps=solver._pressure_flow_equation_values,
) as residuals, patch.object(
solver,
"_scales",
wraps=solver._scales,
) as scales, patch.object(
solver,
"_evaluate_explicit_flow_stage",
wraps=solver._evaluate_explicit_flow_stage,
) as allocating_stage:
diagnostics = solver.solve(effort_variables=("p",))
legacy_seed.assert_not_called()
residuals.assert_not_called()
scales.assert_not_called()
allocating_stage.assert_not_called()
self.assertIs(
diagnostics,
solver._causal_cached_fast_diagnostics,
)
self.assertTrue(diagnostics.causal_fast_path_used)
def test_compiled_v2_nonfinite_assignment_fuses_and_audits_same_solve(
self,
) -> None:
system = _system(zero_force_mass_project(), executor_v2=True)
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
with patch.object(
solver,
"_execute_causal_coordinate_flow_plan",
return_value="nonFiniteCausalFlowAssignment",
):
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"],
"nonFiniteCausalFlowAssignment",
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_compiled_v2_rejects_nonfinite_external_mechanical_effort(
self,
) -> None:
system = _system(zero_force_mass_project(), executor_v2=True)
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
velocity = next(
unknown for unknown in solver.unknowns if unknown.variable == "v"
)
velocity.write(float("nan"))
with self.assertRaises(ValueError):
solver.solve(effort_variables=("p",))
execution = solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"nonFiniteCausalExternalEffort",
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_compiled_v2_stage_error_fuses_to_existing_fallback(self) -> None:
system = _system(zero_force_mass_project(), executor_v2=True)
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
with patch.object(
solver,
"_execute_causal_coordinate_flow_plan",
return_value="causalFlowEvaluationFailed:ValueError",
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.residual_verified_this_solve)
execution = solver.causal_execution_diagnostics()
self.assertEqual(
execution["disabledReason"],
"causalFlowEvaluationFailed:ValueError",
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1)
def test_compiled_v2_assignment_count_drift_fuses_to_fallback(self) -> None:
system = _system(zero_force_mass_project(), executor_v2=True)
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
with patch.object(
solver,
"_execute_causal_coordinate_flow_plan",
return_value="causalFlowAssignmentCountMismatch",
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.residual_verified_this_solve)
execution = solver.causal_execution_diagnostics()
self.assertEqual(
execution["disabledReason"],
"causalFlowAssignmentCountMismatch",
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1)
def test_compiled_v2_keeps_the_sixty_four_solve_audit_boundary(self) -> None:
system = _system(zero_force_mass_project(), executor_v2=True)
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 = solver.causal_execution_diagnostics()
self.assertEqual(before["executorV2FastSolveCount"], 64)
self.assertEqual(before["fullResidualAuditCount"], 1)
system.rhs(0.0, state)
after = solver.causal_execution_diagnostics()
self.assertEqual(after["executorV2FastSolveCount"], 64)
self.assertEqual(after["fullResidualAuditCount"], 2)
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()
@@ -1,287 +0,0 @@
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()
@@ -1,506 +0,0 @@
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.orifices import (
AmesimPnor001,
AmesimPnvo001FixedOpening,
AmesimPnvo001SignalOpening,
)
from app.simulation.components.amesim.flow.pipes import AmesimPnl00r
from app.simulation.components.amesim.flow.pipes import AmesimPnl0001
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
from app.simulation.components.amesim.junctions.nodes import AmesimPn3Node2
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
from app.simulation.components.amesim.storage.chambers import AmesimPnch023
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.state import VolumeState
from app.simulation.solvers.algebraic import AlgebraicSolveError, PressureFlowSolver
from app.simulation.systems.generic import GenericFluidSystem
from app.simulation.systems.network import SimulationNetwork
class PressureFlowSolverInitializationTests(unittest.TestCase):
def test_pnor_pnl0001_series_pressure_is_seeded_by_flow_balance(self) -> None:
medium = IdealGasMedium()
chamber = AmesimPnch023("source", medium, p0=15.3e6)
source_plug = AmesimPnpl01("source_closed")
orifice = AmesimPnor001("orifice", medium)
pipe = AmesimPnl0001("pipe", medium, p0=14.0e6)
storage_plug = AmesimPnpl01("storage_closed")
network = SimulationNetwork("pnor-pnl0001-series")
for component in (chamber, source_plug, orifice, pipe, storage_plug):
network.add_component(component)
network.connect("source_closed", "port_1", "source", "port_1")
network.connect("source", "port_2", "orifice", "port_1")
network.connect("orifice", "port_2", "pipe", "port_1")
network.connect("pipe", "port_2", "storage_closed", "port_1")
chamber.refresh_thermodynamic_ports()
pipe.refresh_thermodynamic_ports()
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_pnvo_pnl0001_series_pressure_is_seeded_by_flow_balance(self) -> None:
medium = IdealGasMedium()
chamber = AmesimPnch023("source", medium, p0=15.3e6)
source_plug = AmesimPnpl01("source_closed")
valve = AmesimPnvo001SignalOpening(
"valve",
medium,
opening0=1.0,
)
valve.res.signal = 1.0
pipe = AmesimPnl0001("pipe", medium, p0=14.0e6)
storage_plug = AmesimPnpl01("storage_closed")
network = SimulationNetwork("pnvo-pnl0001-series")
for component in (chamber, source_plug, valve, pipe, storage_plug):
network.add_component(component)
network.connect("source_closed", "port_1", "source", "port_1")
network.connect("source", "port_2", "valve", "port_3")
network.connect("valve", "port_2", "pipe", "port_1")
network.connect("pipe", "port_2", "storage_closed", "port_1")
chamber.refresh_thermodynamic_ports()
pipe.refresh_thermodynamic_ports()
solver = PressureFlowSolver(network)
result = solver.solve()
self.assertTrue(result.success)
self.assertEqual(result.evaluations, 0)
self.assertGreater(valve.port_2.p, pipe.port_2.p)
self.assertLess(valve.port_2.p, chamber.port_2.p)
self.assertAlmostEqual(valve.port_2.p, pipe.port_1.p)
self.assertAlmostEqual(-valve.port_2.m_flow, pipe.port_1.m_flow)
previous_intermediate_pressure = valve.port_2.p
replay_pressure = 12.0e6
replay_temperature = 293.15
replay_mass = (
medium.density(replay_pressure, replay_temperature) * pipe.volume
)
pipe.state = VolumeState(
m=replay_mass,
U=replay_mass * medium.specific_internal_energy(replay_temperature),
)
pipe.refresh_thermodynamic_ports()
replay = solver.solve()
self.assertTrue(replay.success)
self.assertEqual(replay.evaluations, 0)
self.assertNotAlmostEqual(valve.port_2.p, previous_intermediate_pressure)
self.assertAlmostEqual(valve.port_2.p, pipe.port_1.p)
self.assertAlmostEqual(-valve.port_2.m_flow, pipe.port_1.m_flow)
def test_pnvo_pnl00r_series_pressure_is_seeded_by_flow_balance(self) -> None:
medium = IdealGasMedium()
source = Cylinder(
"source",
medium,
V=0.01,
p0=150_000.0,
T0=300.0,
)
valve = AmesimPnvo001FixedOpening(
"valve",
medium,
opening=0.5,
)
pipe = AmesimPnl00r(
"pipe",
medium,
diam=0.02,
le=1.0,
rr=1.0e-5,
)
sink = Tank(
"sink",
medium,
V=0.1,
p0=100_000.0,
T0=300.0,
)
network = SimulationNetwork("pnvo-pnl00r-series")
for component in (source, valve, pipe, sink):
network.add_component(component)
network.connect("source", "port_b", "valve", "port_2")
network.connect("valve", "port_3", "pipe", "port_1")
network.connect("pipe", "port_2", "sink", "port_a")
source.refresh_thermodynamic_ports()
sink.refresh_thermodynamic_ports()
solver = PressureFlowSolver(network)
result = solver.solve()
self.assertTrue(result.success)
self.assertEqual(result.evaluations, 0)
self.assertGreater(valve.port_3.p, pipe.port_2.p)
self.assertLess(valve.port_3.p, source.port_b.p)
self.assertAlmostEqual(valve.port_3.p, pipe.port_1.p)
self.assertAlmostEqual(
-valve.port_3.m_flow,
pipe.port_1.m_flow,
delta=1.0e-10,
)
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()
pipe = AmesimPnl00r("resistance", medium)
plug = AmesimPnpl01("closed")
pipe.port_1.p = 15.3e6
pipe.port_2.p = 100_000.0
network = SimulationNetwork("dead-ended-resistance")
network.add_component(pipe)
network.add_component(plug)
network.connect("resistance", "port_2", "closed", "port_1")
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[
SimulationNetwork,
AmesimHeliumPengRobinsonMedium,
Cylinder,
Tank,
AmesimPnvo001SignalOpening,
]:
medium = AmesimHeliumPengRobinsonMedium()
high = Cylinder("high", medium, V=0.057, p0=1.0e5, T0=256.1)
low = Tank("low", medium, V=0.015, p0=1.0e5, T0=298.2)
valve = AmesimPnvo001SignalOpening(
"valve",
medium,
cq=0.45,
area0=7.85e-5,
gi=1.0,
flowset=1.0,
opening0=1.0,
)
valve.res.signal = 1.0
network = SimulationNetwork("near-equal-pressure")
for component in (high, low, valve):
network.add_component(component)
network.connect("high", "port_b", "valve", "port_2")
network.connect("valve", "port_3", "low", "port_a")
return network, medium, high, low, valve
@staticmethod
def _set_pressure_temperature(
component: Cylinder | Tank,
medium: AmesimHeliumPengRobinsonMedium,
pressure: float,
temperature: float,
) -> None:
mass = medium.density(pressure, temperature) * component.V
component.state = VolumeState(
m=mass,
U=mass
* medium.specific_internal_energy_at_pressure(
pressure,
temperature,
),
)
component.refresh_thermodynamic_ports()
def test_stream_enthalpy_refreshes_explicit_orifice_flow(self) -> None:
network, medium, high, low, valve = self._near_equal_pressure_network()
self._set_pressure_temperature(
high,
medium,
15_201_996.778497815,
292.3997890954483,
)
self._set_pressure_temperature(
low,
medium,
405_072.8123335606,
393.46713105173205,
)
system = GenericFluidSystem(network)
system.consistent_initial_state_vector()
self.assertLess(valve._connected_h["port_2"], 0.0)
self.assertAlmostEqual(
valve.port_2.m_flow,
valve.mass_flow(valve.port_2.p, valve.port_3.p),
places=12,
)
self.assertAlmostEqual(valve.port_3.m_flow, -valve.port_2.m_flow, places=12)
def test_stream_dependent_pipe_flow_closes_with_junction_mixing_in_one_rhs(self) -> None:
medium = IdealGasMedium()
hot = Cylinder("hot", medium, V=0.1, p0=500_000.0, T0=400.0)
cold = Cylinder("cold", medium, V=0.1, p0=500_000.0, T0=250.0)
sink = Tank("sink", medium, V=0.1, p0=100_000.0, T0=300.0)
hot_line = AmesimPnl00r("hot_line", medium, diam=0.01, le=0.5)
cold_line = AmesimPnl00r("cold_line", medium, diam=0.01, le=0.5)
junction = AmesimPn3Node2("junction")
pipe = AmesimPnl0002(
"pipe",
medium,
diam=0.01,
le=1.0,
p0=200_000.0,
T0=300.0,
)
network = SimulationNetwork("stream-flow-fixed-point")
for component in (
hot,
cold,
sink,
hot_line,
cold_line,
junction,
pipe,
):
network.add_component(component)
network.connect("hot", "port_b", "hot_line", "port_1")
network.connect("hot_line", "port_2", "junction", "port_1")
network.connect("cold", "port_b", "cold_line", "port_1")
network.connect("cold_line", "port_2", "junction", "port_3")
network.connect("junction", "port_2", "pipe", "port_1")
network.connect("pipe", "port_2", "sink", "port_a")
system = GenericFluidSystem(network)
state = system.consistent_initial_state_vector()
first = system.rhs(0.0, state)
second = system.rhs(0.0, state)
# PN3NODE2 takes its pressure-flow temperature reference from port 2,
# so that closure no longer depends on the flow-weighted energy mix.
self.assertGreaterEqual(system.max_thermofluid_iterations, 1)
for first_value, second_value in zip(first, second):
self.assertAlmostEqual(first_value, second_value, delta=1.0e-8)
def test_current_storage_pressure_reseeds_stale_orifice_ports_and_flow(self) -> None:
network, medium, high, low, valve = self._near_equal_pressure_network()
solver = PressureFlowSolver(network, max_evaluations=10)
high_pressure = 10_790_000.0
self._set_pressure_temperature(high, medium, high_pressure, 256.1)
self._set_pressure_temperature(low, medium, high_pressure - 300.0, 298.2)
initial = solver.solve()
self.assertTrue(initial.success)
stale_low_pressure = valve.port_3.p
stale_flow = valve.port_2.m_flow
self._set_pressure_temperature(low, medium, high_pressure - 100.0, 298.2)
self.assertNotAlmostEqual(low.port_a.p, stale_low_pressure, places=3)
updated = solver.solve()
self.assertTrue(updated.success)
self.assertEqual(updated.evaluations, 0)
self.assertAlmostEqual(valve.port_2.p, high.port_b.p, places=6)
self.assertAlmostEqual(valve.port_3.p, low.port_a.p, places=6)
self.assertNotAlmostEqual(valve.port_2.m_flow, stale_flow, places=8)
self.assertAlmostEqual(
valve.port_2.m_flow,
valve.mass_flow(valve.port_2.p, valve.port_3.p),
places=10,
)
self.assertAlmostEqual(high.port_b.m_flow, -valve.port_2.m_flow, places=10)
self.assertAlmostEqual(low.port_a.m_flow, -valve.port_3.m_flow, places=10)
def test_compiled_plan_does_not_rebuild_equation_metadata_during_solve(self) -> None:
boundary = AmesimPnpl01("compiled_closed")
boundary.port_1.p = 100_000.0
boundary.port_1.m_flow = 1.0
network = SimulationNetwork("compiled-closed-boundary")
network.add_component(boundary)
solver = PressureFlowSolver(network)
with patch.object(
boundary,
"pressure_flow_equation_residuals",
side_effect=AssertionError("equation metadata rebuilt at runtime"),
):
diagnostics = solver.solve()
self.assertTrue(diagnostics.success)
self.assertEqual(diagnostics.evaluations, 0)
self.assertEqual(boundary.port_1.m_flow, 0.0)
@staticmethod
def _closed_boundary_solver() -> PressureFlowSolver:
boundary = AmesimPnpl01("closed")
boundary.port_1.p = 100_000.0
boundary.port_1.m_flow = 1.0
network = SimulationNetwork("closed-boundary")
network.add_component(boundary)
return PressureFlowSolver(network)
@staticmethod
def _least_squares_result(x, *, status: int):
return SimpleNamespace(
x=x,
success=status > 0,
status=status,
message="test optimizer result",
nfev=1,
)
def test_status_zero_is_accepted_only_for_finite_converged_residuals(self) -> None:
exact_solver = self._closed_boundary_solver()
def exact_status_zero(_fun, x0, **_kwargs):
values = x0.copy()
flow_index = next(
index
for index, unknown in enumerate(exact_solver.unknowns)
if unknown.variable == "m_flow"
)
values[flow_index] = 0.0
return self._least_squares_result(values, status=0)
with patch.object(
PressureFlowSolver,
"_solve_explicit_flow_unknowns",
return_value=None,
), patch("scipy.optimize.least_squares", side_effect=exact_status_zero):
diagnostics = exact_solver.solve()
self.assertTrue(diagnostics.success)
self.assertEqual(diagnostics.max_scaled_residual, 0.0)
inaccurate_solver = self._closed_boundary_solver()
def inaccurate_status_zero(_fun, x0, **_kwargs):
values = x0.copy()
flow_index = next(
index
for index, unknown in enumerate(inaccurate_solver.unknowns)
if unknown.variable == "m_flow"
)
values[flow_index] = 1.0
return self._least_squares_result(values, status=0)
with patch.object(
PressureFlowSolver,
"_solve_explicit_flow_unknowns",
return_value=None,
), patch("scipy.optimize.least_squares", side_effect=inaccurate_status_zero):
with self.assertRaises(AlgebraicSolveError):
inaccurate_solver.solve()
invalid_status_solver = self._closed_boundary_solver()
def exact_invalid_status(_fun, x0, **_kwargs):
values = x0.copy()
flow_index = next(
index
for index, unknown in enumerate(invalid_status_solver.unknowns)
if unknown.variable == "m_flow"
)
values[flow_index] = 0.0
return self._least_squares_result(values, status=-1)
with patch.object(
PressureFlowSolver,
"_solve_explicit_flow_unknowns",
return_value=None,
), patch("scipy.optimize.least_squares", side_effect=exact_invalid_status):
with self.assertRaises(AlgebraicSolveError):
invalid_status_solver.solve()
def test_zero_residual_seed_does_not_bypass_positive_pressure_bound(self) -> None:
medium = IdealGasMedium()
tank = Tank("tank", medium, V=1.0)
plug = AmesimPnpl01("plug")
network = SimulationNetwork("invalid-negative-pressure")
network.add_component(tank)
network.add_component(plug)
network.connect("tank", "port_a", "plug", "port_1")
tank.state = VolumeState(m=1.0, U=-1000.0)
tank.refresh_thermodynamic_ports()
self.assertLess(tank.port_a.p, 0.0)
solver = PressureFlowSolver(network, max_evaluations=10)
with self.assertRaises(AlgebraicSolveError):
solver.solve()
self.assertIsNotNone(solver.last_diagnostics)
self.assertFalse(solver.last_diagnostics.success)
def test_positive_sub_pascal_seed_uses_the_exact_pressure_without_optimizer(
self,
) -> None:
medium = IdealGasMedium()
pressure = 0.43301848566882734
temperature = pressure / medium.R_gas
tank = Tank("tank", medium, V=1.0)
plug = AmesimPnpl01("plug")
network = SimulationNetwork("positive-sub-pascal-pressure")
network.add_component(tank)
network.add_component(plug)
network.connect("tank", "port_a", "plug", "port_1")
tank.state = VolumeState(
m=1.0,
U=medium.specific_internal_energy(temperature),
)
tank.refresh_thermodynamic_ports()
self.assertAlmostEqual(
tank.port_a.p,
pressure,
delta=pressure * 1.0e-11,
)
solver = PressureFlowSolver(network, max_evaluations=10)
with patch(
"scipy.optimize.least_squares",
side_effect=AssertionError(
"A finite positive pressure seed must stay on the fast path."
),
) as least_squares:
diagnostics = solver.solve()
self.assertTrue(diagnostics.success)
self.assertEqual(diagnostics.evaluations, 0)
self.assertAlmostEqual(
tank.port_a.p,
pressure,
delta=pressure * 1.0e-11,
)
least_squares.assert_not_called()
if __name__ == "__main__":
unittest.main()
-210
View File
@@ -1,210 +0,0 @@
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()
@@ -1,146 +0,0 @@
from __future__ import annotations
import unittest
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()
def test_temperature_iterations_are_recorded_only_on_cache_miss(self) -> None:
pressure = 15.3e6
temperature = 293.15
enthalpy = self.medium.specific_enthalpy_at_pressure(
pressure,
temperature,
)
with patch(
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations:
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()
operation, iterations, converged = record_iterations.call_args.args
self.assertEqual(operation, "temperature_from_pressure_enthalpy")
self.assertEqual(iterations, 5)
self.assertTrue(converged)
def test_state_recovery_iterations_are_recorded_only_on_cache_miss(self) -> None:
pressure = 15.3e6
temperature = 293.15
volume = 0.057
mass = self.medium.density(pressure, temperature) * volume
internal_energy = mass * self.medium.specific_internal_energy_at_pressure(
pressure,
temperature,
)
with patch(
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations:
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()
operation, iterations, converged = record_iterations.call_args.args
self.assertEqual(operation, "properties_from_mU")
self.assertEqual(iterations, 5)
self.assertTrue(converged)
def test_temperature_iteration_limit_is_reported_as_not_converged(self) -> None:
pressure = 123_456.0
base_temperature = 300.0
enthalpy = self.medium.specific_enthalpy(base_temperature)
fake_fluid = Mock()
fake_fluid.residual_specific_enthalpy.side_effect = (
lambda _pressure, temperature: (
self.medium.cp_ref * 10.0
if temperature >= base_temperature
else -self.medium.cp_ref * 10.0
)
)
with (
patch.object(AmesimHeliumPengRobinsonMedium, "fluid", fake_fluid),
patch(
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations,
):
self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
record_iterations.assert_called_once_with(
"temperature_from_pressure_enthalpy",
16,
False,
)
def test_state_recovery_iteration_limit_is_reported_as_not_converged(self) -> None:
base_temperature = 300.0
target_internal_energy = self.medium.specific_internal_energy(
base_temperature
)
fake_fluid = Mock()
fake_fluid.residual_specific_internal_energy_at_density.side_effect = (
lambda temperature, _density: (
self.medium.cv * 10.0
if temperature >= base_temperature
else -self.medium.cv * 10.0
)
)
fake_fluid.pressure_from_density.return_value = 101_325.0
fake_fluid.residual_specific_enthalpy.return_value = 0.0
with (
patch.object(AmesimHeliumPengRobinsonMedium, "fluid", fake_fluid),
patch(
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations,
):
self.medium.properties_from_mU(
1.0,
target_internal_energy,
1.0,
)
record_iterations.assert_called_once_with(
"properties_from_mU",
16,
False,
)
if __name__ == "__main__":
unittest.main()
-88
View File
@@ -1,88 +0,0 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from app.simulation.examples.test_mql.run_baseline import (
TestMqlBaselineExecutionConfig,
TestMqlBaselinePathConfig,
TestMqlBaselineScriptConfig,
format_test_mql_baseline_summary,
run_test_mql_baseline,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class RunTestMqlBaselineScriptTests(unittest.TestCase):
def test_baseline_script_writes_summary_for_selected_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
run, returned_output_dir = run_test_mql_baseline(
TestMqlBaselineScriptConfig(
paths=TestMqlBaselinePathConfig(
archive_path=TEST_MQL_AME,
output_dir=output_dir,
),
execution=TestMqlBaselineExecutionConfig(
data_paths=("press@pn_c1_8", "vol1@pn_brp2_8"),
),
)
)
summary_path = output_dir / "test_mql_baseline_summary.txt"
summary = summary_path.read_text(encoding="utf-8")
self.assertEqual(returned_output_dir, output_dir)
self.assertEqual(run.sample_count, 1002)
self.assertEqual(run.signal_count, 2)
self.assertEqual(run.comparison.max_abs_error, 0.0)
self.assertIn("Mode: AMESim baseline passthrough", summary)
self.assertIn("Samples: 1002", summary)
self.assertIn("Compared signals: 2", summary)
self.assertIn("Max absolute error: 0.0", summary)
def test_baseline_script_can_skip_summary_file(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
run, returned_output_dir = run_test_mql_baseline(
TestMqlBaselineScriptConfig(
paths=TestMqlBaselinePathConfig(
archive_path=TEST_MQL_AME,
output_dir=output_dir,
),
execution=TestMqlBaselineExecutionConfig(
write_summary=False,
data_paths=("press@pn_c1_8",),
),
)
)
self.assertEqual(returned_output_dir, output_dir)
self.assertEqual(run.signal_count, 1)
self.assertFalse((output_dir / "test_mql_baseline_summary.txt").exists())
def test_format_test_mql_baseline_summary(self) -> None:
run, _ = run_test_mql_baseline(
TestMqlBaselineScriptConfig(
paths=TestMqlBaselinePathConfig(archive_path=TEST_MQL_AME),
execution=TestMqlBaselineExecutionConfig(
write_summary=False,
data_paths=("press@pn_c1_8",),
),
)
)
summary = format_test_mql_baseline_summary(run)
self.assertIn("Model: test_mql", summary)
self.assertIn("Output schema signals: 858", summary)
self.assertIn("Compared signals: 1", summary)
self.assertTrue(summary.endswith("\n"))
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large. Load diff
-31
View File
@@ -1,31 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.examples.testmodel.run import prepare_testmodel_run
from app.simulation.paths import (
PROJECT_ROOT,
SIMULATION_RUNS_DIR,
)
class SimulationPathTests(unittest.TestCase):
def test_default_run_paths_are_independent_of_module_depth(self) -> None:
prepared = prepare_testmodel_run()
self.assertEqual(prepared.repo_root, PROJECT_ROOT)
self.assertEqual(prepared.output_dir.parent, SIMULATION_RUNS_DIR)
def test_testmodel_baselines_live_with_the_test_assets(self) -> None:
baseline_dir = PROJECT_ROOT / "tests" / "data" / "testmodel"
self.assertTrue((baseline_dir / "testmodel_primary_series.csv").is_file())
self.assertTrue(
(
baseline_dir / "testmodel_modelica_comparison_summary.txt"
).is_file()
)
if __name__ == "__main__":
unittest.main()
+9 -126
View File
@@ -27,46 +27,6 @@ def profiling_mode(mode: str):
class SimulationPerformanceTests(unittest.TestCase):
def test_off_mode_decorators_return_original_callables(self) -> None:
with profiling_mode("off") as module:
def phase_function(value: int) -> int:
return value + 1
def property_function(value: int) -> int:
return value * 2
self.assertIs(
module.profile_phase("phase")(phase_function),
phase_function,
)
self.assertIs(
module.profile_phase("phase", minimum_mode="off")(phase_function),
phase_function,
)
self.assertIs(
module.profile_property("property")(property_function),
property_function,
)
self.assertIs(
module.profile_property(
"property",
minimum_mode="off",
)(property_function),
property_function,
)
with module.profile_run() as trace:
self.assertEqual(phase_function(2), 3)
self.assertEqual(
trace.snapshot(),
{
"mode": "off",
"phases": {},
"properties": {},
"propertyOutermostNs": 0,
},
)
def test_standard_mode_records_nested_inclusive_and_self_time(self) -> None:
with profiling_mode("standard") as module:
@@ -134,94 +94,8 @@ class SimulationPerformanceTests(unittest.TestCase):
self.assertEqual(snapshot["phases"]["manual"]["inclusiveNs"], 10)
self.assertEqual(snapshot["phases"]["simulation.total"]["selfNs"], 20)
def test_nested_properties_only_add_outermost_time_once(self) -> None:
with profiling_mode("standard") as module:
class TestMedium:
name = "TestMedium"
@module.profile_property("inner", minimum_mode="standard")
def inner(self) -> float:
return 1.0
@module.profile_property("outer", minimum_mode="standard")
def outer(self) -> float:
return self.inner()
medium = TestMedium()
clock = iter((0, 10, 20, 30, 50, 80))
with patch.object(module, "perf_counter_ns", side_effect=clock):
with module.profile_run() as trace:
self.assertEqual(medium.outer(), 1.0)
snapshot = trace.snapshot()
outer = snapshot["properties"]["semantic.TestMedium.outer"]
inner = snapshot["properties"]["semantic.TestMedium.inner"]
self.assertEqual(outer["inclusiveNs"], 40)
self.assertEqual(outer["selfNs"], 30)
self.assertEqual(inner["inclusiveNs"], 10)
self.assertEqual(snapshot["propertyOutermostNs"], 40)
def test_audit_resets_exact_input_shadow_and_records_iterations_and_cache(self) -> None:
with profiling_mode("audit") as module:
class TestMedium:
name = "AuditMedium"
@module.profile_property("inverse", track_cache=True)
@lru_cache(maxsize=4)
def inverse(self, value: float) -> float:
module.record_property_iterations(
"inverse",
3 if value == 1.0 else 4,
value == 1.0,
)
return value * 2.0
medium = TestMedium()
@module.profile_phase("closure", reset_property_shadow=True)
def closure() -> None:
medium.inverse(1.0)
medium.inverse(1.0)
medium.inverse(2.0)
with module.profile_run() as trace:
closure()
closure()
metric = trace.snapshot()["properties"][
"semantic.AuditMedium.inverse"
]
self.assertEqual(metric["calls"], 6)
self.assertEqual(metric["exactInputUnique"], 4)
self.assertEqual(metric["exactInputRepeats"], 2)
self.assertEqual(metric["iterationCalls"], 2)
self.assertEqual(metric["iterationTotal"], 7)
self.assertEqual(metric["iterationMax"], 4)
self.assertEqual(metric["iterationConverged"], 1)
self.assertEqual(metric["iterationNonconverged"], 1)
self.assertEqual(metric["cacheLookups"], 6)
self.assertEqual(metric["cacheHits"], 4)
self.assertEqual(metric["cacheMisses"], 2)
self.assertTrue(callable(medium.inverse.cache_clear))
self.assertTrue(callable(medium.inverse.cache_info))
self.assertTrue(callable(medium.inverse.cache_parameters))
medium.inverse.cache_clear()
self.assertEqual(medium.inverse.cache_info().currsize, 0)
def test_audit_minimum_decorator_is_absent_in_standard_mode(self) -> None:
with profiling_mode("standard") as module:
def kernel(value: float) -> float:
return value
self.assertIs(
module.profile_property(
"kernel",
layer="kernel",
minimum_mode="audit",
)(kernel),
kernel,
)
def test_async_profile_runs_are_context_isolated(self) -> None:
with profiling_mode("standard") as module:
@@ -246,3 +120,12 @@ class SimulationPerformanceTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class NativeOrchestrationPerformanceTests(unittest.TestCase):
def test_off_mode_keeps_the_original_phase_callable(self):
with profiling_mode('off') as module:
def work(): return 1
self.assertIs(module.profile_phase('work')(work), work)
with module.profile_run() as trace:
work()
self.assertEqual(trace.snapshot(), {'mode': 'off', 'phases': {}})
@@ -1,242 +0,0 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import textwrap
import unittest
PROJECT_ROOT = Path(__file__).resolve().parent.parent
class SimulationPerformancePipelineTests(unittest.TestCase):
def test_off_mode_does_not_change_result_diagnostics(self) -> None:
script = textwrap.dedent(
"""
from app.main import (
build_reactflow_system_xml,
run_system_xml_simulation,
)
from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project
result = run_system_xml_simulation(
build_reactflow_system_xml(amesim_pnl00r_project())
)
assert result["success"], result["message"]
print("performance" in result["diagnostics"])
"""
)
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "off"
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
self.assertEqual(completed.stdout.strip(), "False")
def test_standard_mode_reports_coarse_pipeline_phases_per_run(self) -> None:
script = textwrap.dedent(
"""
import json
from app.main import (
build_reactflow_system_xml,
run_system_xml_simulation,
)
from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project
xml = build_reactflow_system_xml(amesim_pnl00r_project())
snapshots = []
for _ in range(2):
result = run_system_xml_simulation(xml)
assert result["success"], result["message"]
performance = result["diagnostics"]["performance"]
snapshots.append(
{
"mode": performance["mode"],
"calls": {
name: metrics["calls"]
for name, metrics in performance["phases"].items()
},
"propertyCount": len(performance["properties"]),
}
)
print(json.dumps(snapshots))
"""
)
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "standard"
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
snapshots = json.loads(completed.stdout)
required_phases = {
"simulation.total",
"simulation.xml_validation",
"simulation.network_compilation",
"simulation.system_construction",
"simulation.sample_initialization",
"simulation.integration",
"simulation.postprocessing",
"simulation.result_assembly",
"simulation.response_assembly",
}
audit_only_phases = {
"simulation.rhs",
"simulation.closure",
"simulation.refresh",
"simulation.pressure_flow",
"simulation.pneumatic_volume",
"simulation.stream",
"simulation.signal",
"simulation.derivatives",
}
self.assertEqual(len(snapshots), 2)
for snapshot in snapshots:
self.assertEqual(snapshot["mode"], "standard")
self.assertTrue(required_phases.issubset(snapshot["calls"]))
self.assertEqual(snapshot["calls"]["simulation.total"], 1)
self.assertEqual(snapshot["calls"]["simulation.xml_validation"], 1)
self.assertEqual(snapshot["calls"]["simulation.network_compilation"], 1)
self.assertEqual(snapshot["calls"]["simulation.integration"], 1)
self.assertEqual(snapshot["calls"]["simulation.postprocessing"], 1)
self.assertEqual(snapshot["calls"]["simulation.result_assembly"], 1)
self.assertEqual(snapshot["calls"]["simulation.response_assembly"], 1)
self.assertTrue(audit_only_phases.isdisjoint(snapshot["calls"]))
self.assertEqual(snapshot["propertyCount"], 0)
def test_audit_mode_reports_hot_phases_and_property_metrics(self) -> None:
script = textwrap.dedent(
"""
import json
from app.main import (
build_reactflow_system_xml,
run_system_xml_simulation,
)
from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project
result = run_system_xml_simulation(
build_reactflow_system_xml(amesim_pnl00r_project())
)
assert result["success"], result["message"]
print(json.dumps(result["diagnostics"]["performance"]))
"""
)
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "audit"
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
snapshot = json.loads(completed.stdout)
for phase in (
"simulation.rhs",
"simulation.closure",
"simulation.refresh",
"simulation.pressure_flow",
"simulation.pneumatic_volume",
"simulation.stream",
"simulation.signal",
"simulation.derivatives",
):
self.assertGreater(snapshot["phases"][phase]["calls"], 0)
self.assertGreater(len(snapshot["properties"]), 0)
self.assertGreater(snapshot["propertyOutermostNs"], 0)
def test_audit_property_totals_match_run_local_cache(self) -> None:
script = textwrap.dedent(
"""
import json
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
from app.simulation.performance import profile_run
from app.simulation.property_cache import property_cache_run
medium = AmesimHeliumPengRobinsonMedium()
pressure = 15.3e6
temperature = 293.15
volume = 0.057
with property_cache_run() as cache:
assert cache is not None
with profile_run() as trace:
density = medium.density(pressure, temperature)
medium.density(pressure, temperature)
medium.isentropic_density_pressure_factor(
pressure, temperature, 1.0e6
)
medium.isentropic_density_pressure_factor(
pressure, temperature, 1.0e6
)
enthalpy = medium.specific_enthalpy_at_pressure(
pressure, temperature
)
medium.temperature_from_pressure_enthalpy(pressure, enthalpy)
medium.temperature_from_pressure_enthalpy(pressure, enthalpy)
mass = density * volume
energy = mass * medium.specific_internal_energy_at_pressure(
pressure, temperature
)
medium.properties_from_mU(mass, energy, volume)
medium.properties_from_mU(mass, energy, volume)
cache_info = cache.info()
snapshot = trace.snapshot()
cached_properties = [
metrics
for metrics in snapshot["properties"].values()
if metrics["cacheLookups"] > 0
]
print(json.dumps({
"cacheHits": cache_info.hits,
"cacheMisses": cache_info.misses,
"propertyHits": sum(item["cacheHits"] for item in cached_properties),
"propertyMisses": sum(item["cacheMisses"] for item in cached_properties),
}))
"""
)
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "audit"
environment["SIMULATIONAPP_PROPERTY_CACHE"] = "on"
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
totals = json.loads(completed.stdout)
self.assertGreater(totals["cacheHits"], 0)
self.assertGreater(totals["cacheMisses"], 0)
self.assertEqual(totals["cacheHits"], totals["propertyHits"])
self.assertEqual(totals["cacheMisses"], totals["propertyMisses"])
if __name__ == "__main__":
unittest.main()
-214
View File
@@ -1,214 +0,0 @@
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()
+12 -31
View File
@@ -9,10 +9,9 @@ from app.simulation.components.amesim.mechanical.translational import (
)
from app.simulation.components.amesim.signals.sources import AmesimStep0
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import (
from app.simulation.config import SolveIVPConfig
from app.simulation.sampling import (
SimulationSampleTimeError,
simulation_preparation_issues,
simulation_sample_times,
)
from app.simulation.systems.network import SimulationNetwork
@@ -193,35 +192,17 @@ class SimulationSampleTimeSafetyTests(unittest.TestCase):
)
class PhysicalIslandSafetyTests(unittest.TestCase):
def test_signal_fanout_does_not_hide_an_unanchored_physical_island(self) -> None:
issues = simulation_preparation_issues(
_fanout_mechanical_network(second_island_has_storage=False)
)
island_issues = [
issue
for issue in issues
if issue.code == "ALGEBRAIC_ISLAND_HAS_NO_STORAGE"
]
self.assertEqual(len(island_issues), 1)
self.assertIn("force_b", island_issues[0].message)
self.assertIn("zero_b", island_issues[0].message)
self.assertNotIn("command", island_issues[0].message)
self.assertNotIn("mass_a", island_issues[0].message)
def test_signal_fanout_between_two_anchored_physical_islands_is_allowed(
self,
) -> None:
issues = simulation_preparation_issues(
_fanout_mechanical_network(second_island_has_storage=True)
)
self.assertNotIn(
"ALGEBRAIC_ISLAND_HAS_NO_STORAGE",
{issue.code for issue in issues},
)
if __name__ == "__main__":
unittest.main()
class NativePhysicalIslandSafetyTests(unittest.TestCase):
def test_unanchored_force_island_is_rejected(self):
from app.simulation.native_codegen.compiler import NativeCapabilityError, compile_native_program
with self.assertRaises(NativeCapabilityError):
compile_native_program(_fanout_mechanical_network(second_island_has_storage=False))
def test_two_anchored_islands_are_supported(self):
from app.simulation.native_codegen.compiler import compile_native_program
self.assertEqual(len(compile_native_program(_fanout_mechanical_network(second_island_has_storage=True)).state_keys), 4)
+33 -28
View File
@@ -8,7 +8,6 @@ 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,
)
@@ -17,6 +16,9 @@ from app.simulation.warmup import (
class SimulationWarmupTests(unittest.TestCase):
def setUp(self) -> None:
_reset_simulation_warmup_for_tests()
self.engine_environment = patch.dict(os.environ, {"SIMULATION_NUMERIC_ENGINE": "native"})
self.engine_environment.start()
self.addCleanup(self.engine_environment.stop)
def tearDown(self) -> None:
_reset_simulation_warmup_for_tests()
@@ -24,7 +26,7 @@ class SimulationWarmupTests(unittest.TestCase):
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,
patch("app.simulation.warmup._run_native_warmup") as run_warmup,
):
first = warm_up_simulation_runtime()
second = warm_up_simulation_runtime()
@@ -36,7 +38,7 @@ class SimulationWarmupTests(unittest.TestCase):
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,
patch("app.simulation.warmup._run_native_warmup") as run_warmup,
):
report = warm_up_simulation_runtime()
@@ -47,7 +49,7 @@ class SimulationWarmupTests(unittest.TestCase):
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch(
"app.simulation.warmup._run_numerical_warmup",
"app.simulation.warmup._run_native_warmup",
side_effect=RuntimeError("broken warmup"),
),
self.assertLogs("app.simulation.warmup", level="ERROR"),
@@ -61,7 +63,7 @@ class SimulationWarmupTests(unittest.TestCase):
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch(
"app.simulation.warmup._run_numerical_warmup",
"app.simulation.warmup._run_native_warmup",
side_effect=MemoryError("out of memory"),
),
self.assertRaises(MemoryError),
@@ -77,6 +79,7 @@ class SimulationWarmupTests(unittest.TestCase):
return_value=report,
) as warmup:
async with _app_lifespan(app):
self.assertEqual(app.state.simulation_numeric_engine, "native")
self.assertEqual(
app.state.simulation_warmup,
report.as_dict(),
@@ -85,6 +88,31 @@ class SimulationWarmupTests(unittest.TestCase):
asyncio.run(enter_lifespan())
def test_default_startup_checks_native_without_python_warmup(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch("app.simulation.warmup._run_native_warmup") as native,
):
os.environ.pop("SIMULATION_NUMERIC_ENGINE", None)
async def enter_lifespan() -> None:
async with _app_lifespan(app):
self.assertEqual(app.state.simulation_numeric_engine, "native")
self.assertEqual(app.state.simulation_warmup["status"], "completed")
asyncio.run(enter_lifespan())
native.assert_called_once_with()
def test_native_failure_does_not_fall_back_to_python(self) -> None:
with (
patch.dict(os.environ, {"SIMULATION_NUMERIC_ENGINE": "native", "SIMULATIONAPP_WARMUP": "on"}),
patch("app.simulation.warmup._run_native_warmup", side_effect=RuntimeError("C compiler unavailable")),
self.assertLogs("app.simulation.warmup", level="ERROR"),
):
report = warm_up_simulation_runtime()
self.assertEqual(report.status, "failed")
self.assertIn("C compiler unavailable", report.error or "")
def test_real_numerical_warmup_completes(self) -> None:
with patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}):
report = warm_up_simulation_runtime()
@@ -92,29 +120,6 @@ class SimulationWarmupTests(unittest.TestCase):
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__":
-950
View File
@@ -1,950 +0,0 @@
from __future__ import annotations
import unittest
import numpy as np
from scipy.integrate._ivp.common import num_jac
from scipy.optimize._numdiff import group_columns
from scipy.sparse import csc_matrix, csr_matrix
from app.simulation.solvers.jacobian import (
ExactColumnsUnavailable,
SparseSecantJacobian,
)
from app.simulation.solvers.solver import IntegrationCancelled
class _SwitchableLinearRhs:
def __init__(self, matrix: np.ndarray) -> None:
self.matrix = np.asarray(matrix, dtype=float)
self.evaluation_count = 0
def __call__(self, time: float, state: np.ndarray) -> np.ndarray:
del time
self.evaluation_count += 1
return self.matrix @ np.asarray(state, dtype=float)
class SparseSecantJacobianTests(unittest.TestCase):
def test_first_call_builds_full_sparse_finite_difference_jacobian(self) -> None:
matrix = np.asarray(
(
(2.0, 0.0, -1.0),
(0.0, 3.0, 0.0),
(4.0, 0.0, 5.0),
)
)
evaluator = _SwitchableLinearRhs(matrix)
builder = SparseSecantJacobian(
evaluator,
csr_matrix(matrix != 0.0),
atol=np.full(3, 1.0e-8),
)
jacobian = builder(0.0, np.asarray((1.0, -2.0, 0.5)))
np.testing.assert_allclose(jacobian.toarray(), matrix, rtol=1.0e-7)
diagnostics = builder.diagnostics()
self.assertEqual(diagnostics["fullBuildCount"], 1)
self.assertEqual(diagnostics["secantReuseCount"], 0)
self.assertEqual(diagnostics["baseRhsEvaluationCount"], 1)
self.assertGreater(
diagnostics["finiteDifferenceRhsEvaluationCount"],
0,
)
self.assertEqual(
evaluator.evaluation_count,
diagnostics["baseRhsEvaluationCount"]
+ diagnostics["finiteDifferenceRhsEvaluationCount"],
)
def test_same_time_secant_is_reused_once_after_directional_audit(self) -> None:
matrix = np.asarray(((2.0, -1.0), (0.5, 4.0)))
evaluator = _SwitchableLinearRhs(matrix)
builder = SparseSecantJacobian(
evaluator,
csr_matrix(np.ones((2, 2), dtype=bool)),
atol=1.0e-8,
)
builder(0.0, np.asarray((1.0, 1.0)))
first = np.asarray((1.5, -0.5))
second = np.asarray((1.75, -0.25))
builder.observe(0.1, first, matrix @ first)
builder.observe(0.1, second, matrix @ second)
reused = builder(0.1, second)
np.testing.assert_allclose(reused.toarray(), matrix, rtol=1.0e-7)
after_reuse = builder.diagnostics()
self.assertEqual(after_reuse["fullBuildCount"], 1)
self.assertEqual(after_reuse["secantReuseCount"], 1)
self.assertEqual(after_reuse["jvAuditEvaluationCount"], 1)
self.assertEqual(after_reuse["lastDecision"], "secantReuse")
self.assertEqual(after_reuse["jacobianEvaluationCount"], 2)
self.assertEqual(
after_reuse["segments"][0]["jvAuditRhsEvaluationCount"],
1,
)
self.assertGreater(after_reuse["segments"][0]["assemblySeconds"], 0.0)
rebuilt = builder(0.1, second)
np.testing.assert_allclose(rebuilt.toarray(), matrix, rtol=1.0e-7)
self.assertEqual(builder.diagnostics()["fullBuildCount"], 2)
def test_failed_directional_audit_falls_back_to_full_refresh(self) -> None:
initial_matrix = np.eye(2)
changed_matrix = np.asarray(((1.0, 0.0), (0.0, 10.0)))
evaluator = _SwitchableLinearRhs(initial_matrix)
builder = SparseSecantJacobian(
evaluator,
csr_matrix(np.ones((2, 2), dtype=bool)),
atol=1.0e-8,
audit_relative_tolerance=1.0e-3,
)
builder(0.0, np.asarray((1.0, 1.0)))
evaluator.matrix = changed_matrix
first = np.asarray((1.0, 1.0))
second = np.asarray((2.0, 1.0))
builder.observe(0.2, first, changed_matrix @ first)
builder.observe(0.2, second, changed_matrix @ second)
refreshed = builder(0.2, second)
np.testing.assert_allclose(
refreshed.toarray(),
changed_matrix,
rtol=1.0e-7,
)
diagnostics = builder.diagnostics()
self.assertEqual(diagnostics["fullBuildCount"], 2)
self.assertEqual(diagnostics["secantReuseCount"], 0)
self.assertEqual(diagnostics["auditFailureCount"], 1)
self.assertEqual(diagnostics["jvAuditEvaluationCount"], 1)
self.assertEqual(diagnostics["lastDecision"], "auditFallback")
def test_directional_audit_does_not_swallow_cancellation(self) -> None:
matrix = np.asarray(((2.0, -1.0), (0.5, 4.0)))
cancel_next = False
def evaluator(_time: float, state: np.ndarray) -> np.ndarray:
nonlocal cancel_next
if cancel_next:
cancel_next = False
raise IntegrationCancelled
return matrix @ np.asarray(state, dtype=float)
builder = SparseSecantJacobian(
evaluator,
csr_matrix(np.ones((2, 2), dtype=bool)),
atol=1.0e-8,
)
builder(0.0, np.asarray((1.0, 1.0)))
first = np.asarray((1.5, -0.5))
second = np.asarray((1.75, -0.25))
builder.observe(0.1, first, matrix @ first)
builder.observe(0.1, second, matrix @ second)
cancel_next = True
with self.assertRaises(IntegrationCancelled):
builder(0.1, second)
diagnostics = builder.diagnostics()
self.assertEqual(diagnostics["fullBuildCount"], 1)
self.assertEqual(diagnostics["auditFailureCount"], 0)
self.assertEqual(diagnostics["secantReuseCount"], 0)
def test_segment_reset_forces_a_new_full_build(self) -> None:
matrix = np.asarray(((3.0, 0.0), (0.0, -2.0)))
evaluator = _SwitchableLinearRhs(matrix)
builder = SparseSecantJacobian(
evaluator,
csr_matrix(matrix != 0.0),
atol=1.0e-8,
)
state = np.asarray((2.0, 4.0))
builder(0.0, state)
builder.start_segment()
rebuilt = builder(1.0, state)
np.testing.assert_allclose(rebuilt.toarray(), matrix, rtol=1.0e-7)
diagnostics = builder.diagnostics()
self.assertEqual(diagnostics["segmentStartCount"], 1)
self.assertEqual(diagnostics["fullBuildCount"], 2)
self.assertEqual(diagnostics["lastDecision"], "fullBuild")
self.assertEqual(len(diagnostics["segments"]), 2)
self.assertEqual(diagnostics["segments"][0]["fullBuildCount"], 1)
self.assertEqual(diagnostics["segments"][1]["fullBuildCount"], 1)
def test_exact_row_replaces_finite_difference_and_secant_row(self) -> None:
def nonlinear_rhs(time: float, state: np.ndarray) -> np.ndarray:
del time
return np.asarray(
(
state[0] * state[0] + 3.0 * state[1],
-2.0 * state[0] + state[1],
)
)
builder = SparseSecantJacobian(
nonlinear_rhs,
csr_matrix(np.ones((2, 2), dtype=bool)),
atol=1.0e-8,
exact_rows={0: {0: 7.0, 1: 8.0}},
)
jacobian = builder(0.0, np.asarray((2.0, 1.0))).toarray()
np.testing.assert_array_equal(jacobian[0], np.asarray((7.0, 8.0)))
np.testing.assert_allclose(jacobian[1], np.asarray((-2.0, 1.0)), rtol=1.0e-7)
def test_exact_columns_match_dense_num_jac_and_use_normalized_order(self) -> None:
def nonlinear_rhs(time: float, state: np.ndarray) -> np.ndarray:
del time
return np.asarray(
(
state[0] * state[0] + state[1] * state[2],
np.sin(state[0]) + 3.0 * state[1] - state[2],
np.exp(state[2]) + state[0] * state[1],
)
)
provider_calls: list[tuple[float, np.ndarray, tuple[int, ...]]] = []
def exact_column_provider(
time: float,
state: np.ndarray,
columns: tuple[int, ...],
) -> np.ndarray:
provider_calls.append((time, state.copy(), columns))
self.assertEqual(columns, (0, 2))
return np.asarray(
(
(2.0 * state[0], state[1]),
(np.cos(state[0]), -1.0),
(state[1], np.exp(state[2])),
)
)
state = np.asarray((1.25, -0.75, 0.2))
atol = np.full(3, 1.0e-8)
structure = csc_matrix(np.ones((3, 3), dtype=bool))
base_rhs = nonlinear_rhs(0.3, state)
def vectorized_rhs(time: float, states: np.ndarray) -> np.ndarray:
states_array = np.asarray(states, dtype=float)
if states_array.ndim == 1:
return nonlinear_rhs(time, states_array)
return np.column_stack(
[
nonlinear_rhs(time, states_array[:, column])
for column in range(states_array.shape[1])
]
)
expected, _factor = num_jac(
vectorized_rhs,
0.3,
state,
base_rhs,
atol,
None,
)
builder = SparseSecantJacobian(
nonlinear_rhs,
structure,
atol=atol,
exact_columns=((2, 0), exact_column_provider),
max_consecutive_reuses=0,
)
actual = builder(0.3, state).toarray()
np.testing.assert_allclose(actual, expected, rtol=2.0e-7, atol=2.0e-8)
self.assertEqual(len(provider_calls), 1)
self.assertEqual(provider_calls[0][0], 0.3)
np.testing.assert_array_equal(provider_calls[0][1], state)
diagnostics = builder.diagnostics()
self.assertEqual(diagnostics["exactColumnCount"], 2)
self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 1)
self.assertEqual(diagnostics["colorGroupCount"], 1)
self.assertEqual(
diagnostics["exactColumnOutsidePatternNonzeroCount"],
0,
)
def test_exact_columns_reduce_seed_zero_coloring(self) -> None:
matrix = np.asarray(
(
(1.0, 2.0, 3.0, 4.0),
(5.0, 6.0, 7.0, 8.0),
(9.0, 10.0, 11.0, 12.0),
(13.0, 14.0, 15.0, 16.0),
)
)
structure = csc_matrix(np.ones((4, 4), dtype=bool))
baseline = SparseSecantJacobian(
_SwitchableLinearRhs(matrix),
structure,
atol=1.0e-8,
max_consecutive_reuses=0,
)
reduced = SparseSecantJacobian(
_SwitchableLinearRhs(matrix),
structure,
atol=1.0e-8,
exact_columns={1: matrix[:, 1], 3: matrix[:, 3]},
max_consecutive_reuses=0,
)
self.assertEqual(baseline.diagnostics()["colorGroupCount"], 4)
diagnostics = reduced.diagnostics()
self.assertEqual(diagnostics["coloringSeed"], 0)
self.assertEqual(diagnostics["colorGroupCount"], 2)
self.assertEqual(diagnostics["exactColumnCount"], 2)
self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 2)
def test_exact_column_nonzeros_outside_pattern_are_preserved(self) -> None:
evaluator = _SwitchableLinearRhs(np.eye(3))
builder = SparseSecantJacobian(
evaluator,
csc_matrix(np.eye(3, dtype=bool)),
atol=1.0e-8,
exact_columns={1: np.asarray((4.0, 5.0, 6.0))},
max_consecutive_reuses=0,
)
jacobian = builder(0.0, np.asarray((1.0, 2.0, 3.0))).toarray()
np.testing.assert_array_equal(jacobian[:, 1], np.asarray((4.0, 5.0, 6.0)))
self.assertEqual(
builder.diagnostics()["exactColumnOutsidePatternNonzeroCount"],
2,
)
def test_exact_rows_override_exact_column_intersections(self) -> None:
builder = SparseSecantJacobian(
_SwitchableLinearRhs(np.eye(3)),
csc_matrix(np.eye(3, dtype=bool)),
atol=1.0e-8,
exact_rows={0: {0: 9.0, 1: 10.0}},
exact_columns={1: np.asarray((4.0, 5.0, 6.0))},
max_consecutive_reuses=0,
)
jacobian = builder(0.0, np.asarray((1.0, 2.0, 3.0))).toarray()
np.testing.assert_array_equal(jacobian[0], np.asarray((9.0, 10.0, 0.0)))
np.testing.assert_array_equal(jacobian[1:, 1], np.asarray((5.0, 6.0)))
def test_all_exact_columns_skip_every_finite_difference_rhs(self) -> None:
matrix = np.asarray(((2.0, -1.0), (3.0, 4.0)))
provider_calls = 0
evaluator = _SwitchableLinearRhs(matrix)
def exact_column_provider(
time: float,
state: np.ndarray,
columns: tuple[int, ...],
) -> np.ndarray:
nonlocal provider_calls
del time, state
provider_calls += 1
self.assertEqual(columns, (0, 1))
return matrix
builder = SparseSecantJacobian(
evaluator,
csc_matrix(np.ones((2, 2), dtype=bool)),
atol=1.0e-8,
exact_columns=((1, 0), exact_column_provider),
max_consecutive_reuses=0,
)
jacobian = builder(0.0, np.asarray((1.0, 2.0))).toarray()
np.testing.assert_array_equal(jacobian, matrix)
self.assertEqual(provider_calls, 1)
self.assertEqual(evaluator.evaluation_count, 1)
diagnostics = builder.diagnostics()
self.assertEqual(diagnostics["colorGroupCount"], 0)
self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 0)
self.assertEqual(diagnostics["baseRhsEvaluationCount"], 1)
self.assertEqual(diagnostics["finiteDifferenceRhsEvaluationCount"], 0)
def test_exact_column_provider_runs_after_base_and_before_fd(self) -> None:
matrix = np.asarray(
(
(2.0, 1.0, 0.0),
(0.0, 3.0, 4.0),
(5.0, 0.0, 6.0),
)
)
state = np.asarray((1.0, 2.0, 3.0))
events: list[tuple[str, int]] = []
evaluated_states: list[np.ndarray] = []
rhs_epoch = 0
def evaluator(time: float, evaluation_state: np.ndarray) -> np.ndarray:
nonlocal rhs_epoch
del time
rhs_epoch += 1
events.append(("rhs", rhs_epoch))
evaluated_states.append(evaluation_state.copy())
return matrix @ evaluation_state
def provider(
time: float,
provider_state: np.ndarray,
columns: tuple[int, ...],
) -> np.ndarray:
del time
events.append(("provider", rhs_epoch))
self.assertEqual(rhs_epoch, 1)
self.assertEqual(columns, (1,))
np.testing.assert_array_equal(provider_state, state)
return matrix[:, [1]]
builder = SparseSecantJacobian(
evaluator,
csc_matrix(matrix != 0.0),
atol=1.0e-8,
exact_columns=((1,), provider),
max_consecutive_reuses=0,
)
builder(0.0, state)
self.assertEqual(events[0], ("rhs", 1))
self.assertEqual(events[1], ("provider", 1))
self.assertTrue(any(event[0] == "rhs" for event in events[2:]))
diagnostics = builder.diagnostics()
self.assertEqual(
len(evaluated_states),
1 + diagnostics["colorGroupCount"],
)
self.assertEqual(
diagnostics["finiteDifferenceRhsEvaluationCount"],
diagnostics["colorGroupCount"],
)
for perturbed_state in evaluated_states[1:]:
self.assertEqual(perturbed_state[1], state[1])
self.assertTrue(np.any(perturbed_state[[0, 2]] != state[[0, 2]]))
def test_exact_column_capture_request_is_always_cancelled(self) -> None:
matrix = np.asarray(((2.0, 1.0), (0.0, 3.0)))
class Provider:
def __init__(self) -> None:
self.pending = False
self.request_count = 0
self.cancel_count = 0
self.call_count = 0
def request_primal_capture(self) -> None:
self.request_count += 1
self.pending = True
def cancel_primal_capture(self) -> None:
self.cancel_count += 1
self.pending = False
def __call__(
self,
time: float,
state: np.ndarray,
columns: tuple[int, ...],
) -> np.ndarray:
del time, state
self.call_count += 1
self.assert_capture_was_cancelled(columns)
return matrix[:, [1]]
def assert_capture_was_cancelled(
self,
columns: tuple[int, ...],
) -> None:
if self.pending or columns != (1,):
raise AssertionError("The one-shot capture request leaked.")
provider = Provider()
builder = SparseSecantJacobian(
_SwitchableLinearRhs(matrix),
csc_matrix(matrix != 0.0),
atol=1.0e-8,
exact_columns=((1,), provider),
max_consecutive_reuses=0,
)
jacobian = builder(0.0, np.asarray((1.0, 2.0))).toarray()
np.testing.assert_allclose(jacobian, matrix, rtol=1.0e-7)
self.assertEqual(provider.request_count, 1)
self.assertEqual(provider.cancel_count, 1)
self.assertEqual(provider.call_count, 1)
self.assertFalse(provider.pending)
def test_exact_column_provider_output_is_validated_and_errors_propagate(self) -> None:
state = np.asarray((1.0, 2.0, 3.0))
structure = csc_matrix(np.eye(3, dtype=bool))
with self.assertRaisesRegex(ValueError, "duplicated"):
SparseSecantJacobian(
_SwitchableLinearRhs(np.eye(3)),
structure,
atol=1.0e-8,
exact_columns=((1, 1), lambda *_args: np.zeros((3, 2))),
)
with self.assertRaisesRegex(ValueError, "out of range"):
SparseSecantJacobian(
_SwitchableLinearRhs(np.eye(3)),
structure,
atol=1.0e-8,
exact_columns=((3,), lambda *_args: np.zeros((3, 1))),
)
invalid_builders = (
(
SparseSecantJacobian(
_SwitchableLinearRhs(np.eye(3)),
structure,
atol=1.0e-8,
exact_columns=((0, 2), lambda *_args: np.zeros((2, 3))),
max_consecutive_reuses=0,
),
"must return shape",
),
(
SparseSecantJacobian(
_SwitchableLinearRhs(np.eye(3)),
structure,
atol=1.0e-8,
exact_columns=(
(0, 2),
lambda *_args: np.asarray(
((1.0, 0.0), (0.0, np.nan), (0.0, 1.0))
),
),
max_consecutive_reuses=0,
),
"finite values",
),
(
SparseSecantJacobian(
_SwitchableLinearRhs(np.eye(3)),
structure,
atol=1.0e-8,
exact_columns=(
(0, 2),
lambda *_args: {
2: np.zeros(3),
0: np.zeros(3),
},
),
max_consecutive_reuses=0,
),
"normalized column order",
),
)
for builder, message in invalid_builders:
with self.subTest(message=message):
with self.assertRaisesRegex(ValueError, message):
builder(0.0, state)
class ProviderFailure(RuntimeError):
pass
def failing_provider(*_args):
raise ProviderFailure("provider failed")
failure_evaluator = _SwitchableLinearRhs(np.eye(3))
failing_builder = SparseSecantJacobian(
failure_evaluator,
structure,
atol=1.0e-8,
exact_columns=((0,), failing_provider),
max_consecutive_reuses=0,
)
with self.assertRaisesRegex(ProviderFailure, "provider failed"):
failing_builder(0.0, state)
self.assertEqual(failure_evaluator.evaluation_count, 1)
failure_diagnostics = failing_builder.diagnostics()
self.assertEqual(failure_diagnostics["baseRhsEvaluationCount"], 1)
self.assertEqual(
failure_diagnostics["finiteDifferenceRhsEvaluationCount"],
0,
)
def test_exact_column_provider_is_refreshed_after_segment_reset(self) -> None:
calls: list[tuple[float, np.ndarray, tuple[int, ...]]] = []
def nonlinear_rhs(time: float, state: np.ndarray) -> np.ndarray:
return np.asarray(
(
2.0 * state[0] + (1.0 + time) * state[1],
state[0] * state[1],
)
)
def provider(
time: float,
state: np.ndarray,
columns: tuple[int, ...],
) -> np.ndarray:
calls.append((time, state.copy(), columns))
return np.asarray(((1.0 + time,), (state[0],)))
builder = SparseSecantJacobian(
nonlinear_rhs,
csc_matrix(np.ones((2, 2), dtype=bool)),
atol=1.0e-8,
exact_columns=((1,), provider),
max_consecutive_reuses=0,
)
first_state = np.asarray((1.0, 2.0))
second_state = np.asarray((3.0, 4.0))
first = builder(0.0, first_state).toarray()
builder.start_segment()
second = builder(1.0, second_state).toarray()
np.testing.assert_allclose(first[:, 1], np.asarray((1.0, 1.0)))
np.testing.assert_allclose(second[:, 1], np.asarray((2.0, 3.0)))
self.assertEqual([call[0] for call in calls], [0.0, 1.0])
self.assertEqual([call[2] for call in calls], [(1,), (1,)])
diagnostics = builder.diagnostics()
self.assertEqual(diagnostics["fullBuildCount"], 2)
self.assertEqual(diagnostics["segmentStartCount"], 1)
self.assertEqual(len(diagnostics["segments"]), 2)
def test_exact_column_unavailable_falls_back_and_next_build_recovers(self) -> None:
matrix = np.asarray(
(
(2.0, 1.0, 3.0, -1.0),
(4.0, 5.0, -2.0, 6.0),
(7.0, -3.0, 8.0, 2.0),
(-4.0, 9.0, 1.0, 10.0),
)
)
state = np.asarray((1.0, -2.0, 0.5, 3.0))
evaluated_states: list[np.ndarray] = []
provider_call_count = 0
def evaluator(time: float, evaluation_state: np.ndarray) -> np.ndarray:
del time
evaluated_states.append(evaluation_state.copy())
return matrix @ evaluation_state
def provider(
time: float,
provider_state: np.ndarray,
columns: tuple[int, ...],
) -> np.ndarray:
nonlocal provider_call_count
del time, provider_state
provider_call_count += 1
if provider_call_count == 1:
raise ExactColumnsUnavailable("tangent domain boundary")
return matrix[:, columns]
structure = csc_matrix(np.ones((4, 4), dtype=bool))
builder = SparseSecantJacobian(
evaluator,
structure,
atol=1.0e-8,
exact_columns=((3, 1), provider),
max_consecutive_reuses=0,
)
baseline = SparseSecantJacobian(
_SwitchableLinearRhs(matrix),
structure,
atol=1.0e-8,
max_consecutive_reuses=0,
)
fallback = builder(0.0, state).toarray()
expected_fallback = baseline(0.0, state).toarray()
np.testing.assert_array_equal(fallback, expected_fallback)
self.assertEqual(provider_call_count, 1)
self.assertTrue(
any(
evaluation_state[1] != state[1]
for evaluation_state in evaluated_states[1:]
)
)
self.assertTrue(
any(
evaluation_state[3] != state[3]
for evaluation_state in evaluated_states[1:]
)
)
fallback_diagnostics = builder.diagnostics()
self.assertEqual(fallback_diagnostics["baseRhsEvaluationCount"], 1)
self.assertEqual(fallback_diagnostics["originalColorGroupCount"], 4)
self.assertEqual(fallback_diagnostics["remainingColorGroupCount"], 2)
self.assertEqual(fallback_diagnostics["exactColumnBuildCount"], 0)
self.assertEqual(fallback_diagnostics["exactColumnFallbackCount"], 1)
self.assertEqual(
fallback_diagnostics["lastExactColumnFallbackReason"],
"tangent domain boundary",
)
self.assertEqual(
fallback_diagnostics[
"lastBuildFiniteDifferenceRhsEvaluationCount"
],
4,
)
self.assertIsNotNone(builder._original_factor)
self.assertIsNone(builder._remaining_factor)
recovery_start = len(evaluated_states)
recovered = builder(0.1, state).toarray()
recovery_states = evaluated_states[recovery_start:]
np.testing.assert_allclose(recovered, matrix, rtol=1.0e-7)
self.assertEqual(provider_call_count, 2)
self.assertEqual(len(recovery_states), 3)
for perturbed_state in recovery_states[1:]:
self.assertEqual(perturbed_state[1], state[1])
self.assertEqual(perturbed_state[3], state[3])
recovery_diagnostics = builder.diagnostics()
self.assertEqual(recovery_diagnostics["exactColumnBuildCount"], 1)
self.assertEqual(recovery_diagnostics["exactColumnFallbackCount"], 1)
self.assertEqual(recovery_diagnostics["baseRhsEvaluationCount"], 2)
self.assertEqual(
recovery_diagnostics[
"lastBuildFiniteDifferenceRhsEvaluationCount"
],
2,
)
self.assertIsNotNone(builder._remaining_factor)
self.assertIsNot(builder._original_factor, builder._remaining_factor)
def test_exact_column_subset_retry_matches_scipy_active_columns(self) -> None:
offset = np.asarray((1.0e8, -3.0e8, 2.0e8))
slopes = np.asarray((1.0, 2.0, -3.0))
state = np.asarray((1.0, -2.0, 0.5))
evaluated_states: list[np.ndarray] = []
def evaluator(time: float, evaluation_state: np.ndarray) -> np.ndarray:
del time
evaluated_states.append(evaluation_state.copy())
return offset + slopes * evaluation_state
builder = SparseSecantJacobian(
evaluator,
csc_matrix(np.eye(3, dtype=bool)),
atol=1.0e-8,
exact_columns={1: np.asarray((0.0, slopes[1], 0.0))},
max_consecutive_reuses=0,
)
actual = builder(0.0, state).toarray()
active = np.asarray((0, 2))
active_state = state[active]
active_offset = offset[active]
active_slopes = slopes[active]
def active_rhs(time: float, states: np.ndarray) -> np.ndarray:
del time
states_array = np.asarray(states, dtype=float)
if states_array.ndim == 1:
return active_offset + active_slopes * states_array
return active_offset[:, None] + active_slopes[:, None] * states_array
active_structure = csc_matrix(np.eye(2, dtype=bool))
active_groups = group_columns(active_structure, order=0)
expected, _factor = num_jac(
active_rhs,
0.0,
active_state,
active_rhs(0.0, active_state),
np.full(2, 1.0e-8),
None,
(active_structure, active_groups),
)
np.testing.assert_array_equal(
actual[np.ix_(active, active)],
expected.toarray(),
)
diagnostics = builder.diagnostics()
self.assertGreater(
diagnostics["lastBuildFiniteDifferenceRhsEvaluationCount"],
diagnostics["remainingColorGroupCount"],
)
for perturbed_state in evaluated_states[1:]:
self.assertEqual(perturbed_state[1], state[1])
def test_audit_step_respects_tiny_state_absolute_tolerance(self) -> None:
evaluator = _SwitchableLinearRhs(np.eye(2))
builder = SparseSecantJacobian(
evaluator,
csr_matrix(np.ones((2, 2), dtype=bool)),
atol=np.asarray((1.0e-12, 1.0e-8)),
)
state = np.zeros(2)
step = builder._audit_step(state)
self.assertGreater(abs(step[0]), 0.0)
self.assertLessEqual(abs(step[0]), 1.0e-12)
self.assertGreater(abs(step[1]), 0.0)
self.assertLessEqual(abs(step[1]), 1.0e-8)
def test_optimized_finite_difference_skips_secant_observation_work(self) -> None:
matrix = np.asarray(((2.0, -1.0), (0.5, 4.0)))
evaluator = _SwitchableLinearRhs(matrix)
builder = SparseSecantJacobian(
evaluator,
csr_matrix(np.ones((2, 2), dtype=bool)),
atol=1.0e-8,
max_consecutive_reuses=0,
)
state = np.asarray((1.0, -2.0))
derivative = evaluator(0.0, state)
builder.observe(0.0, state, derivative)
jacobian = builder(0.0, state)
np.testing.assert_allclose(jacobian.toarray(), matrix, rtol=1.0e-7)
diagnostics = builder.diagnostics()
self.assertEqual(diagnostics["mode"], "optimizedSparseFiniteDifference")
self.assertEqual(diagnostics["fullBuildCount"], 1)
self.assertEqual(diagnostics["secantReuseCount"], 0)
self.assertEqual(diagnostics["baseRhsEvaluationCount"], 1)
self.assertEqual(diagnostics["secantUpdateCount"], 0)
self.assertEqual(diagnostics["coloringSeed"], 0)
def test_seed_zero_callable_matches_scipy_num_jac(self) -> None:
def nonlinear_rhs(time: float, state: np.ndarray) -> np.ndarray:
del time
return np.asarray(
(
state[0] * state[0] + 3.0 * state[1],
np.sin(state[0]) - state[1],
)
)
state = np.asarray((2.0, 1.0))
atol = np.full(2, 1.0e-8)
structure = csc_matrix(np.ones((2, 2), dtype=bool))
groups = group_columns(structure, order=0)
base_rhs = nonlinear_rhs(0.0, state)
def vectorized_rhs(time: float, states: np.ndarray) -> np.ndarray:
states_array = np.asarray(states, dtype=float)
if states_array.ndim == 1:
return nonlinear_rhs(time, states_array)
return np.column_stack(
[
nonlinear_rhs(time, states_array[:, column])
for column in range(states_array.shape[1])
]
)
expected, _factor = num_jac(
vectorized_rhs,
0.0,
state,
base_rhs,
atol,
None,
(structure, groups),
)
builder = SparseSecantJacobian(
nonlinear_rhs,
structure,
atol=atol,
max_consecutive_reuses=0,
)
actual = builder(0.0, state)
np.testing.assert_array_equal(actual.toarray(), expected.toarray())
diagnostics = builder.diagnostics()
self.assertEqual(diagnostics["coloringSeed"], 0)
self.assertEqual(diagnostics["baseRhsEvaluationCount"], 1)
def test_default_coloring_stays_seed_zero_when_another_seed_is_smaller(self) -> None:
structure = csc_matrix(
np.asarray(
(
(1, 1, 0, 0, 0),
(0, 1, 0, 0, 0),
(0, 0, 1, 0, 0),
(0, 0, 0, 1, 1),
(0, 1, 0, 0, 1),
),
dtype=bool,
)
)
seed_zero_count = int(group_columns(structure, order=0).max()) + 1
seed_54_count = int(group_columns(structure, order=54).max()) + 1
self.assertEqual(seed_zero_count, 3)
self.assertEqual(seed_54_count, 2)
builder = SparseSecantJacobian(
_SwitchableLinearRhs(structure.toarray().astype(float)),
structure,
atol=1.0e-8,
max_consecutive_reuses=0,
)
diagnostics = builder.diagnostics()
self.assertEqual(diagnostics["coloringSeed"], 0)
self.assertEqual(diagnostics["colorGroupCount"], 3)
self.assertEqual(diagnostics["defaultColorGroupCount"], 3)
def test_exact_rows_do_not_reorder_seed_zero_perturbation_batches(self) -> None:
structure = csc_matrix(np.asarray(((1, 1), (1, 0)), dtype=bool))
builder = SparseSecantJacobian(
_SwitchableLinearRhs(np.asarray(((1.0, 1.0), (1.0, 0.0)))),
structure,
atol=1.0e-8,
exact_rows={0: {0: 1.0, 1: 1.0}},
max_consecutive_reuses=0,
)
diagnostics = builder.diagnostics()
self.assertEqual(diagnostics["coloringSeed"], 0)
self.assertEqual(diagnostics["colorGroupCount"], 2)
def test_rejects_more_than_one_consecutive_secant_reuse(self) -> None:
with self.assertRaisesRegex(
ValueError,
"max_consecutive_reuses must be either 0 or 1",
):
SparseSecantJacobian(
_SwitchableLinearRhs(np.eye(2)),
csr_matrix(np.eye(2, dtype=bool)),
atol=1.0e-8,
max_consecutive_reuses=2,
)
def test_uninformative_zero_jv_audit_forces_full_refresh(self) -> None:
evaluator = _SwitchableLinearRhs(np.zeros((2, 2)))
builder = SparseSecantJacobian(
evaluator,
csr_matrix(np.ones((2, 2), dtype=bool)),
atol=1.0e-8,
)
state = np.asarray((1.0, 1.0))
builder(0.0, state)
builder.observe(0.1, state, np.zeros(2))
builder.observe(0.1, state + 1.0, np.zeros(2))
refreshed = builder(0.1, state + 1.0)
np.testing.assert_array_equal(refreshed.toarray(), np.zeros((2, 2)))
diagnostics = builder.diagnostics()
self.assertEqual(diagnostics["secantReuseCount"], 0)
self.assertEqual(diagnostics["auditFailureCount"], 1)
self.assertEqual(diagnostics["fullBuildCount"], 2)
if __name__ == "__main__":
unittest.main()
-634
View File
@@ -1,634 +0,0 @@
from __future__ import annotations
import os
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 (
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE,
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,
executor_v2: 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()
with patch.dict(
os.environ,
{
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE: (
"1" if executor_v2 else "0"
)
},
):
solver = PressureFlowSolver(network)
solver.solve()
return solver, pipe
class StreamPressureBlockSolverTests(unittest.TestCase):
def test_compiled_v2_secondary_matches_v1_and_skips_seed_sets(self) -> None:
compiled_solver, _compiled_pipe = _pnl0002_solver(executor_v2=True)
v1_solver, _v1_pipe = _pnl0002_solver()
compiled = StreamPressureBlockSolver(compiled_solver, ("pipe",))
v1 = StreamPressureBlockSolver(v1_solver, ("pipe",))
compiled.solve(scale_context=compiled_solver.scale_context())
v1.solve(scale_context=v1_solver.scale_context())
with patch.object(
compiled,
"_seed_selected_blocks",
wraps=compiled._seed_selected_blocks,
) as legacy_seed, patch.object(
compiled_solver,
"_evaluate_explicit_flow_stage",
wraps=compiled_solver._evaluate_explicit_flow_stage,
) as allocating_stage:
compiled_result = compiled.solve(
scale_context=compiled_solver.scale_context()
)
v1.solve(scale_context=v1_solver.scale_context())
legacy_seed.assert_not_called()
allocating_stage.assert_not_called()
self.assertEqual(
tuple(unknown.read() for unknown in compiled_solver.unknowns),
tuple(unknown.read() for unknown in v1_solver.unknowns),
)
self.assertIs(
compiled_result.diagnostics[0],
compiled._causal_cached_fast_diagnostics,
)
execution = compiled.causal_execution_diagnostics()
self.assertEqual(execution["executorV2FastSolveCount"], 1)
self.assertEqual(execution["coordinateKernelFastSolveCount"], 1)
self.assertEqual(
execution["compiledAssignmentCount"],
len(compiled._selected_unknowns),
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 0)
def test_compiled_v2_secondary_failure_restores_before_legacy_seed(
self,
) -> None:
solver, _pipe = _pnl0002_solver(executor_v2=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
block_solver.solve(scale_context=solver.scale_context())
expected = tuple(
unknown.read() for unknown in block_solver._selected_flow_unknowns
)
original_seed = block_solver._seed_selected_blocks
def one_nonfinite_assignment():
block_solver._selected_flow_unknowns[0].state.m_flow = float("nan")
return "nonFiniteCausalSecondaryFlowAssignment"
def checked_legacy_seed(entry_values):
self.assertEqual(
tuple(
unknown.read()
for unknown in block_solver._selected_flow_unknowns
),
expected,
)
return original_seed(entry_values)
with patch.object(
block_solver,
"_execute_compiled_secondary_flow_plan",
side_effect=one_nonfinite_assignment,
), patch.object(
block_solver,
"_seed_selected_blocks",
side_effect=checked_legacy_seed,
):
result = block_solver.solve(scale_context=solver.scale_context())
self.assertFalse(result.used_global_fallback)
execution = block_solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"nonFiniteCausalSecondaryFlowAssignment",
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_compiled_v2_secondary_base_error_restores_transaction(self) -> None:
class ForcedFatalError(BaseException):
pass
solver, _pipe = _pnl0002_solver(executor_v2=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
block_solver.solve(scale_context=solver.scale_context())
expected = tuple(
unknown.read() for unknown in block_solver._selected_flow_unknowns
)
def broken_execution() -> None:
block_solver._selected_flow_unknowns[0].write(123_456.0)
raise ForcedFatalError("forced v2 interruption")
with patch.object(
block_solver,
"_execute_compiled_secondary_flow_plan",
side_effect=broken_execution,
):
with self.assertRaises(ForcedFatalError):
block_solver.solve(scale_context=solver.scale_context())
self.assertEqual(
tuple(
unknown.read()
for unknown in block_solver._selected_flow_unknowns
),
expected,
)
def test_compiled_v2_secondary_assignment_count_drift_falls_back(self) -> None:
solver, _pipe = _pnl0002_solver(executor_v2=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
block_solver.solve(scale_context=solver.scale_context())
with patch.object(
block_solver,
"_execute_compiled_secondary_flow_plan",
return_value="causalSecondaryFlowAssignmentCountMismatch",
):
result = block_solver.solve(scale_context=solver.scale_context())
self.assertFalse(result.used_global_fallback)
execution = block_solver.causal_execution_diagnostics()
self.assertEqual(
execution["disabledReason"],
"causalSecondaryFlowAssignmentCountMismatch",
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1)
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()
@@ -1,244 +0,0 @@
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"]
class _ReferenceAwarePassThrough(_PassThrough):
def __init__(
self,
name: str,
update_log: list[str],
reference_log: list[str],
) -> None:
super().__init__(name, update_log)
self.reference_log = reference_log
self.flow_temperature_references: dict[str, float] = {}
def update_flow_temperature_references(
self,
connected_h: Mapping[str, float],
) -> None:
self.reference_log.append(self.name)
self.flow_temperature_references = dict(connected_h)
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_non_dynamic_flow_reference_hook_does_not_repeat_stream_update(
self,
) -> None:
update_log: list[str] = []
reference_log: list[str] = []
left = _CountingDynamicAnchor(
"left_anchor",
enthalpy=100.0,
temperature_reference_h=1_100.0,
)
middle = _ReferenceAwarePassThrough(
"middle",
update_log,
reference_log,
)
right = _CountingDynamicAnchor(
"right_anchor",
enthalpy=400.0,
temperature_reference_h=1_400.0,
)
network = SimulationNetwork("flow-temperature-reference")
for component in (left, middle, right):
network.add_component(component)
network.connect("left_anchor", "port", "middle", "left")
network.connect("middle", "right", "right_anchor", "port")
resolver = StreamResolver(network)
diagnostics, _connected = resolver.solve()
stream_updates_before = tuple(update_log)
outflows_before = (middle.left.h_outflow, middle.right.h_outflow)
resolver.refresh_flow_temperature_references()
self.assertEqual(
stream_updates_before,
("middle",) * diagnostics.iterations,
)
self.assertEqual(tuple(update_log), stream_updates_before)
self.assertEqual(reference_log, ["middle"])
self.assertEqual(
middle.flow_temperature_references,
{"left": 1_100.0, "right": 1_400.0},
)
self.assertEqual(
(middle.left.h_outflow, middle.right.h_outflow),
outflows_before,
)
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()
-289
View File
@@ -1,289 +0,0 @@
from pathlib import Path
import os
import unittest
from unittest.mock import patch
import numpy as np
from scipy.optimize._numdiff import group_columns
from app.main import compile_system_xml_network
from app.simulation.solvers.jacobian import (
ExactColumnsUnavailable,
SparseSecantJacobian,
)
import app.simulation.solvers.tangent as tangent_module
from app.simulation.solvers.tangent import (
compile_supported_piston_tangent_provider,
)
from app.simulation.systems.generic import GenericFluidSystem
from app.simulation.systems.network import Endpoint
from app.system_xml import validate_system_xml_document
TARGET_XML = Path("tests/data/test-mql-8.xml")
EXPECTED_COLUMNS = tuple(range(104, 120))
EXPECTED_BRANCH_NAMES = (
(
"amesim_mecmas21_1",
"amesim_pnrp17_1",
"amesim_pnch012_15",
"amesim_pnl0001_13",
"amesim_lstp00a_1",
),
(
"amesim_mecmas21_2",
"amesim_pnrp17_2",
"amesim_pnch012_14",
"amesim_pnl0001_14",
"amesim_lstp00a_2",
),
(
"amesim_mecmas21_3",
"amesim_pnrp17_3",
"amesim_pnch012_13",
"amesim_pnl0001_15",
"amesim_lstp00a_3",
),
(
"amesim_mecmas21_4",
"amesim_pnrp17_4",
"amesim_pnch012_12",
"amesim_pnl0001_16",
"amesim_lstp00a_4",
),
(
"amesim_mecmas21_5",
"amesim_pnrp17_5",
"amesim_pnch012_11",
"amesim_pnl0001_17",
"amesim_lstp00a_5",
),
(
"amesim_mecmas21_6",
"amesim_pnrp17_6",
"amesim_pnch012_10",
"amesim_pnl0001_18",
"amesim_lstp00a_6",
),
(
"amesim_mecmas21_7",
"amesim_pnrp17_7",
"amesim_pnch012_9",
"amesim_pnl0001_19",
"amesim_lstp00a_7",
),
(
"amesim_mecmas21_8",
"amesim_pnrp17_8",
"amesim_pnch012_8",
"amesim_pnl0001_20",
"amesim_lstp00a_8",
),
)
class SupportedPistonTangentCompilerTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
report = validate_system_xml_document(TARGET_XML.read_bytes())
assert report.valid and report.document is not None
with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}):
cls.system = GenericFluidSystem(
compile_system_xml_network(report.document)
)
cls.initial_state = np.asarray(
cls.system.consistent_initial_state_vector(0.0),
dtype=float,
)
def setUp(self) -> None:
self.system.mechanical_state_reducer.reset_constraint_modes()
self.system.apply_state_vector(self.initial_state.tolist())
def _closed_primal(
self,
state: np.ndarray,
) -> tuple[dict[str, dict[str, float]], np.ndarray]:
self.system.apply_state_vector(state.tolist())
connected_h = self.system._close_current_state(0.0)
derivative = np.asarray(
self.system._state_derivatives(connected_h),
dtype=float,
)
return connected_h, derivative
def test_discovers_all_branches_without_legacy_names_or_offsets(self) -> None:
with patch.object(
tangent_module,
"_TARGET_BRANCH_NAMES",
(("not", "a", "real", "branch", "name"),),
):
compilation = compile_supported_piston_tangent_provider(
self.system
)
self.assertTrue(compilation.eligible, compilation.reason)
self.assertEqual(compilation.columns, EXPECTED_COLUMNS)
self.assertEqual(compilation.reached_assignment_count, 84)
provider = compilation.provider
assert provider is not None
self.assertEqual(len(provider.branches), 8)
self.assertEqual(
tuple(
(
branch.mass.name,
branch.piston.name,
branch.chamber.name,
branch.pipe.name,
branch.contact.name,
)
for branch in provider.branches
),
EXPECTED_BRANCH_NAMES,
)
self.assertEqual(
{branch.chamber_connection_port for branch in provider.branches},
{"port_2"},
)
def test_exact_columns_reduce_seed_zero_pattern_to_36_colors(self) -> None:
compilation = compile_supported_piston_tangent_provider(self.system)
self.assertTrue(compilation.eligible, compilation.reason)
provider = compilation.provider
assert provider is not None
pattern = self.system.jacobian_sparsity().tocsc().astype(bool)
columns_only = pattern.tolil(copy=True)
columns_only[:, list(compilation.columns)] = False
columns_only = columns_only.tocsc()
columns_only.eliminate_zeros()
active_columns = np.flatnonzero(
np.asarray(columns_only.getnnz(axis=0)).reshape(-1) > 0
)
groups = group_columns(
columns_only[:, active_columns],
order=0,
)
self.assertEqual(pattern.nnz, 3280)
self.assertEqual(columns_only.nnz, 2448)
self.assertEqual(len(active_columns), 116)
self.assertEqual(int(groups.max(initial=-1)) + 1, 36)
jacobian = SparseSecantJacobian(
lambda _time, state: np.zeros_like(state),
pattern,
1.0e-8,
exact_rows=self.system._exact_ode_jacobian_rows(),
exact_columns=(compilation.columns, provider),
max_consecutive_reuses=0,
)
diagnostics = jacobian.diagnostics()
self.assertEqual(diagnostics["originalColorGroupCount"], 52)
self.assertEqual(diagnostics["remainingColorGroupCount"], 36)
self.assertEqual(diagnostics["exactColumnCount"], 16)
self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 116)
self.assertEqual(
jacobian._remaining_finite_difference_sparsity.nnz,
2160,
)
def test_partial_unsupported_branch_fails_with_stable_reason(self) -> None:
piston_endpoint = Endpoint("amesim_pnrp17_1", "port_5")
connection_index, connection = next(
(index, connection)
for index, connection in enumerate(self.system.network.connections)
if piston_endpoint in connection.endpoints
)
del self.system.network.connections[connection_index]
try:
compilation = compile_supported_piston_tangent_provider(
self.system
)
finally:
self.system.network.connections.insert(
connection_index,
connection,
)
self.assertFalse(compilation.eligible)
self.assertEqual(
compilation.reason,
"unsupportedPistonBranchTopology:contact",
)
def test_initial_contact_boundary_requests_full_numeric_columns(self) -> None:
compilation = compile_supported_piston_tangent_provider(self.system)
self.assertTrue(compilation.eligible, compilation.reason)
provider = compilation.provider
assert provider is not None
connected_h, _derivative = self._closed_primal(
self.initial_state.copy()
)
provider.request_primal_capture()
provider.record_primal(
0.0,
self.initial_state,
connected_h,
)
with self.assertRaises(ExactColumnsUnavailable) as captured:
provider(
0.0,
self.initial_state.copy(),
compilation.columns,
)
self.assertEqual(
captured.exception.reason,
"contactMode:contact_mode_boundary",
)
def test_smooth_sixteen_columns_match_centered_full_rhs(self) -> None:
compilation = compile_supported_piston_tangent_provider(self.system)
self.assertTrue(compilation.eligible, compilation.reason)
provider = compilation.provider
assert provider is not None
state = self.initial_state.copy()
for branch in provider.branches:
chamber_offset, chamber_size = provider.state_offsets[
branch.chamber.name
]
self.assertEqual(chamber_size, 2)
state[chamber_offset] *= 1.01
state[branch.position_index] -= 1.0e-3
connected_h, _base = self._closed_primal(state)
provider.request_primal_capture()
provider.record_primal(0.0, state, connected_h)
exact = provider(0.0, state.copy(), compilation.columns)
numerical = np.empty_like(exact)
for local_column, state_index in enumerate(compilation.columns):
step = 1.0e-7 * max(abs(state[state_index]), 1.0)
lower = state.copy()
upper = state.copy()
lower[state_index] -= step
upper[state_index] += step
lower_rhs = np.asarray(
self.system.rhs(0.0, lower.tolist()),
dtype=float,
)
upper_rhs = np.asarray(
self.system.rhs(0.0, upper.tolist()),
dtype=float,
)
numerical[:, local_column] = (
upper_rhs - lower_rhs
) / (2.0 * step)
np.testing.assert_allclose(
exact,
numerical,
rtol=3.0e-6,
atol=2.0e-5,
)
if __name__ == "__main__":
unittest.main()
-387
View File
@@ -1,387 +0,0 @@
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import unittest
from app.main import compile_system_xml_network
from app.simulation.benchmark_regression import (
DEFAULT_MANIFEST_PATH,
evaluate_regression_golden,
load_regression_golden,
load_regression_manifest,
run_regression_suite,
source_simulation_config,
)
from app.simulation.systems.generic import GenericFluidSystem
from app.system_xml import validate_system_xml_document
LONG_RUN_ENVIRONMENT = "RUN_TEST_MQL_8_LONG_REGRESSION"
class TestMql8StaticRegressionTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH)
cls.source_path = Path(cls.manifest["_sourcePath"])
cls.source_payload = cls.source_path.read_bytes()
cls.validation = validate_system_xml_document(cls.source_payload)
def test_authoritative_xml_hash_and_simulation_settings_are_fixed(self) -> None:
source = self.manifest["source"]
self.assertEqual(
hashlib.sha256(self.source_payload).hexdigest(), source["sha256"]
)
self.assertEqual(len(self.source_payload), source["bytes"])
self.assertEqual(
source_simulation_config(self.source_payload), source["simulation"]
)
self.assertEqual(self.source_path.name, "test-mql-8.xml")
companion = source["companionProject"]
companion_path = Path(self.manifest["_companionPath"])
companion_payload = companion_path.read_bytes()
companion_document = json.loads(companion_payload)
self.assertEqual(companion_path.name, "test-mql-8.json")
self.assertEqual(
hashlib.sha256(companion_payload).hexdigest(), companion["sha256"]
)
self.assertEqual(len(companion_payload), companion["bytes"])
self.assertFalse(companion["executionInput"])
self.assertEqual(
companion_document["simulation"],
{
"t_start": source["simulation"]["tStart"],
"t_stop": source["simulation"]["tStop"],
"step": source["simulation"]["sampleStep"],
"max_step": source["simulation"]["maxStep"],
"method": source["simulation"]["method"],
},
)
def test_authoritative_xml_validates_and_matches_structure_snapshot(self) -> None:
self.assertTrue(self.validation.valid, self.validation.as_dict())
assert self.validation.document is not None
network = compile_system_xml_network(self.validation.document)
system = GenericFluidSystem(network)
expected = self.manifest["structure"]
pressure_flow = network.pressure_flow_structure_dict()
causal_execution = system.pressure_flow_solver.causal_execution_diagnostics()
jacobian = system.jacobian_sparsity_diagnostics()
self.assertEqual(len(network.components), expected["componentCount"])
self.assertEqual(len(network.connections), expected["connectionCount"])
self.assertEqual(
len(network.dynamic_components()), expected["dynamicComponentCount"]
)
self.assertEqual(
sum(component.state_size for component in network.dynamic_components()),
expected["stateCount"],
)
self.assertEqual(
len(network.result_variable_metadata()), expected["resultVariableCount"]
)
self.assertEqual(
pressure_flow["unknownCount"], expected["pressureFlowUnknownCount"]
)
self.assertEqual(
pressure_flow["equationCount"], expected["pressureFlowEquationCount"]
)
self.assertEqual(pressure_flow["isSquare"], expected["pressureFlowIsSquare"])
for key in (
"logicalEffortCoordinateCount",
"eliminatedEffortAliasCount",
"canonicalCoordinateCount",
"compatibilityScatterCount",
):
self.assertEqual(causal_execution[key], expected[key])
self.assertEqual(
jacobian["nonzeroCount"], expected["jacobianNonzeroCount"]
)
self.assertEqual(
jacobian["colorGroupCount"], expected["jacobianColorGroupCount"]
)
self.assertEqual(
system.mechanical_state_reducer.has_state_events,
expected["hasMechanicalStateEvents"],
)
def test_contact_aware_mechanical_tolerance_plan_is_proof_gated(self) -> None:
assert self.validation.document is not None
system = GenericFluidSystem(
compile_system_xml_network(self.validation.document)
)
reducer = system.mechanical_state_reducer
plan = reducer.absolute_tolerance_plan(
1.0e-8,
mode="contact-aware-v1",
)
diagnostics = plan.as_dict()
self.assertEqual(len(plan.values), len(system.initial_state_vector()))
self.assertEqual(diagnostics["groupCount"], 10)
self.assertEqual(diagnostics["eligibleGroupCount"], 8)
self.assertEqual(diagnostics["relaxedVelocityStateCount"], 8)
self.assertEqual(diagnostics["relaxedPositionStateCount"], 0)
by_component = {
group.components[0]: group
for group in plan.groups
}
for index in range(1, 9):
group = by_component[f"amesim_mecmas21_{index}"]
self.assertTrue(group.eligible)
self.assertEqual(group.reason, "eligibleFlexibleContact")
self.assertAlmostEqual(group.velocity_atol, 1.0e-10)
self.assertEqual(group.position_atol, 1.0e-12)
self.assertAlmostEqual(
group.minimum_damping_strength_ratio,
10.0,
)
self.assertAlmostEqual(
group.minimum_force_limited_velocity_atol,
1.0e-10,
)
self.assertEqual(
group.contacts,
(f"amesim_lstp00a_{index}",),
)
for index in (9, 10):
group = by_component[f"amesim_mecmas21_{index}"]
self.assertFalse(group.eligible)
self.assertEqual(group.reason, "discreteEndstop")
self.assertEqual(group.velocity_atol, 1.0e-12)
self.assertEqual(group.position_atol, 1.0e-12)
legacy = reducer.absolute_tolerance_plan(1.0e-8, mode="legacy")
self.assertTrue(
all(
group.velocity_atol == 1.0e-12
and group.position_atol == 1.0e-12
for group in legacy.groups
)
)
def test_signal_event_schedule_is_fixed_for_every_horizon(self) -> None:
assert self.validation.document is not None
system = GenericFluidSystem(
compile_system_xml_network(self.validation.document)
)
for case_id in self.manifest["sequence"]:
variant = self.manifest["variants"][case_id]
actual = system.signal_resolver.event_times(
0.0, float(variant["stopTime"])
)
self.assertEqual(
actual,
tuple(variant["expectedSignalEventTimes"]),
case_id,
)
def test_historical_v2_golden_replays_its_source_report(self) -> None:
repository_root = Path(self.manifest["_repositoryRoot"])
golden_path = (
repository_root
/ "tests/baselines/simulation/test_mql_8/goldens/production-0.2s-v1.json"
)
golden = load_regression_golden(
golden_path,
expected_sha256="99fa7b3631a89f59f86551847175f3701a1567d8d0bd10d87545734f17515d72",
repository_root=repository_root,
)
source_report = golden["provenance"]["sourceReport"]
report = json.loads(
(repository_root / source_report["path"]).read_text(encoding="utf-8")
)
case = next(item for item in report["cases"] if item["caseId"] == "0.2s")
audit = evaluate_regression_golden(case["worker"]["summary"], golden)
self.assertNotEqual(
golden["sourceXmlSha256"],
self.manifest["source"]["sha256"],
)
self.assertTrue(audit["passed"], audit)
self.assertEqual(audit["comparedValueCount"], 402)
self.assertEqual(audit["maxAbsoluteError"], 0.0)
self.assertEqual(audit["maxToleranceRatio"], 0.0)
def test_approved_production_golden_replays_its_source_report(self) -> None:
repository_root = Path(self.manifest["_repositoryRoot"])
reference = self.manifest["variants"]["0.2s"]["goldens"]["production"]
golden = load_regression_golden(
repository_root / reference["path"],
expected_sha256=reference["sha256"],
repository_root=repository_root,
)
source_report = golden["provenance"]["sourceReport"]
report = json.loads(
(repository_root / source_report["path"]).read_text(encoding="utf-8")
)
case = next(item for item in report["cases"] if item["caseId"] == "0.2s")
audit = evaluate_regression_golden(case["worker"]["summary"], golden)
self.assertTrue(audit["passed"], audit)
self.assertEqual(audit["comparedValueCount"], 426)
self.assertEqual(audit["maxAbsoluteError"], 0.0)
self.assertEqual(audit["maxToleranceRatio"], 0.0)
def test_historical_v2_extension_decision_remains_auditable(self) -> None:
repository_root = Path(self.manifest["_repositoryRoot"])
runs = repository_root / "tests/baselines/simulation/test_mql_8/runs"
decision = json.loads(
(runs / "2026-08-17-production-v2-extension-decision.json").read_text(
encoding="utf-8"
)
)
report_path = runs / decision["sourceReport"]
report_payload = report_path.read_bytes()
report = json.loads(report_payload)
source_case = next(
item for item in report["cases"] if item["caseId"] == "0.2s"
)
first_decision = decision["decisions"][0]
self.assertEqual(
decision["sourceXmlSha256"],
"170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
)
self.assertNotEqual(
decision["sourceXmlSha256"], self.manifest["source"]["sha256"]
)
self.assertEqual(
hashlib.sha256(report_payload).hexdigest(),
decision["sourceReportSha256"],
)
self.assertTrue(source_case["acceptance"]["passed"])
self.assertEqual(
decision["observedCase"]["workerWallSeconds"],
source_case["worker"]["wallSeconds"],
)
expected_prediction = (
source_case["worker"]["wallSeconds"]
* self.manifest["variants"]["1s"]["stopTime"]
/ source_case["stopTime"]
* self.manifest["execution"]["predictionSafetyFactor"]
)
self.assertAlmostEqual(
first_decision["predictedWallSeconds"], expected_prediction
)
self.assertGreater(
first_decision["predictedWallSeconds"],
first_decision["softTimeoutSeconds"],
)
self.assertEqual(first_decision["outcome"], "deferred")
self.assertTrue(decision["simulationWasNotStartedForDeferredCases"])
def test_extension_decision_is_bound_to_the_current_report_and_budget(self) -> None:
repository_root = Path(self.manifest["_repositoryRoot"])
evidence = self.manifest["acceptanceEvidence"]
decision_reference = evidence["extensionDecision"]
decision_path = repository_root / decision_reference["path"]
decision_payload = decision_path.read_bytes()
decision = json.loads(decision_payload)
report_reference = evidence["production0.2ApprovedReplay"]
report_path = repository_root / report_reference["path"]
report_payload = report_path.read_bytes()
report = json.loads(report_payload)
source_case = next(
item for item in report["cases"] if item["caseId"] == "0.2s"
)
first_decision = decision["decisions"][0]
self.assertEqual(
decision["sourceXmlSha256"], self.manifest["source"]["sha256"]
)
self.assertEqual(decision["sourceReport"], report_path.name)
self.assertEqual(len(report_payload), report_reference["bytes"])
self.assertEqual(
hashlib.sha256(report_payload).hexdigest(), report_reference["sha256"]
)
self.assertEqual(len(decision_payload), decision_reference["bytes"])
self.assertEqual(
hashlib.sha256(decision_payload).hexdigest(),
decision_reference["sha256"],
)
self.assertEqual(
hashlib.sha256(report_payload).hexdigest(),
decision["sourceReportSha256"],
)
self.assertTrue(source_case["acceptance"]["passed"])
self.assertEqual(
decision["observedCase"]["workerWallSeconds"],
source_case["worker"]["wallSeconds"],
)
expected_prediction = (
source_case["worker"]["wallSeconds"]
* self.manifest["variants"]["1s"]["stopTime"]
/ source_case["stopTime"]
* self.manifest["execution"]["predictionSafetyFactor"]
)
self.assertAlmostEqual(
first_decision["predictedWallSeconds"], expected_prediction
)
self.assertLessEqual(
first_decision["predictedWallSeconds"],
first_decision["softTimeoutSeconds"],
)
self.assertEqual(first_decision["outcome"], "eligible")
self.assertEqual(first_decision["reason"], "predictedWallWithinSoftBudget")
self.assertEqual(
[item["outcome"] for item in decision["decisions"]],
["eligible", "notEvaluated", "notEvaluated"],
)
self.assertTrue(decision["extensionWasNotStarted"])
self.assertEqual(
decision["scopeReason"],
"OPT-00 closes at the approved production 0.2 s gate; longer progressive horizons remain separately staged work.",
)
def test_periodic_main_lane_exercises_the_approved_production_golden(self) -> None:
workflow = (
Path(self.manifest["_repositoryRoot"])
/ ".github/workflows/solver-regression.yml"
).read_text(encoding="utf-8")
self.assertIn("default: production", workflow)
self.assertIn("inputs.lane || 'production'", workflow)
@unittest.skipUnless(
os.getenv(LONG_RUN_ENVIRONMENT, "").strip().lower() in {"1", "true", "yes"},
f"Set {LONG_RUN_ENVIRONMENT}=1 to run bounded 0.01/0.2/1/5/10 s integration.",
)
class TestMql8ProgressiveLongRegressionTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH)
def test_all_horizons_complete_or_stop_at_the_first_bounded_failure(self) -> None:
lane = os.getenv("TEST_MQL_8_REGRESSION_LANE", "production")
report = run_regression_suite(DEFAULT_MANIFEST_PATH, lane=lane)
outcomes = [case["outcome"] for case in report["cases"]]
self.assertEqual(outcomes[0], "completed", report["cases"][0])
if "deferred" in outcomes:
first_deferred = outcomes.index("deferred")
self.assertTrue(
all(outcome == "deferred" for outcome in outcomes[first_deferred:])
)
predecessor = report["cases"][first_deferred - 1]
self.fail(
"Progressive run stopped within its configured safety budget; "
f"optimize before resuming. Predecessor: {predecessor}"
)
self.assertEqual(
outcomes,
["completed"] * len(self.manifest["sequence"]),
report["cases"],
)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -20,8 +20,8 @@ from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
AME_PATH = REPOSITORY_ROOT / "AmesimModels" / "test_mql.ame"
JSON_PATH = REPOSITORY_ROOT / "tests/data/test-mql-8.json"
XML_PATH = REPOSITORY_ROOT / "tests/data/test-mql-8.xml"
JSON_PATH = REPOSITORY_ROOT / "tests/baselines/simulation/test_mql_8/sources/test-mql-8.json"
XML_PATH = REPOSITORY_ROOT / "tests/baselines/simulation/test_mql_8/sources/test-mql-8.xml"
MANIFEST_PATH = (
REPOSITORY_ROOT
/ "tests/baselines/simulation/test_mql_8/manifest.json"
-50
View File
@@ -1,50 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.examples.test_mql.baseline import run_test_mql_baseline_passthrough
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlBaselinePassthroughTests(unittest.TestCase):
def test_full_baseline_passthrough_satisfies_output_contract_with_zero_error(self) -> None:
run = run_test_mql_baseline_passthrough(TEST_MQL_AME)
self.assertEqual(run.sample_count, 1002)
self.assertEqual(run.signal_count, 858)
self.assertEqual(run.output_schema.signal_count, 858)
self.assertEqual(run.observation_catalog.binding_count, 114)
self.assertEqual(len(run.comparison.metrics), 858)
self.assertEqual(run.comparison.max_abs_error, 0.0)
self.assertEqual(run.comparison.max_rel_error, 0.0)
self.assertEqual(run.output.data_paths, run.output_schema.data_paths())
self.assertAlmostEqual(run.output.series("press@pn_c1_8")[-1], 4310310.457796034)
def test_selected_baseline_passthrough_preserves_requested_order(self) -> None:
data_paths = (
"vol1@pn_brp2_8",
"press@pn_c1_8",
"dm1@pneumatic_69",
)
run = run_test_mql_baseline_passthrough(TEST_MQL_AME, data_paths=data_paths)
self.assertEqual(run.signal_count, 3)
self.assertEqual(run.output.data_paths, data_paths)
self.assertEqual(tuple(metric.data_path for metric in run.comparison.metrics), data_paths)
self.assertEqual(run.comparison.max_abs_error, 0.0)
def test_baseline_passthrough_rejects_unobserved_data_path(self) -> None:
with self.assertRaises(KeyError):
run_test_mql_baseline_passthrough(
TEST_MQL_AME,
data_paths=("not_a_signal@not_an_owner",),
)
if __name__ == "__main__":
unittest.main()
-130
View File
@@ -1,130 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.examples.test_mql.solver import SolveIVPConfig
from app.simulation.examples.test_mql.system import TestMqlSystem
class TestMqlPneumaticBranchSimulationTests(unittest.TestCase):
def test_simulate_pneumatic_branch_returns_ode_solution(self) -> None:
system = TestMqlSystem()
solution = system.simulate_pneumatic_branch(
name="sample_branch",
upstream_volume_alias="pn_general_chamber",
orifice_alias="pn_orifice_18",
downstream_volume_alias="pn_c1_8",
config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6),
t_eval=[0.0, 5.0e-6, 1.0e-5],
)
self.assertTrue(solution.success)
self.assertEqual(len(solution.t), 3)
self.assertEqual(len(solution.y), 4)
self.assertEqual([len(row) for row in solution.y], [3, 3, 3, 3])
def test_evaluate_pneumatic_branch_solution_reports_branch_series(self) -> None:
system = TestMqlSystem()
solution = system.simulate_pneumatic_branch(
name="sample_branch",
upstream_volume_alias="pn_general_chamber",
orifice_alias="pn_orifice_18",
downstream_volume_alias="pn_c1_8",
config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6),
t_eval=[0.0, 5.0e-6, 1.0e-5],
)
series = system.evaluate_pneumatic_branch_solution(
solution,
name="sample_branch",
upstream_volume_alias="pn_general_chamber",
orifice_alias="pn_orifice_18",
downstream_volume_alias="pn_c1_8",
)
self.assertEqual(series["time"], [0.0, 5.0e-6, 1.0e-5])
self.assertIn("sample_branch.flow", series)
self.assertIn("pn_general_chamber.p", series)
self.assertIn("pn_c1_8.p", series)
self.assertEqual(len(series["sample_branch.flow"]), 3)
self.assertGreater(series["sample_branch.flow"][0], 0.0)
self.assertGreater(series["pn_general_chamber.p"][0], series["pn_general_chamber.p"][-1])
self.assertLess(series["pn_c1_8.p"][0], series["pn_c1_8.p"][-1])
self.assertAlmostEqual(
series["pn_general_chamber.m_flow"][0],
-series["sample_branch.flow"][0],
)
self.assertAlmostEqual(
series["pn_c1_8.m_flow"][0],
series["sample_branch.flow"][0],
)
def test_pneumatic_branch_spec_drives_closure_and_simulation(self) -> None:
system = TestMqlSystem()
spec = system.pneumatic_branch_spec(
name="sample_branch",
upstream_volume_alias="pn_general_chamber",
orifice_alias="pn_orifice_18",
downstream_volume_alias="pn_c1_8",
source="manual-test",
)
closure = system.pneumatic_branch_closure_from_spec(spec)
solution = system.simulate_pneumatic_branch_from_spec(
spec,
config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6),
t_eval=[0.0, 1.0e-5],
)
series = system.evaluate_pneumatic_branch_solution_from_spec(solution, spec)
self.assertIs(closure.components.spec, spec)
self.assertEqual(spec.source, "manual-test")
self.assertEqual(len(solution.t), 2)
self.assertIn("sample_branch.flow", series)
self.assertGreater(series["sample_branch.flow"][0], 0.0)
self.assertGreater(series["pn_general_chamber.p"][0], series["pn_general_chamber.p"][-1])
self.assertLess(series["pn_c1_8.p"][0], series["pn_c1_8.p"][-1])
def test_discover_pneumatic_branch_topology_reports_blocked_candidates(self) -> None:
system = TestMqlSystem()
discovery = system.discover_pneumatic_branch_topology()
self.assertEqual(discovery.branch_specs, ())
self.assertEqual(len(discovery.blocked_candidates), 48)
candidates = {
candidate.connection_alias: candidate
for candidate in discovery.blocked_candidates
}
chamber_candidate = candidates["pneumatic_69"]
self.assertEqual(chamber_candidate.line_submodel, "PNL0001")
self.assertEqual(chamber_candidate.source_component, "pnnode4_16")
self.assertEqual(chamber_candidate.target_component, "pn_c1_8")
self.assertFalse(chamber_candidate.source_is_typed_pneumatic)
self.assertTrue(chamber_candidate.target_is_typed_pneumatic)
self.assertIn("node/line", chamber_candidate.reason)
orifice_candidate = candidates["pneumatic_96"]
self.assertEqual(orifice_candidate.line_submodel, "PNL0001")
self.assertEqual(orifice_candidate.source_component, "pn_node3_8")
self.assertEqual(orifice_candidate.target_component, "pn_orifice_18")
self.assertFalse(orifice_candidate.source_is_typed_pneumatic)
self.assertTrue(orifice_candidate.target_is_typed_pneumatic)
self.assertIn("node/line", orifice_candidate.reason)
def test_simulate_pneumatic_branch_rejects_invalid_component_aliases(self) -> None:
system = TestMqlSystem()
with self.assertRaises(TypeError):
system.simulate_pneumatic_branch(
name="invalid_branch",
upstream_volume_alias="pn_brp2_8",
orifice_alias="pn_orifice_18",
downstream_volume_alias="pn_c1_8",
config=SolveIVPConfig(t_start=0.0, t_stop=0.0),
)
if __name__ == "__main__":
unittest.main()
-112
View File
@@ -1,112 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.reporting.test_mql_chamber_observations import (
build_test_mql_chamber_observation_catalog,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlChamberObservationCatalogTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
cls.catalog = build_test_mql_chamber_observation_catalog(cls.amesim_results)
def test_builds_expected_chamber_observation_bindings(self) -> None:
self.assertEqual(len(self.catalog.bindings), 12)
self.assertEqual(self.catalog.fixed_count, 4)
self.assertEqual(self.catalog.variable_count, 8)
fixed = self.catalog.by_alias("pn_general_chamber")
variable = self.catalog.by_alias("pn_c1_8")
self.assertEqual(fixed.submodel, "PNCH023")
self.assertFalse(fixed.is_variable)
self.assertEqual(fixed.pressure_path, "press@pn_general_chamber")
self.assertEqual(fixed.temperature_path, "temp@pn_general_chamber")
self.assertEqual(fixed.gas_mass_path, "mgas@pn_general_chamber")
self.assertEqual(fixed.pressure_duplicate_paths, ("press2@pn_general_chamber",))
self.assertEqual(fixed.temperature_duplicate_paths, ("temp2@pn_general_chamber",))
self.assertIsNone(fixed.volume_path)
self.assertEqual(variable.submodel, "PNCH012")
self.assertTrue(variable.is_variable)
self.assertEqual(variable.pressure_path, "press@pn_c1_8")
self.assertEqual(variable.temperature_path, "temp@pn_c1_8")
self.assertEqual(variable.gas_mass_path, "mgas1@pn_c1_8")
self.assertEqual(
variable.pressure_duplicate_paths,
("press3@pn_c1_8", "press2@pn_c1_8", "press4@pn_c1_8"),
)
self.assertEqual(
variable.temperature_duplicate_paths,
("temp3@pn_c1_8", "temp2@pn_c1_8", "temp4@pn_c1_8"),
)
self.assertEqual(variable.volume_path, "vol@pn_c1_8")
def test_fixed_chamber_observation_matches_amesim_samples(self) -> None:
observation = self.catalog.by_alias("pn_general_chamber").observation_at(
self.amesim_results,
-1,
)
self.assertAlmostEqual(observation.time, self.amesim_results.times[-1])
self.assertAlmostEqual(observation.pressure_pa, 4310273.332655074)
self.assertAlmostEqual(observation.temperature_k, 179.646447269399)
self.assertAlmostEqual(observation.gas_mass_g, 666.3851161956942)
self.assertIsNone(observation.volume_cm3)
def test_variable_chamber_observation_matches_amesim_samples(self) -> None:
observation = self.catalog.by_alias("pn_c1_8").observation_at(
self.amesim_results,
-1,
)
self.assertAlmostEqual(observation.pressure_pa, 4310310.457796034)
self.assertAlmostEqual(observation.temperature_k, 293.1143259012449)
self.assertAlmostEqual(observation.gas_mass_g, 353.6887889918552)
self.assertAlmostEqual(observation.volume_cm3, 49242.54636512914)
def test_initial_pressure_conventions_are_preserved(self) -> None:
fixed = self.catalog.by_alias("pn_general_chamber").observation_at(
self.amesim_results,
0,
)
variable = self.catalog.by_alias("pn_c1_8").observation_at(
self.amesim_results,
0,
)
self.assertAlmostEqual(fixed.pressure_pa, 15198699.999999998)
self.assertAlmostEqual(variable.pressure_pa, -1299.99999999999)
self.assertAlmostEqual(fixed.temperature_k, 293.15)
self.assertAlmostEqual(variable.temperature_k, 293.15)
self.assertAlmostEqual(variable.volume_cm3, 15000.0)
def test_duplicate_pressure_and_temperature_paths_match_primary_series(self) -> None:
for binding in self.catalog.bindings:
pressure = self.amesim_results.series(binding.pressure_path)
temperature = self.amesim_results.series(binding.temperature_path)
with self.subTest(alias=binding.alias):
for duplicate_path in binding.pressure_duplicate_paths:
duplicate = self.amesim_results.series(duplicate_path)
self.assertLess(
max(abs(a - b) for a, b in zip(pressure, duplicate)),
1.0e-12,
)
for duplicate_path in binding.temperature_duplicate_paths:
duplicate = self.amesim_results.series(duplicate_path)
self.assertLess(
max(abs(a - b) for a, b in zip(temperature, duplicate)),
1.0e-12,
)
if __name__ == "__main__":
unittest.main()
-92
View File
@@ -1,92 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.examples.test_mql.solver import SolveIVPConfig
from app.simulation.examples.test_mql.system import TestMqlSystem
class TestMqlPneumaticChamberSegmentTests(unittest.TestCase):
def setUp(self) -> None:
self.system = TestMqlSystem()
self.discovery = self.system.discover_pneumatic_branch_topology()
self.spec = self.discovery.chamber_segment_specs[0]
def test_discovers_four_fixed_chamber_segments_from_real_topology(self) -> None:
self.assertEqual(len(self.discovery.chamber_segment_specs), 4)
self.assertEqual(self.spec.volume_alias, "pn_general_chamber")
self.assertEqual(self.spec.inlet_node_alias, "pn_node3_8")
self.assertEqual(self.spec.inlet_line_alias, "pneumatic_96")
self.assertEqual(self.spec.inlet_orifice_alias, "pn_orifice_18")
self.assertEqual(self.spec.volume_inlet_port, "port_2")
self.assertEqual(self.spec.outlet_orifice_alias, "pn_orifice_19")
self.assertEqual(self.spec.outlet_line_alias, "pneumatic_97")
self.assertEqual(self.spec.outlet_node_alias, "pn_node3_9")
self.assertEqual(self.spec.source, "amesim-cir-topology")
def test_segment_closure_writes_real_amesim_port_directions(self) -> None:
closure = self.system.pneumatic_chamber_segment_closure_from_spec(
self.spec,
inlet_pressure_pa=16.0e6,
outlet_pressure_pa=1.0e6,
)
snapshot = closure.snapshot()
rhs = closure.rhs(closure.initial_state_vector())
self.assertGreater(snapshot.inlet_flow, 0.0)
self.assertGreater(snapshot.outlet_flow, 0.0)
self.assertAlmostEqual(
closure.components.volume.port_b.m_flow,
snapshot.inlet_flow,
)
self.assertAlmostEqual(
closure.components.volume.port_a.m_flow,
-snapshot.outlet_flow,
)
self.assertAlmostEqual(
closure.components.inlet_orifice.port_b.m_flow,
snapshot.inlet_flow,
)
self.assertAlmostEqual(
closure.components.outlet_orifice.port_a.m_flow,
-snapshot.outlet_flow,
)
self.assertEqual(len(rhs), 2)
self.assertAlmostEqual(rhs[0], snapshot.inlet_flow - snapshot.outlet_flow)
def test_segment_closure_handles_reverse_boundary_pressures(self) -> None:
closure = self.system.pneumatic_chamber_segment_closure_from_spec(
self.spec,
inlet_pressure_pa=1.0e6,
outlet_pressure_pa=16.0e6,
)
snapshot = closure.snapshot()
rhs = closure.rhs(closure.initial_state_vector())
self.assertLess(snapshot.inlet_flow, 0.0)
self.assertLess(snapshot.outlet_flow, 0.0)
self.assertAlmostEqual(rhs[0], snapshot.inlet_flow - snapshot.outlet_flow)
def test_simulates_topology_derived_segment_with_prescribed_boundaries(self) -> None:
solution = self.system.simulate_pneumatic_chamber_segment_from_spec(
self.spec,
inlet_pressure_pa=16.0e6,
outlet_pressure_pa=14.0e6,
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0e-5,
max_step=1.0e-6,
),
t_eval=[0.0, 5.0e-6, 1.0e-5],
)
self.assertTrue(solution.success)
self.assertEqual(len(solution.t), 3)
self.assertEqual(len(solution.y), 2)
self.assertEqual([len(row) for row in solution.y], [3, 3])
if __name__ == "__main__":
unittest.main()
-138
View File
@@ -1,138 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.examples.test_mql.primitives.pneumatic import (
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
)
from app.simulation.examples.test_mql.closure import (
TestMqlPneumaticBranchComponents,
TestMqlPneumaticClosure,
)
class TestMqlPneumaticClosureTests(unittest.TestCase):
def _make_closure(
self,
*,
upstream_pressure: float = 15.3e6,
downstream_pressure: float = 1.0e5,
) -> TestMqlPneumaticClosure:
upstream = AmesimPneumaticVolume.from_liters(
name="source_chamber",
volume_liters=57.0,
p0=upstream_pressure,
T0=293.15,
)
downstream = AmesimPneumaticVolume.from_liters(
name="target_chamber",
volume_liters=15.0,
p0=downstream_pressure,
T0=293.15,
)
orifice = AmesimPneumaticOrifice.from_mm2(
name="branch_orifice",
area_mm2=78.5,
flow_coefficient=0.9,
)
def initial_state_vector() -> list[float]:
return [*upstream.get_state_vector(), *downstream.get_state_vector()]
def apply_state_vector(values: list[float]) -> None:
if len(values) != 4:
raise ValueError("two-volume closure state vector requires four values")
upstream.set_state_vector(values[:2])
downstream.set_state_vector(values[2:])
return TestMqlPneumaticClosure(
components=TestMqlPneumaticBranchComponents(
name="source_to_target",
upstream_volume=upstream,
orifice=orifice,
downstream_volume=downstream,
),
initial_state_vector=initial_state_vector,
apply_state_vector=apply_state_vector,
)
def test_snapshot_writes_modelica_style_port_flow_signs(self) -> None:
closure = self._make_closure()
snapshot = closure.snapshot()
self.assertGreater(snapshot.flow, 0.0)
self.assertAlmostEqual(
closure.components.upstream_volume.port_a.m_flow,
-snapshot.flow,
)
self.assertAlmostEqual(
closure.components.orifice.port_a.m_flow,
snapshot.flow,
)
self.assertAlmostEqual(
closure.components.orifice.port_b.m_flow,
-snapshot.flow,
)
self.assertAlmostEqual(
closure.components.downstream_volume.port_a.m_flow,
snapshot.flow,
)
self.assertAlmostEqual(
closure.components.orifice.port_a.p,
snapshot.upstream.p,
)
self.assertAlmostEqual(
closure.components.orifice.port_b.p,
snapshot.downstream.p,
)
def test_rhs_uses_canonical_flow_for_volume_mass_balance(self) -> None:
closure = self._make_closure()
state = closure.initial_state_vector()
snapshot = closure.snapshot(state)
rhs = closure.rhs(state)
self.assertEqual(len(rhs), 4)
self.assertAlmostEqual(rhs[0], -snapshot.flow)
self.assertAlmostEqual(rhs[2], snapshot.flow)
self.assertAlmostEqual(rhs[0] + rhs[2], 0.0)
self.assertAlmostEqual(rhs[1], -snapshot.flow * snapshot.upstream.h)
self.assertAlmostEqual(rhs[3], snapshot.flow * snapshot.upstream.h)
def test_reverse_pressure_reverses_canonical_flow_and_port_signs(self) -> None:
closure = self._make_closure(
upstream_pressure=1.0e5,
downstream_pressure=15.3e6,
)
snapshot = closure.snapshot()
rhs = closure.rhs(closure.initial_state_vector())
self.assertLess(snapshot.flow, 0.0)
self.assertGreater(closure.components.upstream_volume.port_a.m_flow, 0.0)
self.assertLess(closure.components.downstream_volume.port_a.m_flow, 0.0)
self.assertAlmostEqual(rhs[0], -snapshot.flow)
self.assertAlmostEqual(rhs[2], snapshot.flow)
self.assertAlmostEqual(rhs[0] + rhs[2], 0.0)
self.assertAlmostEqual(rhs[1], -snapshot.flow * snapshot.downstream.h)
self.assertAlmostEqual(rhs[3], snapshot.flow * snapshot.downstream.h)
def test_snapshot_can_apply_explicit_state_vector(self) -> None:
closure = self._make_closure()
state = closure.initial_state_vector()
state[0] *= 0.99
snapshot = closure.snapshot(state)
self.assertAlmostEqual(
closure.components.upstream_volume.state.m,
state[0],
)
self.assertGreater(snapshot.upstream.p, snapshot.downstream.p)
if __name__ == "__main__":
unittest.main()
-169
View File
@@ -1,169 +0,0 @@
from __future__ import annotations
import csv
import tempfile
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import (
AmesimResults,
load_test_mql_amesim_results,
)
from app.simulation.reporting.test_mql_comparison import (
DEFAULT_TEST_MQL_ALIGNMENT_PATHS,
TestMqlComparisonError,
compare_test_mql_series,
interpolate_series_value,
write_test_mql_amesim_baseline_csv,
write_test_mql_comparison_csv,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlComparisonTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
def test_exact_amesim_series_compare_with_zero_error(self) -> None:
data_paths = ("temp3@pn_c1_8", "press3@pn_c1_8")
python_series = {
data_path: self.amesim_results.series(data_path)
for data_path in data_paths
}
comparison = compare_test_mql_series(
python_times=self.amesim_results.times,
python_series_by_data_path=python_series,
amesim_results=self.amesim_results,
data_paths=data_paths,
)
self.assertEqual(len(comparison.metrics), 2)
self.assertEqual(comparison.max_abs_error, 0.0)
self.assertEqual(comparison.metric("temp3@pn_c1_8").sample_count, 1002)
def test_coarse_python_times_are_interpolated_against_amesim(self) -> None:
data_path = "temp3@pn_c1_8"
python_times = self.amesim_results.times[::100]
python_series = {
data_path: tuple(
interpolate_series_value(
self.amesim_results.times,
self.amesim_results.series(data_path),
time_value,
)
for time_value in python_times
)
}
comparison = compare_test_mql_series(
python_times=python_times,
python_series_by_data_path=python_series,
amesim_results=self.amesim_results,
data_paths=(data_path,),
)
self.assertEqual(comparison.metric(data_path).sample_count, len(python_times))
self.assertEqual(comparison.max_abs_error, 0.0)
def test_offset_series_reports_abs_and_relative_error(self) -> None:
data_path = "temp3@pn_c1_8"
python_series = {
data_path: tuple(value + 1.0 for value in self.amesim_results.series(data_path))
}
comparison = compare_test_mql_series(
python_times=self.amesim_results.times,
python_series_by_data_path=python_series,
amesim_results=self.amesim_results,
data_paths=(data_path,),
)
metric = comparison.metric(data_path)
self.assertAlmostEqual(metric.max_abs_error, 1.0)
self.assertAlmostEqual(metric.mean_abs_error, 1.0)
self.assertGreater(metric.max_rel_error, 0.0)
def test_zero_amesim_baseline_does_not_invent_a_relative_denominator(self) -> None:
amesim_results = AmesimResults(
times=(0.0, 1.0),
variables=(),
saved_variable_indices=(),
series_by_data_path={"zero@baseline": (0.0, -0.0)},
final_values_by_data_path={"zero@baseline": -0.0},
)
comparison = compare_test_mql_series(
python_times=(0.0, 1.0),
python_series_by_data_path={"zero@baseline": (0.0, 1.0e-6)},
amesim_results=amesim_results,
data_paths=("zero@baseline",),
)
metric = comparison.metric("zero@baseline")
self.assertEqual(metric.max_abs_error, 1.0e-6)
self.assertEqual(metric.undefined_rel_error_count, 2)
self.assertEqual(metric.near_zero_baseline_count, 2)
self.assertEqual(comparison.undefined_rel_error_count, 2)
self.assertEqual(metric.max_rel_error, 0.0)
with tempfile.TemporaryDirectory() as temp_dir:
comparison_path, _summary_path, _comparison = (
write_test_mql_comparison_csv(
output_dir=Path(temp_dir),
python_times=(0.0, 1.0),
python_series_by_data_path={
"zero@baseline": (0.0, 1.0e-6)
},
amesim_results=amesim_results,
data_paths=("zero@baseline",),
)
)
with comparison_path.open(newline="", encoding="utf-8") as handle:
rows = list(csv.reader(handle))
self.assertEqual(rows[1][-1], "")
self.assertEqual(rows[2][-1], "")
def test_missing_data_path_is_rejected(self) -> None:
with self.assertRaises(TestMqlComparisonError):
compare_test_mql_series(
python_times=self.amesim_results.times,
python_series_by_data_path={"missing@component": (1.0,) * 1002},
amesim_results=self.amesim_results,
)
def test_writes_baseline_and_comparison_csv_files(self) -> None:
data_paths = DEFAULT_TEST_MQL_ALIGNMENT_PATHS[:2]
python_series = {
data_path: self.amesim_results.series(data_path)
for data_path in data_paths
}
with tempfile.TemporaryDirectory() as temp_dir:
output_dir = Path(temp_dir)
baseline_path = write_test_mql_amesim_baseline_csv(
output_dir,
self.amesim_results,
data_paths=data_paths,
)
comparison_path, summary_path, comparison = write_test_mql_comparison_csv(
output_dir=output_dir,
python_times=self.amesim_results.times,
python_series_by_data_path=python_series,
amesim_results=self.amesim_results,
data_paths=data_paths,
)
self.assertTrue(baseline_path.exists())
self.assertTrue(comparison_path.exists())
self.assertTrue(summary_path.exists())
self.assertIn("time_s,temp3@pn_c1_8,press3@pn_c1_8", baseline_path.read_text(encoding="utf-8"))
self.assertEqual(comparison.max_abs_error, 0.0)
if __name__ == "__main__":
unittest.main()
-272
View File
@@ -1,272 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.examples.test_mql.computed import (
run_test_mql_computed_geometry,
run_test_mql_computed_line_relations,
run_test_mql_computed_mechanical_relations,
run_test_mql_computed_piston_geometry,
run_test_mql_computed_pneumatic_relations,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlComputedPistonGeometryTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.computed_run = run_test_mql_computed_piston_geometry(TEST_MQL_AME)
def test_computed_piston_geometry_outputs_schema_valid_series(self) -> None:
self.assertEqual(self.computed_run.sample_count, 1002)
self.assertEqual(len(self.computed_run.mechanical_assembly.pistons), 8)
self.assertEqual(self.computed_run.signal_count, 24)
self.assertEqual(len(self.computed_run.comparison.metrics), 24)
self.assertEqual(self.computed_run.output_schema.signal_count, 858)
def test_computed_piston_geometry_output_paths_are_expected(self) -> None:
paths = self.computed_run.output.data_paths
self.assertIn("length@pn_brp2_8", paths)
self.assertIn("vol1@pn_brp2_8", paths)
self.assertIn("vvol1@pn_brp2_8", paths)
self.assertIn("length@pn_brp2_15", paths)
self.assertEqual(len([path for path in paths if path.startswith("length@")]), 8)
self.assertEqual(len([path for path in paths if path.startswith("vol1@")]), 8)
self.assertEqual(len([path for path in paths if path.startswith("vvol1@")]), 8)
def test_computed_piston_geometry_matches_amesim_with_tight_error_bounds(self) -> None:
for data_path in self.computed_run.output.data_paths:
metric = self.computed_run.comparison.metric(data_path)
with self.subTest(data_path=data_path):
if data_path.startswith("length@"):
self.assertLessEqual(metric.max_abs_error, 3.0e-13)
elif data_path.startswith("vol1@"):
self.assertLessEqual(metric.max_abs_error, 8.0e-12)
elif data_path.startswith("vvol1@"):
self.assertLessEqual(metric.max_abs_error, 3.0e-11)
else:
self.fail(f"Unexpected computed piston geometry path: {data_path}")
def test_computed_piston_geometry_final_samples_match_expected_values(self) -> None:
self.assertAlmostEqual(
self.computed_run.output.series("length@pn_brp2_8")[-1],
1090.0013536465842,
)
self.assertAlmostEqual(
self.computed_run.output.series("vol1@pn_brp2_8")[-1],
34242.54636512914,
delta=1.0e-9,
)
self.assertAlmostEqual(
self.computed_run.output.series("vvol1@pn_brp2_8")[-1],
8.360141002531034e-07,
delta=1.0e-11,
)
class TestMqlComputedGeometryTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.computed_run = run_test_mql_computed_geometry(TEST_MQL_AME)
def test_computed_geometry_outputs_schema_valid_series(self) -> None:
self.assertEqual(self.computed_run.sample_count, 1002)
self.assertEqual(len(self.computed_run.mechanical_assembly.pistons), 8)
self.assertEqual(len(self.computed_run.pneumatic_assembly.variable_chambers), 8)
self.assertEqual(self.computed_run.signal_count, 32)
self.assertEqual(len(self.computed_run.comparison.metrics), 32)
self.assertEqual(self.computed_run.output_schema.signal_count, 858)
def test_computed_geometry_includes_variable_chamber_volume_paths(self) -> None:
paths = self.computed_run.output.data_paths
self.assertIn("vol@pn_c1_8", paths)
self.assertIn("vol@pn_c1_15", paths)
self.assertEqual(len([path for path in paths if path.startswith("vol@pn_c1")]), 8)
self.assertEqual(len([path for path in paths if path.startswith("length@")]), 8)
self.assertEqual(len([path for path in paths if path.startswith("vol1@")]), 8)
self.assertEqual(len([path for path in paths if path.startswith("vvol1@")]), 8)
def test_computed_variable_chamber_volumes_match_amesim_with_tight_error_bounds(self) -> None:
for data_path in self.computed_run.output.data_paths:
metric = self.computed_run.comparison.metric(data_path)
with self.subTest(data_path=data_path):
if data_path.startswith("vol@pn_c1"):
self.assertLessEqual(metric.max_abs_error, 8.0e-12)
def test_computed_variable_chamber_volume_final_samples_match_expected_values(self) -> None:
self.assertAlmostEqual(
self.computed_run.output.series("vol@pn_c1_8")[-1],
49242.54636512914,
delta=1.0e-9,
)
self.assertAlmostEqual(
self.computed_run.output.series("vol@pn_c1_15")[-1],
49242.546365149814,
delta=1.0e-9,
)
class TestMqlComputedLineRelationsTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.computed_run = run_test_mql_computed_line_relations(TEST_MQL_AME)
def test_computed_line_relations_outputs_schema_valid_series(self) -> None:
self.assertEqual(self.computed_run.sample_count, 1002)
self.assertEqual(len(self.computed_run.observation_catalog.lines.by_submodel("PNL00R")), 4)
self.assertEqual(self.computed_run.signal_count, 8)
self.assertEqual(len(self.computed_run.comparison.metrics), 8)
self.assertEqual(self.computed_run.output_schema.signal_count, 858)
def test_computed_line_relations_include_expected_paths(self) -> None:
paths = self.computed_run.output.data_paths
self.assertIn("dm2@pneumatic_100", paths)
self.assertIn("dh2@pneumatic_100", paths)
self.assertEqual(len([path for path in paths if path.startswith("dm2@")]), 4)
self.assertEqual(len([path for path in paths if path.startswith("dh2@")]), 4)
def test_computed_line_relations_match_amesim_exactly(self) -> None:
for data_path in self.computed_run.output.data_paths:
metric = self.computed_run.comparison.metric(data_path)
with self.subTest(data_path=data_path):
self.assertEqual(metric.max_abs_error, 0.0)
def test_computed_line_relations_final_samples_match_expected_values(self) -> None:
self.assertAlmostEqual(
self.computed_run.output.series("dm2@pneumatic_100")[-1],
1.4837698580885316e-30,
)
self.assertAlmostEqual(
self.computed_run.output.series("dh2@pneumatic_100")[-1],
-1.2638893078048237e-28,
)
class TestMqlComputedPneumaticRelationsTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.computed_run = run_test_mql_computed_pneumatic_relations(TEST_MQL_AME)
def test_computed_pneumatic_relations_outputs_schema_valid_series(self) -> None:
self.assertEqual(self.computed_run.sample_count, 1002)
self.assertEqual(self.computed_run.observation_catalog.chambers.fixed_count, 4)
self.assertEqual(self.computed_run.observation_catalog.chambers.variable_count, 8)
self.assertEqual(self.computed_run.observation_catalog.orifices.fixed_count, 8)
self.assertEqual(self.computed_run.observation_catalog.orifices.variable_count, 8)
self.assertEqual(self.computed_run.signal_count, 88)
self.assertEqual(len(self.computed_run.comparison.metrics), 88)
self.assertEqual(self.computed_run.output_schema.signal_count, 858)
def test_computed_pneumatic_relations_include_expected_paths(self) -> None:
paths = self.computed_run.output.data_paths
self.assertIn("press2@pn_c1_8", paths)
self.assertIn("temp2@pn_c1_8", paths)
self.assertIn("dm2@pn_orifice_18", paths)
self.assertIn("dh2@pn_orifice_18", paths)
self.assertEqual(len([path for path in paths if path.startswith("press")]), 28)
self.assertEqual(len([path for path in paths if path.startswith("temp")]), 28)
self.assertEqual(len([path for path in paths if path.startswith("dm")]), 16)
self.assertEqual(len([path for path in paths if path.startswith("dh")]), 16)
def test_computed_pneumatic_relations_match_amesim_exactly(self) -> None:
for data_path in self.computed_run.output.data_paths:
metric = self.computed_run.comparison.metric(data_path)
with self.subTest(data_path=data_path):
self.assertEqual(metric.max_abs_error, 0.0)
def test_computed_pneumatic_relations_final_samples_match_expected_values(self) -> None:
self.assertAlmostEqual(
self.computed_run.output.series("press2@pn_c1_8")[-1],
4310310.457796034,
)
self.assertAlmostEqual(
self.computed_run.output.series("temp2@pn_c1_8")[-1],
293.1143259012449,
)
self.assertAlmostEqual(
self.computed_run.output.series("dm2@pn_orifice_18")[-1],
-0.009097746783809915,
)
self.assertAlmostEqual(
self.computed_run.output.series("dh2@pn_orifice_18")[-1],
1.1281175640493923,
)
class TestMqlComputedMechanicalRelationsTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.computed_run = run_test_mql_computed_mechanical_relations(TEST_MQL_AME)
def test_computed_mechanical_relations_outputs_schema_valid_series(self) -> None:
self.assertEqual(self.computed_run.sample_count, 1002)
self.assertEqual(len(self.computed_run.mechanical_assembly.masses), 10)
self.assertEqual(self.computed_run.signal_count, 76)
self.assertEqual(len(self.computed_run.comparison.metrics), 76)
self.assertEqual(self.computed_run.output_schema.signal_count, 858)
def test_computed_mechanical_relations_include_expected_paths(self) -> None:
paths = self.computed_run.output.data_paths
self.assertIn("x1dup@mass_friction_endstops_10", paths)
self.assertIn("v1dup@mass_friction_endstops_10", paths)
self.assertIn("acc1dup@mass_friction_endstops_10", paths)
self.assertIn("Fmin@mass_friction_endstops_10", paths)
self.assertIn("Fvisc@mass_friction_endstops_10", paths)
self.assertIn("Ffric@mass_friction_endstops_10", paths)
self.assertIn("fzero@zeroforcesource_17", paths)
self.assertEqual(len([path for path in paths if path.startswith("x1dup@")]), 10)
self.assertEqual(len([path for path in paths if path.startswith("v1dup@")]), 10)
self.assertEqual(len([path for path in paths if path.startswith("acc1dup@")]), 10)
self.assertEqual(len([path for path in paths if path.startswith("Fmin@")]), 10)
self.assertEqual(len([path for path in paths if path.startswith("Fvisc@")]), 10)
self.assertEqual(len([path for path in paths if path.startswith("Ffric@")]), 10)
self.assertEqual(len([path for path in paths if path.startswith("fzero@")]), 16)
def test_computed_mechanical_relations_match_amesim_exactly(self) -> None:
for data_path in self.computed_run.output.data_paths:
metric = self.computed_run.comparison.metric(data_path)
with self.subTest(data_path=data_path):
self.assertEqual(metric.max_abs_error, 0.0)
def test_computed_mechanical_relations_final_samples_match_expected_values(self) -> None:
self.assertAlmostEqual(
self.computed_run.output.series("x1dup@mass_friction_endstops_10")[-1],
-0.3700013536465843,
)
self.assertAlmostEqual(
self.computed_run.output.series("v1dup@mass_friction_endstops_10")[-1],
-4.435303434246023e-10,
)
self.assertAlmostEqual(
self.computed_run.output.series("acc1dup@mass_friction_endstops_10")[-1],
3.5662378650158644e-07,
)
self.assertEqual(
self.computed_run.output.series("Fmin@mass_friction_endstops_10")[-1],
0.0,
)
self.assertEqual(
self.computed_run.output.series("Fvisc@mass_friction_endstops_10")[-1],
0.0,
)
self.assertEqual(
self.computed_run.output.series("Ffric@mass_friction_endstops_10")[-1],
0.0,
)
self.assertEqual(
self.computed_run.output.series("fzero@zeroforcesource_17")[-1],
0.0,
)
if __name__ == "__main__":
unittest.main()
-70
View File
@@ -1,70 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.examples.test_mql.config import TestMqlConfig, resolve_numeric_expression
class TestMqlConfigTest(unittest.TestCase):
def test_resolves_global_parameters(self) -> None:
config = TestMqlConfig.from_amesim_specs()
self.assertEqual(config.global_parameters["D1"], 20.0)
self.assertEqual(config.global_parameters["D2"], 20.0)
self.assertEqual(config.global_parameters["D3"], 14.0)
self.assertEqual(config.global_parameters["P0"], 153.0)
self.assertEqual(config.global_parameters["Pdq"], 1.0)
self.assertEqual(config.global_parameters["V"], 15.0)
self.assertEqual(config.global_parameters["cf"], 0.45)
self.assertEqual(config.fluid.name, "helium")
self.assertAlmostEqual(config.fluid.specific_gas_constant, 2077.3, delta=0.5)
def test_resolves_amesim_parameter_expressions(self) -> None:
variables = {"D2": 20.0, "P0": 153.0, "cf": 0.45}
self.assertAlmostEqual(resolve_numeric_expression("3.14*10^2/4", variables), 78.5)
self.assertAlmostEqual(resolve_numeric_expression("0.045/D2", variables), 0.00225)
self.assertAlmostEqual(resolve_numeric_expression("cf", variables), 0.45)
self.assertAlmostEqual(
resolve_numeric_expression("(P0-1.01300000000000e+00)/1.00000000000000e-05", variables),
15198700.0,
)
self.assertIsNone(resolve_numeric_expression("predefined nitrogen (N2)", variables))
def test_groups_components_by_submodel(self) -> None:
config = TestMqlConfig.from_amesim_specs()
self.assertEqual(len(config.components_by_submodel("PNRP17")), 8)
self.assertEqual(len(config.components_by_submodel("PNCH012")), 8)
self.assertEqual(len(config.components_by_submodel("PNOR001")), 8)
self.assertEqual(len(config.components_by_submodel("PNVO001")), 8)
self.assertEqual(len(config.components_by_submodel("PNGD00")), 1)
def test_resolves_representative_component_parameters(self) -> None:
config = TestMqlConfig.from_amesim_specs()
piston = config.component("pn_brp2_8")
self.assertEqual(piston.submodel, "PNRP17")
self.assertEqual(piston.parameter_value("dp"), 200.0)
self.assertEqual(piston.parameter_value("dr"), 1.0)
fixed_orifice = config.component("pn_orifice_18")
self.assertEqual(fixed_orifice.submodel, "PNOR001")
self.assertAlmostEqual(fixed_orifice.parameter_value("area"), 78.5)
variable_orifice = config.component("pn_morifice_11")
self.assertEqual(variable_orifice.submodel, "PNVO001")
self.assertAlmostEqual(variable_orifice.parameter_value("cq"), 0.45)
self.assertAlmostEqual(variable_orifice.parameter_value("area0"), 78.5)
chamber = config.component("pn_c1_8")
self.assertEqual(chamber.submodel, "PNCH012")
self.assertEqual(chamber.parameter_value("cvol0"), 15.0)
gas = config.component("pn_gas_data")
self.assertEqual(gas.submodel, "PNGD00")
self.assertAlmostEqual(gas.parameter_value("rGas"), 287.2)
if __name__ == "__main__":
unittest.main()
-63
View File
@@ -1,63 +0,0 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from app.main import ReactFlowProjectPayload, run_reactflow_test_mql
from app.simulation.examples.test_mql.run import prepare_test_mql_run, run_test_mql
from app.simulation.examples.test_mql.solver import SolveIVPConfig
from app.simulation.examples.test_mql.system import TestMqlRunConfig
from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR
class TestMqlExampleRunnerTests(unittest.TestCase):
def test_test_mql_keeps_calibrated_default_tolerance(self) -> None:
self.assertEqual(SolveIVPConfig().atol, 1.0e-10)
def test_default_run_paths_use_app_simulation_runs_dir(self) -> None:
prepared = prepare_test_mql_run()
self.assertEqual(prepared.repo_root, PROJECT_ROOT)
self.assertEqual(prepared.output_dir.parent, SIMULATION_RUNS_DIR)
def test_zero_duration_run_writes_summary(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output_dir = Path(directory) / "test_mql"
result = run_test_mql(
run_config=TestMqlRunConfig(t_start=0.0, t_stop=0.0),
output_dir=output_dir,
)
self.assertEqual(result.result.t, [0.0])
self.assertEqual(result.summary_path, output_dir / "test_mql_model_summary.txt")
self.assertIn(
"Model: test_mql",
result.summary_path.read_text(encoding="utf-8"),
)
def test_reactflow_helper_runs_fixed_topology_test_mql(self) -> None:
payload = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="test-mql-fixed",
simulation={
"t_start": 0.0,
"t_stop": 0.2,
"step": 0.1,
"max_step": 0.001,
"method": "BDF",
},
)
result = run_reactflow_test_mql(payload)
self.assertTrue(result["success"])
self.assertEqual(result["model"]["name"], "test_mql")
self.assertEqual(result["model"]["componentCount"], 117)
self.assertEqual(result["series"]["time"], [0.0, 0.1, 0.2])
self.assertIn("summary", result["artifacts"])
if __name__ == "__main__":
unittest.main()
-111
View File
@@ -1,111 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.reporting.test_mql_line_observations import (
G_PER_S_TO_KG_PER_S,
build_test_mql_line_observation_catalog,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlLineObservationCatalogTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
cls.catalog = build_test_mql_line_observation_catalog(cls.amesim_results)
def test_builds_expected_line_observation_counts(self) -> None:
self.assertEqual(self.catalog.line_count, 40)
self.assertEqual(len(self.catalog.by_submodel("PNL0001")), 20)
self.assertEqual(len(self.catalog.by_submodel("PNL0002")), 8)
self.assertEqual(len(self.catalog.by_submodel("PNL0003")), 8)
self.assertEqual(len(self.catalog.by_submodel("PNL00R")), 4)
def test_pnl0001_binding_exposes_single_flow_and_compliance_observables(self) -> None:
binding = self.catalog.by_alias("pneumatic_69")
self.assertEqual(binding.submodel, "PNL0001")
self.assertEqual(binding.pattern, "C-R")
self.assertEqual(binding.mass_flow_paths, ("dm1@pneumatic_69",))
self.assertEqual(binding.enthalpy_flow_paths, ("dh1@pneumatic_69",))
self.assertEqual(binding.pressure_paths, ("p2@pneumatic_69",))
self.assertEqual(binding.temperature_paths, ("t2@pneumatic_69",))
self.assertEqual(binding.gas_mass_path, "mgas@pneumatic_69")
self.assertEqual(binding.reynolds_path, "re@pneumatic_69")
self.assertEqual(binding.mass_flow_parameter_path, "cm@pneumatic_69")
self.assertEqual(binding.gas_velocity_path, "v@pneumatic_69")
self.assertEqual(binding.friction_factor_path, "ff@pneumatic_69")
def test_pnl0002_and_pnl0003_keep_distinct_flow_topologies(self) -> None:
rcr = self.catalog.by_alias("pneumatic_80")
crc = self.catalog.by_alias("pneumatic_75")
self.assertEqual(rcr.pattern, "R-C-R")
self.assertEqual(rcr.mass_flow_paths, ("dm1@pneumatic_80", "dm2@pneumatic_80"))
self.assertEqual(rcr.enthalpy_flow_paths, ("dh1@pneumatic_80", "dh2@pneumatic_80"))
self.assertEqual(rcr.pressure_paths, ("pctr@pneumatic_80",))
self.assertEqual(rcr.temperature_paths, ("tctr@pneumatic_80",))
self.assertEqual(crc.pattern, "C-R-C")
self.assertEqual(crc.mass_flow_paths, ("dmctr@pneumatic_75",))
self.assertEqual(crc.enthalpy_flow_paths, ("dhctr@pneumatic_75",))
self.assertEqual(crc.pressure_paths, ("p1@pneumatic_75", "p2@pneumatic_75"))
self.assertEqual(crc.temperature_paths, ("t1@pneumatic_75", "t2@pneumatic_75"))
def test_mass_flow_series_are_converted_to_si_units(self) -> None:
binding = self.catalog.by_alias("pneumatic_69")
mass_flow = binding.mass_flow_kg_s_series(self.amesim_results)
self.assertAlmostEqual(
mass_flow[-1],
self.amesim_results.series("dm1@pneumatic_69")[-1] * G_PER_S_TO_KG_PER_S,
)
self.assertAlmostEqual(mass_flow[-1], -9.527778353091475e-6)
self.assertAlmostEqual(max(mass_flow), 0.4490761730524304)
def test_observation_exposes_line_state_and_diagnostics(self) -> None:
observation = self.catalog.by_alias("pneumatic_75").observation_at(
self.amesim_results,
-1,
)
self.assertAlmostEqual(observation.time, self.amesim_results.times[-1])
self.assertAlmostEqual(
observation.mass_flows_kg_s["dmctr@pneumatic_75"],
9.486502325625855e-6,
)
self.assertAlmostEqual(
observation.enthalpy_flows_w["dhctr@pneumatic_75"],
-0.7322143070637952,
)
self.assertAlmostEqual(observation.pressures_pa["p1@pneumatic_75"], 4310289.672688478)
self.assertAlmostEqual(observation.pressures_pa["p2@pneumatic_75"], 4310295.447776705)
self.assertAlmostEqual(observation.temperatures_k["t1@pneumatic_75"], 281.37837485614546)
self.assertAlmostEqual(observation.temperatures_k["t2@pneumatic_75"], 282.9136549991407)
self.assertAlmostEqual(observation.gas_mass_g, 0.7031236652270819)
self.assertAlmostEqual(observation.reynolds_number, 31.539799199000434)
self.assertAlmostEqual(observation.mass_flow_parameter, 6.351754064211156e-07)
self.assertAlmostEqual(observation.gas_velocity_m_s, 0.0040585650473562935)
self.assertAlmostEqual(observation.friction_factor, 2.0291822277051246)
def test_resistance_only_lines_preserve_reversed_duplicate_signs(self) -> None:
for binding in self.catalog.by_submodel("PNL00R"):
dm1 = self.amesim_results.series(f"dm1@{binding.alias}")
dm2 = self.amesim_results.series(f"dm2@{binding.alias}")
dh1 = self.amesim_results.series(f"dh1@{binding.alias}")
dh2 = self.amesim_results.series(f"dh2@{binding.alias}")
with self.subTest(alias=binding.alias):
self.assertIsNone(binding.gas_mass_path)
self.assertEqual(binding.pressure_paths, ())
self.assertEqual(binding.temperature_paths, ())
self.assertLess(max(abs(a + b) for a, b in zip(dm1, dm2)), 1.0e-36)
self.assertLess(max(abs(a + b) for a, b in zip(dh1, dh2)), 1.0e-34)
if __name__ == "__main__":
unittest.main()
-72
View File
@@ -1,72 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.reporting.test_mql_variables import build_test_mql_variable_catalog
from app.simulation.examples.test_mql.lines import build_test_mql_line_assembly
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlLineAssemblyTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
cls.variable_catalog = build_test_mql_variable_catalog(cls.amesim_results)
cls.assembly = build_test_mql_line_assembly(
cls.amesim_results,
cls.variable_catalog,
)
def test_assembles_expected_line_counts(self) -> None:
self.assertEqual(self.assembly.line_count, 40)
self.assertEqual(
self.assembly.counts_by_submodel(),
{"PNL0001": 20, "PNL0003": 8, "PNL0002": 8, "PNL00R": 4},
)
self.assertIn("pneumatic_69", self.assembly.aliases())
self.assertIn("pneumatic_100", self.assembly.aliases())
def test_preserves_connection_topology(self) -> None:
line = self.assembly.by_alias("pneumatic_69")
self.assertEqual(line.index, 37)
self.assertEqual(line.submodel, "PNL0001")
self.assertEqual(line.pattern, "C-R")
self.assertEqual(line.source_component, "pnnode4_16")
self.assertEqual(line.source_port, "port_2")
self.assertEqual(line.target_component, "pn_c1_8")
self.assertEqual(line.target_port, "port_1")
self.assertTrue(line.has_compliance)
self.assertTrue(line.has_resistance)
def test_line_data_paths_are_attached_from_amesim_results(self) -> None:
line = self.assembly.by_alias("pneumatic_69")
self.assertIn("dh1@pneumatic_69", line.data_paths)
self.assertIn("dm1@pneumatic_69", line.data_paths)
self.assertIn("dh1", line.signal_names)
self.assertIn("dm1", line.signal_names)
def test_resistance_only_line_keeps_r_pattern(self) -> None:
line = self.assembly.by_alias("pneumatic_100")
self.assertEqual(line.submodel, "PNL00R")
self.assertEqual(line.pattern, "R")
self.assertFalse(line.has_compliance)
self.assertTrue(line.has_resistance)
self.assertIn("dm1@pneumatic_100", line.data_paths)
def test_rcr_and_crc_patterns_are_distinct(self) -> None:
self.assertEqual(self.assembly.by_alias("pneumatic_80").pattern, "R-C-R")
self.assertEqual(self.assembly.by_alias("pneumatic_75").pattern, "C-R-C")
self.assertEqual(len(self.assembly.by_submodel("PNL0002")), 8)
self.assertEqual(len(self.assembly.by_submodel("PNL0003")), 8)
if __name__ == "__main__":
unittest.main()
-297
View File
@@ -1,297 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.examples.test_mql.solver import SolveIVPConfig
from app.simulation.reporting.test_mql_variables import build_test_mql_variable_catalog
from app.simulation.examples.test_mql.system import TestMqlSystem
from app.simulation.examples.test_mql.mechanical import (
TestMqlMechanicalMassClosure,
build_test_mql_mechanical_assembly,
circular_area,
mm_to_m,
n_per_mm_per_s_to_n_per_m_per_s,
n_per_mm_to_n_per_m,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlMechanicalAssemblyTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
cls.variable_catalog = build_test_mql_variable_catalog(cls.amesim_results)
cls.assembly = build_test_mql_mechanical_assembly(
amesim_results=cls.amesim_results,
variable_catalog=cls.variable_catalog,
)
def test_assembles_expected_mechanical_component_counts(self) -> None:
self.assertEqual(len(self.assembly.pistons), 8)
self.assertEqual(len(self.assembly.masses), 10)
self.assertEqual(len(self.assembly.elastic_endstops), 8)
self.assertEqual(len(self.assembly.mechanical_nodes), 2)
self.assertEqual(len(self.assembly.piecewise_signals), 2)
self.assertEqual(len(self.assembly.force_connectors), 2)
self.assertEqual(len(self.assembly.zero_force_sources), 16)
self.assertEqual(self.assembly.component_count, 48)
def test_piecewise_force_sources_match_amesim_samples(self) -> None:
front_signal = self.assembly.piecewise_signals["piecewiselinear_1"]
rear_signal = self.assembly.piecewise_signals["piecewiselinear"]
self.assertAlmostEqual(front_signal.output_at(0.0), 1.0e12)
self.assertAlmostEqual(front_signal.output_at(0.8000000000000009), 0.0)
self.assertAlmostEqual(rear_signal.output_at(0.0), 1.0e17)
self.assertAlmostEqual(rear_signal.output_at(0.8000000000000009), 49000.0)
self.assertEqual(
self.assembly.force_connectors["forcecon_2"].target_mass_alias,
"mass_friction_endstops_18",
)
self.assertEqual(
self.assembly.force_connectors["forcecon_1"].target_mass_alias,
"mass_friction_endstops_19",
)
self.assertAlmostEqual(
self.assembly.force_connectors["forcecon_2"].force_at(
0.0,
self.assembly.piecewise_signals,
),
self.amesim_results.series("force@forcecon_2")[0],
)
self.assertAlmostEqual(
self.assembly.force_connectors["forcecon_1"].force_at(
0.8000000000000009,
self.assembly.piecewise_signals,
),
self.amesim_results.series("force@forcecon_1")[80],
)
def test_mechanical_mass_closure_exposes_all_mecmas21_states(self) -> None:
closure = TestMqlMechanicalMassClosure(self.assembly)
state = closure.initial_state_vector()
snapshot = closure.snapshot(state)
rhs = closure.rhs(state)
self.assertEqual(closure.mass_aliases, tuple(self.assembly.masses))
self.assertEqual(len(closure.mass_aliases), 10)
self.assertEqual(len(state), 20)
self.assertEqual(snapshot.state_count, 20)
self.assertEqual(len(rhs), 20)
self.assertEqual(snapshot.states[0].alias, "mass_friction_endstops_10")
self.assertAlmostEqual(
snapshot.states[0].velocity_m_s,
self.amesim_results.series("v1@mass_friction_endstops_10")[0],
)
self.assertAlmostEqual(
snapshot.states[0].displacement_m,
self.amesim_results.series("x1@mass_friction_endstops_10")[0],
)
for index, mass_state in enumerate(snapshot.states):
self.assertAlmostEqual(rhs[2 * index + 1], mass_state.velocity_m_s)
def test_mechanical_mass_snapshot_exposes_lmechan1_node_kinematics(self) -> None:
closure = TestMqlMechanicalMassClosure(self.assembly)
state = closure.initial_state_vector()
state[16] = 0.2
state[17] = 0.37
state[18] = -0.3
state[19] = -0.72
snapshot = closure.snapshot(state)
front = snapshot.node_kinematics_by_alias[
"dynamic_mechanical_node_alternative_2"
]
rear = snapshot.node_kinematics_by_alias[
"dynamic_mechanical_node_alternative_3"
]
self.assertEqual(set(front.velocities_m_s), set(range(1, 9)))
self.assertEqual(set(front.displacements_m), set(range(1, 9)))
self.assertEqual(set(rear.velocities_m_s), set(range(1, 9)))
self.assertEqual(set(rear.displacements_m), set(range(1, 9)))
self.assertAlmostEqual(front.velocities_m_s[1], -0.2)
self.assertAlmostEqual(front.displacements_m[8], -0.37)
self.assertAlmostEqual(rear.velocities_m_s[1], -0.3)
self.assertAlmostEqual(rear.displacements_m[8], -0.72)
def test_mechanical_mass_snapshot_exposes_piston_kinematics(self) -> None:
closure = TestMqlMechanicalMassClosure(self.assembly)
state = closure.initial_state_vector()
state[0] = 0.4
state[1] = 0.37
state[18] = -0.3
state[19] = -0.72
snapshot = closure.snapshot(state)
piston = snapshot.piston_kinematics_by_alias["pn_brp2_8"]
self.assertEqual(set(snapshot.piston_kinematics_by_alias), set(self.assembly.pistons))
self.assertAlmostEqual(piston.port_2_velocity_m_s, 0.4)
self.assertAlmostEqual(piston.port_2_displacement_m, 0.37)
self.assertAlmostEqual(piston.port_3_velocity_m_s, -0.3)
self.assertAlmostEqual(piston.port_3_displacement_m, -0.72)
self.assertAlmostEqual(
self.assembly.pistons["pn_brp2_8"].geometry().chamber_length_mm(
piston.port_3_displacement_m,
piston.port_2_displacement_m,
),
1090.0,
)
def test_system_simulates_mechanical_mass_state_closure(self) -> None:
system = TestMqlSystem()
solution = system.simulate_mechanical_masses(
config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6),
t_eval=[0.0, 1.0e-5],
)
self.assertTrue(solution.success)
self.assertEqual(len(system.mechanical_state_vector()), 20)
self.assertEqual(len(solution.t), 2)
self.assertEqual(len(solution.y), 20)
self.assertEqual([len(row) for row in solution.y], [2] * 20)
def test_piston_geometry_is_converted_to_si_units(self) -> None:
piston = self.assembly.pistons["pn_brp2_8"]
self.assertAlmostEqual(piston.piston_diameter_m, 0.2)
self.assertAlmostEqual(piston.rod_diameter_m, 0.001)
self.assertAlmostEqual(piston.zero_displacement_m, 0.0)
self.assertAlmostEqual(piston.piston_area_m2, circular_area(0.2))
self.assertAlmostEqual(piston.rod_area_m2, circular_area(0.001))
self.assertAlmostEqual(piston.annulus_area_m2, piston.piston_area_m2 - piston.rod_area_m2)
self.assertIn("vvol1@pn_brp2_8", piston.data_paths)
self.assertIn("f2@pn_brp2_8", piston.data_paths)
def test_mass_endstop_parameters_are_converted_to_si_units(self) -> None:
mass = self.assembly.masses["mass_friction_endstops_10"]
self.assertEqual(mass.mass_kg, 50.0)
self.assertEqual(mass.xmin_m, -1.0)
self.assertEqual(mass.xmax_m, 0.8)
self.assertEqual(mass.min_stiffness_n_per_m, 1.0e9)
self.assertEqual(mass.max_stiffness_n_per_m, 1.0e9)
self.assertEqual(mass.min_damping_n_per_m_per_s, 1.0e4)
self.assertEqual(mass.max_damping_n_per_m_per_s, 1.0e4)
self.assertAlmostEqual(mass.min_penetration_m, 1.0e-4)
self.assertAlmostEqual(mass.max_penetration_m, 1.0e-4)
self.assertEqual(mass.stiction_force_n, 0.0)
self.assertEqual(mass.coulomb_friction_n, 0.0)
self.assertEqual(mass.viscous_friction_n_per_m_per_s, 0.0)
self.assertEqual(mass.windage_n_per_m2_per_s2, 0.0)
self.assertEqual(mass.stick_velocity_threshold_m_s, 1.0e-6)
self.assertEqual(mass.reset_velocity_threshold_m_s, 1.0e-6)
self.assertEqual(mass.rest_coeff, 0.65)
self.assertEqual(mass.stribeck_constant_m_s, 1.0e-3)
self.assertFalse(mass.use_friction)
self.assertEqual(mass.stop_type, 4)
self.assertIn("v1@mass_friction_endstops_10", mass.data_paths)
self.assertIn("x1@mass_friction_endstops_10", mass.data_paths)
def test_large_load_masses_keep_distinct_limits(self) -> None:
front = self.assembly.masses["mass_friction_endstops_18"]
rear = self.assembly.masses["mass_friction_endstops_19"]
self.assertEqual(front.mass_kg, 170000.0)
self.assertEqual(front.xmin_m, 0.0)
self.assertEqual(front.xmax_m, 0.37)
self.assertEqual(rear.mass_kg, 90000.0)
self.assertEqual(rear.xmin_m, -0.72)
self.assertEqual(rear.xmax_m, 0.0)
def test_elastic_endstop_parameters_are_converted_to_si_units(self) -> None:
endstop = self.assembly.elastic_endstops["elasticendstop_8"]
self.assertEqual(endstop.gap_m, 0.0)
self.assertEqual(endstop.contact_stiffness_n_per_m, 1.0e11)
self.assertEqual(endstop.contact_damping_n_per_m_per_s, 1.0e11)
self.assertAlmostEqual(endstop.spring_diameter_m, 0.02)
self.assertAlmostEqual(endstop.wire_diameter_m, 0.002)
def test_mechanical_nodes_keep_port_count(self) -> None:
node = self.assembly.mechanical_nodes["dynamic_mechanical_node_alternative_2"]
self.assertEqual(node.port_count, 8)
self.assertEqual(node.sum_mode, 1)
self.assertIn("dynamic_mechanical_node_alternative_2", self.assembly.aliases)
def test_all_piston_geometry_observables_match_amesim_final_sample(self) -> None:
for alias, piston in self.assembly.pistons.items():
with self.subTest(alias=alias):
geometry = piston.geometry()
x4 = self.amesim_results.series(f"x4@{alias}")[-1]
x5 = self.amesim_results.series(f"x5@{alias}")[-1]
v4 = self.amesim_results.series(f"v4@{alias}")[-1]
v5 = self.amesim_results.series(f"v5@{alias}")[-1]
self.assertAlmostEqual(
geometry.chamber_length_mm(x4, x5),
self.amesim_results.series(f"length@{alias}")[-1],
)
self.assertAlmostEqual(
geometry.chamber_volume_cm3(x4, x5),
self.amesim_results.series(f"vol1@{alias}")[-1],
delta=1.0e-6,
)
self.assertAlmostEqual(
geometry.chamber_volume_rate_l_min(v4, v5),
self.amesim_results.series(f"vvol1@{alias}")[-1],
delta=1.0e-8,
)
def test_all_elastic_endstop_forces_match_amesim_final_sample(self) -> None:
for endstop_alias, endstop_spec in self.assembly.elastic_endstops.items():
piston_alias = endstop_alias.replace("elasticendstop", "pn_brp2")
with self.subTest(alias=endstop_alias):
endstop = endstop_spec.endstop()
gap_mm = self.amesim_results.series(f"gap@{endstop_alias}")[-1]
penetration_velocity_m_s = self.amesim_results.series(f"v5@{piston_alias}")[-1]
self.assertAlmostEqual(
endstop.contact_force(gap_mm, penetration_velocity_m_s),
self.amesim_results.series(f"f1@{endstop_alias}")[-1],
delta=1.0e-3,
)
def test_mass_endstop_second_port_observables_are_opposite_sign(self) -> None:
for alias in self.assembly.masses:
with self.subTest(alias=alias):
for signal in ("x1", "v1", "acc1"):
primary = self.amesim_results.series(f"{signal}@{alias}")
second_port = self.amesim_results.series(f"{signal}dup@{alias}")
for index in (0, len(primary) // 2, -1):
self.assertAlmostEqual(primary[index], -second_port[index])
def test_mass_endstop_force_observables_match_inactive_amesim_contacts(self) -> None:
for alias, mass_spec in self.assembly.masses.items():
endstop = mass_spec.endstop()
with self.subTest(alias=alias):
x1 = self.amesim_results.series(f"x1@{alias}")[-1]
v1 = self.amesim_results.series(f"v1@{alias}")[-1]
self.assertAlmostEqual(
endstop.viscous_friction_force(v1),
self.amesim_results.series(f"Fvisc@{alias}")[-1],
)
self.assertAlmostEqual(self.amesim_results.series(f"Ffric@{alias}")[-1], 0.0)
self.assertAlmostEqual(self.amesim_results.series(f"Fmin@{alias}")[-1], 0.0)
self.assertAlmostEqual(self.amesim_results.series(f"Fmax@{alias}")[-1], 0.0)
self.assertLessEqual(endstop.lower_penetration_m(x1), 1.0e-12)
self.assertLessEqual(endstop.upper_penetration_m(x1), 1.0e-12)
def test_unit_helpers(self) -> None:
self.assertAlmostEqual(mm_to_m(20.0), 0.02)
self.assertAlmostEqual(n_per_mm_to_n_per_m(1.0e6), 1.0e9)
self.assertAlmostEqual(n_per_mm_per_s_to_n_per_m_per_s(10.0), 1.0e4)
if __name__ == "__main__":
unittest.main()
@@ -1,189 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.reporting.test_mql_mechanical_observations import (
build_test_mql_mechanical_observation_catalog,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlMechanicalObservationCatalogTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
cls.catalog = build_test_mql_mechanical_observation_catalog(cls.amesim_results)
def test_builds_expected_mechanical_observation_counts(self) -> None:
self.assertEqual(len(self.catalog.pistons), 8)
self.assertEqual(len(self.catalog.masses), 10)
self.assertEqual(len(self.catalog.elastic_endstops), 8)
self.assertEqual(len(self.catalog.zero_force_sources), 16)
self.assertEqual(len(self.catalog.force_connectors), 2)
self.assertEqual(len(self.catalog.mechanical_nodes), 2)
self.assertEqual(self.catalog.binding_count, 46)
def test_piston_binding_exposes_amesim_data_paths(self) -> None:
binding = self.catalog.pistons["pn_brp2_8"]
self.assertEqual(binding.volume_path, "vol1@pn_brp2_8")
self.assertEqual(binding.volume_rate_path, "vvol1@pn_brp2_8")
self.assertEqual(binding.length_path, "length@pn_brp2_8")
self.assertEqual(binding.force_port_2_path, "f2@pn_brp2_8")
self.assertEqual(binding.force_port_3_path, "f3@pn_brp2_8")
self.assertEqual(binding.displacement_port_2_path, "x5@pn_brp2_8")
self.assertEqual(binding.velocity_port_2_path, "v5@pn_brp2_8")
self.assertEqual(binding.displacement_port_3_path, "x4@pn_brp2_8")
self.assertEqual(binding.velocity_port_3_path, "v4@pn_brp2_8")
def test_piston_observation_matches_amesim_final_sample(self) -> None:
observation = self.catalog.pistons["pn_brp2_8"].observation_at(
self.amesim_results,
-1,
)
self.assertAlmostEqual(observation.time, self.amesim_results.times[-1])
self.assertAlmostEqual(observation.chamber_volume_cm3, 34242.54636512914)
self.assertAlmostEqual(observation.chamber_volume_rate_l_min, 8.360141002531034e-07)
self.assertAlmostEqual(observation.chamber_length_mm, 1090.0013536465842)
self.assertAlmostEqual(observation.displacement_port_2_m, 0.3700013536465843)
self.assertAlmostEqual(observation.velocity_port_2_m_s, 4.435303434246023e-10)
self.assertAlmostEqual(observation.displacement_port_3_m, -0.72)
self.assertAlmostEqual(observation.velocity_port_3_m_s, 0.0)
def test_mass_binding_exposes_primary_and_reversed_duplicate_paths(self) -> None:
binding = self.catalog.masses["mass_friction_endstops_10"]
self.assertEqual(binding.displacement_path, "x1@mass_friction_endstops_10")
self.assertEqual(binding.velocity_path, "v1@mass_friction_endstops_10")
self.assertEqual(binding.acceleration_path, "acc1@mass_friction_endstops_10")
self.assertEqual(binding.displacement_duplicate_path, "x1dup@mass_friction_endstops_10")
self.assertEqual(binding.velocity_duplicate_path, "v1dup@mass_friction_endstops_10")
self.assertEqual(binding.acceleration_duplicate_path, "acc1dup@mass_friction_endstops_10")
self.assertEqual(binding.lower_contact_force_path, "Fmin@mass_friction_endstops_10")
self.assertEqual(binding.upper_contact_force_path, "Fmax@mass_friction_endstops_10")
self.assertEqual(binding.viscous_friction_force_path, "Fvisc@mass_friction_endstops_10")
self.assertEqual(binding.dry_friction_force_path, "Ffric@mass_friction_endstops_10")
self.assertEqual(binding.stick_flag_path, "stick@mass_friction_endstops_10")
def test_mass_observation_matches_amesim_final_sample(self) -> None:
observation = self.catalog.masses["mass_friction_endstops_10"].observation_at(
self.amesim_results,
-1,
)
self.assertAlmostEqual(observation.displacement_m, 0.3700013536465843)
self.assertAlmostEqual(observation.velocity_m_s, 4.435303434246023e-10)
self.assertAlmostEqual(observation.acceleration_m_s2, -3.5662378650158644e-07)
self.assertAlmostEqual(observation.lower_contact_force_n, 0.0)
self.assertAlmostEqual(observation.upper_contact_force_n, 0.0)
self.assertAlmostEqual(observation.viscous_friction_force_n, 0.0)
self.assertAlmostEqual(observation.dry_friction_force_n, 0.0)
self.assertAlmostEqual(observation.stick_flag, 0.0)
def test_mass_duplicate_series_are_sign_reversed(self) -> None:
for binding in self.catalog.masses.values():
with self.subTest(alias=binding.alias):
for primary_path, duplicate_path in (
(binding.displacement_path, binding.displacement_duplicate_path),
(binding.velocity_path, binding.velocity_duplicate_path),
(binding.acceleration_path, binding.acceleration_duplicate_path),
):
primary = self.amesim_results.series(primary_path)
duplicate = self.amesim_results.series(duplicate_path)
self.assertLess(
max(abs(a + b) for a, b in zip(primary, duplicate)),
1.0e-12,
)
def test_elastic_endstop_observation_matches_amesim_final_sample(self) -> None:
observation = self.catalog.elastic_endstops["elasticendstop_8"].observation_at(
self.amesim_results,
-1,
)
self.assertAlmostEqual(observation.force_n, 135409.0113969468)
self.assertAlmostEqual(observation.duplicate_force_n, 135409.0113969468)
self.assertAlmostEqual(observation.gap_mm, -0.0013536465842123313)
self.assertAlmostEqual(observation.stiffness_n_m, 100000000000.0)
def test_elastic_endstop_duplicate_force_matches_primary_series(self) -> None:
for binding in self.catalog.elastic_endstops.values():
force = self.amesim_results.series(binding.force_path)
duplicate = self.amesim_results.series(binding.duplicate_force_path)
with self.subTest(alias=binding.alias):
self.assertLess(
max(abs(a - b) for a, b in zip(force, duplicate)),
1.0e-9,
)
def test_zero_force_source_observations_are_zero(self) -> None:
for binding in self.catalog.zero_force_sources.values():
force = self.amesim_results.series(binding.force_path)
with self.subTest(alias=binding.alias):
self.assertEqual(binding.force_path, f"fzero@{binding.alias}")
self.assertEqual(min(force), 0.0)
self.assertEqual(max(force), 0.0)
self.assertEqual(binding.observation_at(self.amesim_results, -1).force_n, 0.0)
def test_force_connector_observations_match_amesim_samples(self) -> None:
force_1 = self.catalog.force_connectors["forcecon_1"].observation_at(
self.amesim_results,
-1,
)
force_2 = self.catalog.force_connectors["forcecon_2"].observation_at(
self.amesim_results,
-1,
)
self.assertEqual(self.catalog.force_connectors["forcecon_1"].force_path, "force@forcecon_1")
self.assertEqual(self.catalog.force_connectors["forcecon_2"].force_path, "force@forcecon_2")
self.assertAlmostEqual(force_1.force_n, 49000.0)
self.assertAlmostEqual(force_2.force_n, 0.0)
self.assertAlmostEqual(
self.amesim_results.series("force@forcecon_1")[0],
1.0e17,
)
self.assertAlmostEqual(
self.amesim_results.series("force@forcecon_2")[0],
1.0e12,
)
def test_mechanical_node_bindings_expose_all_ports(self) -> None:
node = self.catalog.mechanical_nodes["dynamic_mechanical_node_alternative_2"]
self.assertEqual(sorted(node.velocity_paths_by_port), list(range(1, 9)))
self.assertEqual(sorted(node.displacement_paths_by_port), list(range(1, 9)))
self.assertEqual(node.velocity_paths_by_port[1], "p1__vt@dynamic_mechanical_node_alternative_2")
self.assertEqual(node.displacement_paths_by_port[8], "p8__xt@dynamic_mechanical_node_alternative_2")
self.assertEqual(node.total_force_path, "tforce@dynamic_mechanical_node_alternative_2")
def test_mechanical_node_observations_match_amesim_final_samples(self) -> None:
front = self.catalog.mechanical_nodes["dynamic_mechanical_node_alternative_2"].observation_at(
self.amesim_results,
-1,
)
rear = self.catalog.mechanical_nodes["dynamic_mechanical_node_alternative_3"].observation_at(
self.amesim_results,
-1,
)
self.assertEqual(set(front.velocities_m_s), set(range(1, 9)))
self.assertEqual(set(front.displacements_m), set(range(1, 9)))
self.assertAlmostEqual(front.velocities_m_s[1], -0.0)
self.assertAlmostEqual(front.displacements_m[1], -0.3700000000000001)
self.assertAlmostEqual(front.displacements_m[8], -0.3700000000000001)
self.assertAlmostEqual(front.total_force_n, 1083272.2763054767)
self.assertAlmostEqual(rear.velocities_m_s[1], 0.0)
self.assertAlmostEqual(rear.displacements_m[1], -0.72)
self.assertAlmostEqual(rear.displacements_m[8], -0.72)
self.assertAlmostEqual(rear.total_force_n, 1083272.2762592242)
if __name__ == "__main__":
unittest.main()
-245
View File
@@ -1,245 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.examples.test_mql.system import TestMqlSystem
from app.simulation.examples.test_mql.nodes import (
build_test_mql_node3_assembly,
build_test_mql_node4_assembly,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlPneumaticNode3Tests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.nodes = build_test_mql_node3_assembly()
cls.results = load_test_mql_amesim_results(TEST_MQL_AME)
def test_builds_all_eight_pn3node2_instances(self) -> None:
self.assertEqual(len(self.nodes), 8)
self.assertIn("pn_node3_9", self.nodes)
system = TestMqlSystem()
self.assertEqual(system.typed_node3_count, 8)
self.assertEqual(system.node3_assembly["pn_node3_9"].alias, "pn_node3_9")
def test_port_two_flow_is_exact_sum_of_ports_one_and_three(self) -> None:
balance = self.nodes["pn_node3_9"].balance(
port_2_temperature_k=281.0,
port_2_pressure_pa=4.4e6,
port_1_enthalpy_flow_w=2.0,
port_1_mass_flow_g_s=3.0,
port_3_enthalpy_flow_w=-0.5,
port_3_mass_flow_g_s=-1.0,
port_1_volume_derivative_l_min=4.0,
port_1_volume_cm3=5.0,
port_3_volume_derivative_l_min=-1.0,
port_3_volume_cm3=6.0,
)
self.assertEqual(balance.temperature_k, 281.0)
self.assertEqual(balance.pressure_pa, 4.4e6)
self.assertEqual(balance.port_2_enthalpy_flow_w, 1.5)
self.assertEqual(balance.port_2_mass_flow_g_s, 2.0)
self.assertEqual(balance.port_2_volume_derivative_l_min, 3.0)
self.assertEqual(balance.port_2_volume_cm3, 11.0)
def test_pn_node3_9_baseline_matches_adjacent_line_flow_sum(self) -> None:
index = 500
balance = self.nodes["pn_node3_9"].balance(
port_2_temperature_k=self.results.series("t1@pneumatic_88")[index],
port_2_pressure_pa=(
self.results.series("p1@pneumatic_88")[index] + 101_300.0
),
port_1_enthalpy_flow_w=self.results.series("dh1@pneumatic_100")[index],
port_1_mass_flow_g_s=self.results.series("dm1@pneumatic_100")[index],
port_3_enthalpy_flow_w=self.results.series("dh1@pneumatic_97")[index],
port_3_mass_flow_g_s=self.results.series("dm1@pneumatic_97")[index],
)
self.assertAlmostEqual(
balance.port_2_mass_flow_g_s,
self.results.series("dm2@pn_node3_9")[index],
)
self.assertAlmostEqual(
balance.port_2_enthalpy_flow_w,
self.results.series("dh2@pn_node3_9")[index],
)
self.assertEqual(self.results.series("vol2@pn_node3_9")[index], 0.0)
self.assertEqual(self.results.series("dvol2@pn_node3_9")[index], 0.0)
def test_rejects_nonphysical_primary_pressure_or_temperature(self) -> None:
node = self.nodes["pn_node3_9"]
with self.assertRaises(ValueError):
node.balance(
port_2_temperature_k=0.0,
port_2_pressure_pa=1.0e5,
port_1_enthalpy_flow_w=0.0,
port_1_mass_flow_g_s=0.0,
port_3_enthalpy_flow_w=0.0,
port_3_mass_flow_g_s=0.0,
)
class TestMqlPneumaticNode4Tests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.nodes = build_test_mql_node4_assembly()
cls.results = load_test_mql_amesim_results(TEST_MQL_AME)
def test_builds_all_eight_p4node2_instances(self) -> None:
self.assertEqual(len(self.nodes), 8)
self.assertIn("pnnode4_17", self.nodes)
system = TestMqlSystem()
self.assertEqual(system.typed_node4_count, 8)
self.assertEqual(system.node4_assembly["pnnode4_17"].alias, "pnnode4_17")
def test_pnnode4_17_neighborhood_maps_adjacent_p4_nodes(self) -> None:
system = TestMqlSystem()
neighborhood = system.p4_node_neighborhood("pnnode4_17")
self.assertEqual(neighborhood.node_alias, "pnnode4_17")
self.assertEqual(neighborhood.primary.line_alias, "pneumatic_68")
self.assertEqual(neighborhood.primary.chamber_alias, "pn_c1_9")
self.assertEqual(neighborhood.port_1.line_alias, "pneumatic_86")
self.assertEqual(neighborhood.port_1.remote_node_alias, "pnnode4_18")
self.assertEqual(neighborhood.port_1.remote_port, "port_3")
self.assertEqual(neighborhood.port_3.line_alias, "pneumatic_83")
self.assertEqual(neighborhood.port_3.remote_node_alias, "pnnode4_16")
self.assertEqual(neighborhood.port_3.remote_port, "port_1")
self.assertEqual(neighborhood.port_4.orifice_alias, "pn_morifice_9")
self.assertEqual(neighborhood.port_4.direct_line_alias, "pneumatic_90")
def test_adjacent_p4_node_primary_pressures_match_their_pnl0001_states(self) -> None:
index = 500
self.assertAlmostEqual(
self.results.series("press1@pnnode4_18")[index],
self.results.series("p2@pneumatic_66")[index],
)
self.assertAlmostEqual(
self.results.series("press1@pnnode4_16")[index],
self.results.series("p2@pneumatic_69")[index],
)
def test_adjacent_p4_node_baselines_match_adjacent_flow_sums(self) -> None:
index = 500
pnnode4_18 = self.nodes["pnnode4_18"].balance(
port_2_temperature_k=self.results.series("temp1@pnnode4_18")[index],
port_2_pressure_pa=(
self.results.series("press1@pnnode4_18")[index] + 101_300.0
),
port_1_enthalpy_flow_w=self.results.series("dh1@pneumatic_80")[index],
port_1_mass_flow_g_s=self.results.series("dm1@pneumatic_80")[index],
port_3_enthalpy_flow_w=self.results.series("dh2@pneumatic_86")[index],
port_3_mass_flow_g_s=self.results.series("dm2@pneumatic_86")[index],
port_4_enthalpy_flow_w=self.results.series("dh2@pn_morifice_10")[index],
port_4_mass_flow_g_s=self.results.series("dm2@pn_morifice_10")[index],
)
pnnode4_16 = self.nodes["pnnode4_16"].balance(
port_2_temperature_k=self.results.series("temp1@pnnode4_16")[index],
port_2_pressure_pa=(
self.results.series("press1@pnnode4_16")[index] + 101_300.0
),
port_1_enthalpy_flow_w=self.results.series("dh1@pneumatic_83")[index],
port_1_mass_flow_g_s=self.results.series("dm1@pneumatic_83")[index],
port_3_enthalpy_flow_w=self.results.series("dh2@pneumatic_95")[index],
port_3_mass_flow_g_s=self.results.series("dm2@pneumatic_95")[index],
port_4_enthalpy_flow_w=self.results.series("dh2@pn_morifice_1")[index],
port_4_mass_flow_g_s=self.results.series("dm2@pn_morifice_1")[index],
)
self.assertAlmostEqual(
pnnode4_18.port_2_mass_flow_g_s,
self.results.series("dm2@pnnode4_18")[index],
)
self.assertAlmostEqual(
pnnode4_18.port_2_enthalpy_flow_w,
self.results.series("dh2@pnnode4_18")[index],
)
self.assertAlmostEqual(
pnnode4_16.port_2_mass_flow_g_s,
self.results.series("dm2@pnnode4_16")[index],
)
self.assertAlmostEqual(
pnnode4_16.port_2_enthalpy_flow_w,
self.results.series("dh2@pnnode4_16")[index],
)
def test_port_two_flow_is_exact_sum_of_ports_one_three_and_four(self) -> None:
balance = self.nodes["pnnode4_17"].balance(
port_2_temperature_k=281.0,
port_2_pressure_pa=4.4e6,
port_1_enthalpy_flow_w=2.0,
port_1_mass_flow_g_s=3.0,
port_3_enthalpy_flow_w=-0.5,
port_3_mass_flow_g_s=-1.0,
port_4_enthalpy_flow_w=4.0,
port_4_mass_flow_g_s=5.0,
port_1_volume_derivative_l_min=4.0,
port_1_volume_cm3=5.0,
port_3_volume_derivative_l_min=-1.0,
port_3_volume_cm3=6.0,
port_4_volume_derivative_l_min=2.0,
port_4_volume_cm3=7.0,
)
self.assertEqual(balance.temperature_k, 281.0)
self.assertEqual(balance.pressure_pa, 4.4e6)
self.assertEqual(balance.port_2_enthalpy_flow_w, 5.5)
self.assertEqual(balance.port_2_mass_flow_g_s, 7.0)
self.assertEqual(balance.port_2_volume_derivative_l_min, 5.0)
self.assertEqual(balance.port_2_volume_cm3, 18.0)
def test_pnnode4_17_baseline_matches_adjacent_flow_sum(self) -> None:
index = 500
balance = self.nodes["pnnode4_17"].balance(
port_2_temperature_k=self.results.series("temp1@pnnode4_17")[index],
port_2_pressure_pa=(
self.results.series("press1@pnnode4_17")[index] + 101_300.0
),
port_1_enthalpy_flow_w=self.results.series("dh1@pneumatic_86")[index],
port_1_mass_flow_g_s=self.results.series("dm1@pneumatic_86")[index],
port_3_enthalpy_flow_w=self.results.series("dh2@pneumatic_83")[index],
port_3_mass_flow_g_s=self.results.series("dm2@pneumatic_83")[index],
port_4_enthalpy_flow_w=self.results.series("dh2@pn_morifice_9")[index],
port_4_mass_flow_g_s=self.results.series("dm2@pn_morifice_9")[index],
)
self.assertAlmostEqual(
balance.port_2_mass_flow_g_s,
self.results.series("dm2@pnnode4_17")[index],
)
self.assertAlmostEqual(
balance.port_2_enthalpy_flow_w,
self.results.series("dh2@pnnode4_17")[index],
)
self.assertEqual(self.results.series("vol2@pnnode4_17")[index], 0.0)
self.assertEqual(self.results.series("dvol2@pnnode4_17")[index], 0.0)
self.assertAlmostEqual(
self.results.series("press1@pnnode4_17")[index],
self.results.series("p2@pneumatic_68")[index],
)
def test_rejects_nonphysical_primary_pressure_or_temperature(self) -> None:
node = self.nodes["pnnode4_17"]
with self.assertRaises(ValueError):
node.balance(
port_2_temperature_k=0.0,
port_2_pressure_pa=1.0e5,
port_1_enthalpy_flow_w=0.0,
port_1_mass_flow_g_s=0.0,
port_3_enthalpy_flow_w=0.0,
port_3_mass_flow_g_s=0.0,
port_4_enthalpy_flow_w=0.0,
port_4_mass_flow_g_s=0.0,
)
if __name__ == "__main__":
unittest.main()
-95
View File
@@ -1,95 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.reporting.test_mql_comparison import compare_test_mql_series
from app.simulation.reporting.test_mql_observations import build_test_mql_observation_catalog
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlObservationCatalogTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
cls.catalog = build_test_mql_observation_catalog(cls.amesim_results)
def test_aggregates_observation_catalog_counts(self) -> None:
self.assertEqual(len(self.catalog.chambers.bindings), 12)
self.assertEqual(len(self.catalog.orifices.bindings), 16)
self.assertEqual(self.catalog.lines.line_count, 40)
self.assertEqual(self.catalog.mechanical.binding_count, 46)
self.assertEqual(self.catalog.binding_count, 114)
def test_reports_observed_data_paths_by_domain(self) -> None:
paths_by_domain = self.catalog.data_paths_by_domain()
self.assertEqual(set(paths_by_domain), {"chambers", "orifices", "lines", "mechanical"})
self.assertEqual(len(paths_by_domain["chambers"]), 100)
self.assertEqual(len(paths_by_domain["orifices"]), 104)
self.assertEqual(len(paths_by_domain["lines"]), 388)
self.assertEqual(len(paths_by_domain["mechanical"]), 266)
self.assertEqual(len(self.catalog.data_paths()), 858)
self.assertIn("press@pn_general_chamber", paths_by_domain["chambers"])
self.assertIn("dm1@pn_orifice_18", paths_by_domain["orifices"])
self.assertIn("pctr@pneumatic_80", paths_by_domain["lines"])
self.assertIn("tforce@dynamic_mechanical_node_alternative_2", paths_by_domain["mechanical"])
def test_all_observed_data_paths_exist_in_saved_amesim_results(self) -> None:
for data_path in self.catalog.data_paths():
with self.subTest(data_path=data_path):
self.assertIn(data_path, self.amesim_results.series_by_data_path)
def test_baseline_series_can_be_selected_for_comparison(self) -> None:
selected_paths = (
"press@pn_general_chamber",
"dm1@pn_orifice_18",
"dm1@pneumatic_69",
"vol1@pn_brp2_8",
"tforce@dynamic_mechanical_node_alternative_2",
)
baseline = self.catalog.baseline_series_by_data_path(
self.amesim_results,
selected_paths,
)
self.assertEqual(tuple(baseline), selected_paths)
self.assertEqual(len(baseline["press@pn_general_chamber"]), len(self.amesim_results.times))
self.assertAlmostEqual(baseline["vol1@pn_brp2_8"][-1], 34242.54636512914)
def test_baseline_series_rejects_unobserved_data_path(self) -> None:
with self.assertRaises(KeyError):
self.catalog.baseline_series_by_data_path(
self.amesim_results,
("not_a_real_signal@not_a_real_owner",),
)
def test_observation_baseline_round_trips_through_comparison_with_zero_error(self) -> None:
selected_paths = (
"press@pn_c1_8",
"dm2@pn_morifice_11",
"dmctr@pneumatic_75",
"x1@mass_friction_endstops_10",
)
baseline = self.catalog.baseline_series_by_data_path(
self.amesim_results,
selected_paths,
)
comparison = compare_test_mql_series(
python_times=self.amesim_results.times,
python_series_by_data_path=baseline,
amesim_results=self.amesim_results,
data_paths=selected_paths,
)
self.assertEqual(len(comparison.metrics), len(selected_paths))
self.assertEqual(comparison.max_abs_error, 0.0)
self.assertEqual(comparison.max_rel_error, 0.0)
if __name__ == "__main__":
unittest.main()
-110
View File
@@ -1,110 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.reporting.test_mql_orifice_observations import (
G_PER_S_TO_KG_PER_S,
build_test_mql_orifice_observation_catalog,
)
from app.simulation.examples.test_mql.pneumatic import (
TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlOrificeObservationCatalogTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
cls.catalog = build_test_mql_orifice_observation_catalog(cls.amesim_results)
def test_builds_expected_orifice_observation_bindings(self) -> None:
self.assertEqual(len(self.catalog.bindings), 16)
self.assertEqual(self.catalog.fixed_count, 8)
self.assertEqual(self.catalog.variable_count, 8)
fixed = self.catalog.by_alias("pn_orifice_18")
variable = self.catalog.by_alias("pn_morifice_11")
self.assertEqual(fixed.submodel, "PNOR001")
self.assertEqual(fixed.primary_mass_flow_path, "dm1@pn_orifice_18")
self.assertEqual(fixed.reversed_mass_flow_path, "dm2@pn_orifice_18")
self.assertEqual(fixed.primary_enthalpy_flow_path, "dh1@pn_orifice_18")
self.assertEqual(fixed.reversed_enthalpy_flow_path, "dh2@pn_orifice_18")
self.assertIsNone(fixed.opening_path)
self.assertEqual(variable.submodel, "PNVO001")
self.assertEqual(variable.primary_mass_flow_path, "dm2@pn_morifice_11")
self.assertEqual(variable.reversed_mass_flow_path, "dm3@pn_morifice_11")
self.assertEqual(variable.opening_path, "xv@pn_morifice_11")
def test_fixed_orifice_opening_defaults_to_full_area(self) -> None:
binding = self.catalog.by_alias("pn_orifice_18")
opening = binding.opening_series(self.amesim_results)
effective_area = binding.effective_area_series(self.amesim_results)
self.assertEqual(opening[0], 1.0)
self.assertEqual(opening[-1], 1.0)
self.assertAlmostEqual(effective_area[0], 78.5e-6)
self.assertAlmostEqual(effective_area[-1], 78.5e-6)
def test_variable_orifice_opening_controls_effective_area(self) -> None:
binding = self.catalog.by_alias("pn_morifice_11")
first = binding.observation_at(self.amesim_results, 0)
last = binding.observation_at(self.amesim_results, -1)
self.assertAlmostEqual(binding.nominal_area_m2, 78.5e-6)
self.assertAlmostEqual(
binding.flow_coefficient,
0.45 * TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER,
)
self.assertEqual(first.opening, 0.0)
self.assertEqual(first.effective_area_m2, 0.0)
self.assertEqual(last.opening, 1.0)
self.assertAlmostEqual(last.effective_area_m2, 78.5e-6)
def test_mass_flow_series_is_converted_to_si_units(self) -> None:
binding = self.catalog.by_alias("pn_orifice_18")
mass_flow = binding.mass_flow_kg_s_series(self.amesim_results)
self.assertAlmostEqual(
mass_flow[-1],
self.amesim_results.series("dm1@pn_orifice_18")[-1] * G_PER_S_TO_KG_PER_S,
)
self.assertAlmostEqual(mass_flow[-1], 9.097746783809915e-6)
self.assertAlmostEqual(min(mass_flow), -0.4462054699031737)
def test_reversed_duplicate_mass_and_enthalpy_flow_signs_match_amesim(self) -> None:
for binding in self.catalog.bindings:
primary_mass = self.amesim_results.series(binding.primary_mass_flow_path)
reversed_mass = self.amesim_results.series(binding.reversed_mass_flow_path)
primary_enthalpy = self.amesim_results.series(binding.primary_enthalpy_flow_path)
reversed_enthalpy = self.amesim_results.series(binding.reversed_enthalpy_flow_path)
with self.subTest(alias=binding.alias):
self.assertEqual(len(primary_mass), len(reversed_mass))
self.assertEqual(len(primary_enthalpy), len(reversed_enthalpy))
self.assertLess(
max(abs(a + b) for a, b in zip(primary_mass, reversed_mass)),
1.0e-12,
)
self.assertLess(
max(abs(a + b) for a, b in zip(primary_enthalpy, reversed_enthalpy)),
1.0e-9,
)
def test_observation_exposes_amesim_cm_and_gas_velocity(self) -> None:
fixed = self.catalog.by_alias("pn_orifice_18").observation_at(self.amesim_results, -1)
variable = self.catalog.by_alias("pn_morifice_11").observation_at(self.amesim_results, -1)
self.assertAlmostEqual(fixed.mass_flow_parameter, 4.831077004227076e-07)
self.assertAlmostEqual(fixed.gas_velocity_m_s, 0.016760630907703977)
self.assertAlmostEqual(variable.mass_flow_parameter, 9.302013872202105e-07)
self.assertAlmostEqual(variable.gas_velocity_m_s, -0.03277314382613749)
if __name__ == "__main__":
unittest.main()
-91
View File
@@ -1,91 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.reporting.test_mql_observations import build_test_mql_observation_catalog
from app.simulation.reporting.test_mql_output_schema import build_test_mql_output_schema
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlOutputSchemaTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
cls.observation_catalog = build_test_mql_observation_catalog(cls.amesim_results)
cls.schema = build_test_mql_output_schema(
cls.amesim_results,
observation_catalog=cls.observation_catalog,
)
def test_schema_contains_observed_output_data_paths(self) -> None:
self.assertEqual(self.schema.signal_count, 858)
self.assertEqual(self.schema.data_paths(), self.observation_catalog.data_paths())
self.assertEqual(len(set(self.schema.data_paths())), self.schema.signal_count)
def test_counts_by_domain_owner_kind_and_submodel(self) -> None:
self.assertEqual(
self.schema.counts_by_domain(),
{"chambers": 100, "lines": 388, "mechanical": 266, "orifices": 104},
)
self.assertEqual(
self.schema.counts_by_owner_kind(),
{"component": 470, "connection": 388},
)
self.assertEqual(self.schema.counts_by_submodel()["PNCH012"], 80)
self.assertEqual(self.schema.counts_by_submodel()["PNCH023"], 20)
self.assertEqual(self.schema.counts_by_submodel()["PNL0001"], 180)
self.assertEqual(self.schema.counts_by_submodel()["MECMAS21"], 110)
self.assertEqual(self.schema.counts_by_submodel()["LMECHN1"], 34)
def test_counts_by_units_preserve_amesim_units(self) -> None:
counts = self.schema.counts_by_units()
self.assertEqual(counts["m/s"], 108)
self.assertEqual(counts[None], 98)
self.assertEqual(counts["N"], 92)
self.assertEqual(counts["J/s"], 84)
self.assertEqual(counts["g/s"], 84)
self.assertEqual(counts["Pa"], 84)
self.assertEqual(counts["K"], 84)
self.assertEqual(counts["cm**3"], 16)
self.assertEqual(counts["L/min"], 8)
def test_typical_signal_metadata_is_available_by_data_path(self) -> None:
chamber = self.schema.by_data_path("press@pn_general_chamber")
line = self.schema.by_data_path("dm1@pneumatic_69")
mechanical = self.schema.by_data_path("tforce@dynamic_mechanical_node_alternative_2")
self.assertEqual(chamber.domain, "chambers")
self.assertEqual(chamber.owner_alias, "pn_general_chamber")
self.assertEqual(chamber.owner_kind, "component")
self.assertEqual(chamber.submodel, "PNCH023")
self.assertEqual(chamber.signal_name, "press")
self.assertEqual(chamber.units, "Pa")
self.assertTrue(chamber.saved)
self.assertEqual(line.domain, "lines")
self.assertEqual(line.owner_kind, "connection")
self.assertEqual(line.submodel, "PNL0001")
self.assertEqual(line.units, "g/s")
self.assertEqual(mechanical.domain, "mechanical")
self.assertEqual(mechanical.submodel, "LMECHN1")
self.assertEqual(mechanical.units, "N")
def test_data_paths_can_be_selected_by_domain(self) -> None:
self.assertEqual(len(self.schema.data_paths_by_domain("chambers")), 100)
self.assertEqual(len(self.schema.data_paths_by_domain("orifices")), 104)
self.assertEqual(len(self.schema.data_paths_by_domain("lines")), 388)
self.assertEqual(len(self.schema.data_paths_by_domain("mechanical")), 266)
self.assertEqual(self.schema.data_paths_by_domain("missing"), ())
def test_unknown_data_path_raises_key_error(self) -> None:
with self.assertRaises(KeyError):
self.schema.by_data_path("not_a_signal@not_an_owner")
if __name__ == "__main__":
unittest.main()
-181
View File
@@ -1,181 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.reporting.test_mql_observations import build_test_mql_observation_catalog
from app.simulation.reporting.test_mql_output_schema import build_test_mql_output_schema
from app.simulation.reporting.test_mql_output_validation import (
TestMqlOutputValidationError,
compare_validated_test_mql_output,
validate_test_mql_output,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlOutputValidationTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
cls.observation_catalog = build_test_mql_observation_catalog(cls.amesim_results)
cls.schema = build_test_mql_output_schema(
cls.amesim_results,
observation_catalog=cls.observation_catalog,
)
def test_validates_selected_python_output_series(self) -> None:
times = [0.0, 0.1, 0.2]
series = {
"press@pn_c1_8": [1.0, 2.0, 3.0],
"vol1@pn_brp2_8": [4, 5, 6],
}
validated = validate_test_mql_output(
times=times,
series_by_data_path=series,
schema=self.schema,
)
self.assertEqual(validated.times, (0.0, 0.1, 0.2))
self.assertEqual(validated.data_paths, ("press@pn_c1_8", "vol1@pn_brp2_8"))
self.assertEqual(validated.series("press@pn_c1_8"), (1.0, 2.0, 3.0))
self.assertEqual(validated.series("vol1@pn_brp2_8"), (4.0, 5.0, 6.0))
def test_can_validate_explicit_data_path_order(self) -> None:
validated = validate_test_mql_output(
times=(0.0, 0.1),
series_by_data_path={
"press@pn_c1_8": (10.0, 11.0),
"vol1@pn_brp2_8": (20.0, 21.0),
},
schema=self.schema,
data_paths=("vol1@pn_brp2_8", "press@pn_c1_8"),
)
self.assertEqual(validated.data_paths, ("vol1@pn_brp2_8", "press@pn_c1_8"))
def test_rejects_output_paths_outside_schema_by_default(self) -> None:
with self.assertRaisesRegex(TestMqlOutputValidationError, "outside test_mql schema"):
validate_test_mql_output(
times=(0.0,),
series_by_data_path={"not_a_signal@not_an_owner": (1.0,)},
schema=self.schema,
)
def test_can_ignore_extra_output_paths_when_requested(self) -> None:
validated = validate_test_mql_output(
times=(0.0,),
series_by_data_path={
"press@pn_c1_8": (1.0,),
"not_a_signal@not_an_owner": (2.0,),
},
schema=self.schema,
allow_extra_paths=True,
)
self.assertEqual(validated.data_paths, ("press@pn_c1_8",))
def test_rejects_missing_selected_data_path(self) -> None:
with self.assertRaisesRegex(TestMqlOutputValidationError, "missing selected"):
validate_test_mql_output(
times=(0.0,),
series_by_data_path={"press@pn_c1_8": (1.0,)},
schema=self.schema,
data_paths=("press@pn_c1_8", "vol1@pn_brp2_8"),
)
def test_rejects_incomplete_full_schema_output(self) -> None:
with self.assertRaisesRegex(TestMqlOutputValidationError, "missing required"):
validate_test_mql_output(
times=(0.0,),
series_by_data_path={"press@pn_c1_8": (1.0,)},
schema=self.schema,
require_all_schema_paths=True,
)
def test_rejects_length_mismatch(self) -> None:
with self.assertRaisesRegex(TestMqlOutputValidationError, "length mismatch"):
validate_test_mql_output(
times=(0.0, 0.1),
series_by_data_path={"press@pn_c1_8": (1.0,)},
schema=self.schema,
)
def test_rejects_non_finite_values(self) -> None:
with self.assertRaisesRegex(TestMqlOutputValidationError, "non-finite"):
validate_test_mql_output(
times=(0.0,),
series_by_data_path={"press@pn_c1_8": (float("nan"),)},
schema=self.schema,
)
def test_rejects_non_monotonic_time_axis(self) -> None:
with self.assertRaisesRegex(TestMqlOutputValidationError, "monotonically increasing"):
validate_test_mql_output(
times=(0.0, 0.2, 0.1),
series_by_data_path={"press@pn_c1_8": (1.0, 2.0, 3.0)},
schema=self.schema,
)
def test_compares_validated_amesim_baseline_with_zero_error(self) -> None:
selected_paths = (
"press@pn_c1_8",
"dm2@pn_morifice_11",
"x1@mass_friction_endstops_10",
)
baseline = self.observation_catalog.baseline_series_by_data_path(
self.amesim_results,
selected_paths,
)
comparison = compare_validated_test_mql_output(
times=self.amesim_results.times,
series_by_data_path=baseline,
schema=self.schema,
amesim_results=self.amesim_results,
data_paths=selected_paths,
)
self.assertEqual(len(comparison.metrics), len(selected_paths))
self.assertEqual(comparison.max_abs_error, 0.0)
self.assertEqual(comparison.max_rel_error, 0.0)
def test_compares_validated_output_and_reports_perturbation_error(self) -> None:
selected_paths = ("press@pn_c1_8",)
baseline = self.observation_catalog.baseline_series_by_data_path(
self.amesim_results,
selected_paths,
)
perturbed = dict(baseline)
values = list(perturbed["press@pn_c1_8"])
values[-1] += 10.0
perturbed["press@pn_c1_8"] = tuple(values)
comparison = compare_validated_test_mql_output(
times=self.amesim_results.times,
series_by_data_path=perturbed,
schema=self.schema,
amesim_results=self.amesim_results,
data_paths=selected_paths,
)
metric = comparison.metric("press@pn_c1_8")
self.assertAlmostEqual(metric.final_abs_error, 10.0)
self.assertAlmostEqual(metric.max_abs_error, 10.0)
def test_compare_validated_output_reuses_schema_validation(self) -> None:
with self.assertRaisesRegex(TestMqlOutputValidationError, "outside test_mql schema"):
compare_validated_test_mql_output(
times=(0.0,),
series_by_data_path={"not_a_signal@not_an_owner": (1.0,)},
schema=self.schema,
amesim_results=self.amesim_results,
)
if __name__ == "__main__":
unittest.main()
-137
View File
@@ -1,137 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.examples.test_mql.primitives.pneumatic import HELIUM_PNEUMATIC_GAS, cm3_to_m3
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.examples.test_mql.pneumatic import (
TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER,
absolute_pressure_from_amesim_bar_parameter,
build_test_mql_pneumatic_assembly,
pressure_from_amesim_bar_parameter,
pressure_to_amesim_gauge_pa,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlPneumaticAssemblyTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
def test_assembles_expected_amesim_pneumatic_component_counts(self) -> None:
assembly = build_test_mql_pneumatic_assembly()
self.assertEqual(len(assembly.fixed_chambers), 4)
self.assertEqual(len(assembly.variable_chambers), 8)
self.assertEqual(len(assembly.fixed_orifices), 8)
self.assertEqual(len(assembly.variable_orifices), 8)
self.assertEqual(assembly.component_count, 28)
self.assertIn("pn_general_chamber", assembly.aliases)
self.assertIn("pn_c1_8", assembly.aliases)
self.assertIn("pn_orifice_18", assembly.aliases)
self.assertIn("pn_morifice_11", assembly.aliases)
def test_uses_amesim_pressure_conventions(self) -> None:
assembly = build_test_mql_pneumatic_assembly()
self.assertAlmostEqual(absolute_pressure_from_amesim_bar_parameter(153.0), 15300000.0)
self.assertAlmostEqual(pressure_from_amesim_bar_parameter(153.0), 15198700.0)
self.assertAlmostEqual(pressure_to_amesim_gauge_pa(100000.0), -1300.0)
self.assertAlmostEqual(assembly.fixed_initial_absolute_pressure_pa, 15300000.0)
self.assertAlmostEqual(assembly.fixed_initial_gauge_pressure_pa, 15198700.0)
self.assertAlmostEqual(assembly.variable_initial_absolute_pressure_pa, 100000.0)
self.assertAlmostEqual(assembly.variable_initial_gauge_pressure_pa, -1300.0)
self.assertAlmostEqual(assembly.initial_pressure_pa, 15198700.0)
def test_chamber_parameters_are_resolved_in_si_units(self) -> None:
assembly = build_test_mql_pneumatic_assembly()
fixed_chamber = assembly.fixed_chambers["pn_general_chamber"]
variable_chamber = assembly.variable_chambers["pn_c1_8"]
self.assertAlmostEqual(fixed_chamber.volume, 0.057)
self.assertAlmostEqual(variable_chamber.dead_volume, 0.015)
self.assertAlmostEqual(variable_chamber.volume, 0.015)
self.assertAlmostEqual(variable_chamber.heat_transfer_coefficient, 1500.0)
self.assertAlmostEqual(variable_chamber.heat_transfer_area, 0.7)
self.assertAlmostEqual(variable_chamber.external_temperature, 293.15)
self.assertIs(fixed_chamber.gas, HELIUM_PNEUMATIC_GAS)
self.assertIs(variable_chamber.gas, HELIUM_PNEUMATIC_GAS)
self.assertAlmostEqual(fixed_chamber.properties().T, 293.15)
self.assertAlmostEqual(variable_chamber.properties().T, 293.15)
def test_orifice_parameters_are_resolved_in_si_units(self) -> None:
assembly = build_test_mql_pneumatic_assembly()
fixed_orifice = assembly.fixed_orifices["pn_orifice_18"]
variable_orifice = assembly.variable_orifices["pn_morifice_11"]
self.assertAlmostEqual(fixed_orifice.area, 78.5e-6)
self.assertAlmostEqual(fixed_orifice.flow_coefficient, 0.9)
self.assertAlmostEqual(variable_orifice.area, 78.5e-6)
self.assertAlmostEqual(
variable_orifice.flow_coefficient,
0.45 * TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER,
)
self.assertAlmostEqual(variable_orifice.opening, 0.0)
self.assertEqual(assembly.variable_orifice_control_count, 8)
control = assembly.variable_orifice_controls["pn_morifice_11"]
self.assertEqual(control.step.alias, "step_8")
self.assertAlmostEqual(control.opening_at(0.0), 0.0)
self.assertAlmostEqual(control.opening_at(0.039), 0.0)
self.assertAlmostEqual(control.opening_at(0.04), 1.0)
assembly.set_variable_orifice_openings(0.04)
self.assertAlmostEqual(variable_orifice.opening, 1.0)
def test_chamber_initial_observables_match_amesim_results(self) -> None:
assembly = build_test_mql_pneumatic_assembly()
fixed_chamber = assembly.fixed_chambers["pn_general_chamber"]
variable_chamber = assembly.variable_chambers["pn_c1_8"]
self.assertAlmostEqual(
fixed_chamber.pressure_gauge_pa(),
self.amesim_results.series("press@pn_general_chamber")[0],
delta=1.0e-8,
)
self.assertAlmostEqual(
variable_chamber.pressure_gauge_pa(),
self.amesim_results.series("press@pn_c1_8")[0],
delta=1.0e-8,
)
self.assertAlmostEqual(
variable_chamber.volume_cm3(),
self.amesim_results.series("vol@pn_c1_8")[0],
)
self.assertAlmostEqual(
variable_chamber.gas_mass_g(),
self.amesim_results.series("mgas1@pn_c1_8")[0],
delta=1.0e-4,
)
def test_variable_chamber_volume_matches_piston_volume_observable(self) -> None:
assembly = build_test_mql_pneumatic_assembly()
for chamber_alias, chamber in assembly.variable_chambers.items():
piston_alias = chamber_alias.replace("pn_c1", "pn_brp2")
with self.subTest(alias=chamber_alias):
piston_volume_cm3 = self.amesim_results.series(f"vol1@{piston_alias}")[-1]
chamber.set_external_volume_m3(cm3_to_m3(piston_volume_cm3))
self.assertAlmostEqual(
chamber.volume_cm3(),
self.amesim_results.series(f"vol@{chamber_alias}")[-1],
delta=1.0e-6,
)
def test_assembled_orifice_can_compute_helium_mass_flow(self) -> None:
assembly = build_test_mql_pneumatic_assembly()
fixed_orifice = assembly.fixed_orifices["pn_orifice_18"]
flow = fixed_orifice.mass_flow(15.0e6, 10.0e6, 293.15)
self.assertGreater(flow, 0.0)
if __name__ == "__main__":
unittest.main()
-115
View File
@@ -1,115 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.examples.test_mql.line_parameters import load_test_mql_pnl0001_specs
from app.simulation.examples.test_mql.pneumatic_lines import (
TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE,
build_test_mql_pnl0001_assembly,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlPnl0001Tests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.specs = load_test_mql_pnl0001_specs(TEST_MQL_AME)
cls.assembly = build_test_mql_pnl0001_assembly(TEST_MQL_AME)
cls.results = load_test_mql_amesim_results(TEST_MQL_AME)
def test_loads_all_real_pnl0001_parameters_from_cir(self) -> None:
self.assertEqual(len(self.specs), 20)
spec = self.assembly.spec("pneumatic_96")
self.assertEqual(spec.source_component, "pn_node3_8")
self.assertEqual(spec.target_component, "pn_orifice_18")
self.assertEqual(spec.diameter_mm, 14.0)
self.assertEqual(spec.length_m, 1.0)
self.assertAlmostEqual(spec.relative_roughness, 0.045 / 14.0)
self.assertEqual(spec.polytropic_constant, 1.35)
self.assertEqual(spec.heat_transfer_coefficient, 0.0)
self.assertEqual(spec.external_temperature_k, 293.15)
self.assertEqual(spec.gas_type_index, 1)
self.assertEqual(spec.mode, 2)
self.assertAlmostEqual(spec.initial_gauge_pressure_pa, 15_198_700.0)
self.assertAlmostEqual(spec.initial_absolute_pressure_pa, 15_300_000.0)
def test_builds_twenty_two_state_physical_line_components(self) -> None:
self.assertEqual(len(self.assembly.lines), 20)
self.assertTrue(
all(len(line.get_state_vector()) == 2 for line in self.assembly.lines.values())
)
def test_calibrates_primary_d20_chamber_lines_only(self) -> None:
calibrated_aliases = {
alias
for alias, line in self.assembly.lines.items()
if line.calibrated_linear_conductance is not None
}
self.assertEqual(
calibrated_aliases,
{
"pneumatic_65",
"pneumatic_66",
"pneumatic_68",
"pneumatic_69",
"pneumatic_71",
"pneumatic_72",
"pneumatic_73",
"pneumatic_74",
},
)
for alias in calibrated_aliases:
self.assertAlmostEqual(
self.assembly.lines[alias].calibrated_linear_conductance,
TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE,
)
self.assertIsNone(
self.assembly.lines["pneumatic_70"].calibrated_linear_conductance
)
self.assertIsNone(
self.assembly.lines["pneumatic_96"].calibrated_linear_conductance
)
def test_pneumatic_96_initial_observables_match_amesim_baseline(self) -> None:
pipe = self.assembly.lines["pneumatic_96"]
self.assertAlmostEqual(
pipe.properties().p - 101_300.0,
self.results.series("p2@pneumatic_96")[0],
delta=1.0e-5,
)
self.assertAlmostEqual(
pipe.properties().T,
self.results.series("t2@pneumatic_96")[0],
)
self.assertAlmostEqual(
pipe.gas_mass_g(),
self.results.series("mgas@pneumatic_96")[0],
delta=0.005,
)
def test_baseline_mass_balance_identifies_port_two_input_sign(self) -> None:
mass = self.results.series("mgas@pneumatic_96")
dm1 = self.results.series("dm1@pneumatic_96")
dm2_peer = self.results.series("dm2@pn_orifice_18")
index = 500
finite_difference = (mass[index] - mass[index - 1]) / (
self.results.times[index] - self.results.times[index - 1]
)
self.assertAlmostEqual(
finite_difference,
dm2_peer[index] - dm1[index],
delta=3.0e-5,
)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large. Load diff
-92
View File
@@ -1,92 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.examples.test_mql.system import TestMqlSystem
from app.simulation.examples.test_mql.line_parameters import load_test_mql_pnl0002_specs
from app.simulation.examples.test_mql.pneumatic_lines import build_test_mql_pnl0002_assembly
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlPnl0002Tests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.pnl0002_specs = load_test_mql_pnl0002_specs(TEST_MQL_AME)
cls.pnl0002_assembly = build_test_mql_pnl0002_assembly(TEST_MQL_AME)
cls.results = load_test_mql_amesim_results(TEST_MQL_AME)
def test_loads_all_real_pnl0002_parameters_from_cir(self) -> None:
self.assertEqual(len(self.pnl0002_specs), 8)
spec = self.pnl0002_assembly.spec("pneumatic_86")
self.assertEqual(spec.source_component, "pnnode4_17")
self.assertEqual(spec.source_port, "port_1")
self.assertEqual(spec.target_component, "pnnode4_18")
self.assertEqual(spec.target_port, "port_3")
self.assertEqual(spec.diameter_mm, 20.0)
self.assertEqual(spec.length_m, 2.0)
self.assertAlmostEqual(spec.relative_roughness, 0.045 / 20.0)
self.assertEqual(spec.gas_type_index, 1)
self.assertEqual(spec.mode, 2)
self.assertAlmostEqual(spec.initial_center_temperature_k, 293.15)
self.assertAlmostEqual(spec.initial_center_gauge_pressure_pa, -1300.0)
self.assertAlmostEqual(spec.initial_center_absolute_pressure_pa, 100_000.0)
def test_builds_pnl0002_center_compliance_line_components(self) -> None:
self.assertEqual(len(self.pnl0002_assembly.lines), 8)
self.assertTrue(
all(len(line.get_state_vector()) == 2 for line in self.pnl0002_assembly.lines.values())
)
def test_pneumatic_86_initial_observables_match_amesim_baseline(self) -> None:
pipe = self.pnl0002_assembly.lines["pneumatic_86"]
properties = pipe.properties()
self.assertAlmostEqual(
properties.p - 101_300.0,
self.results.series("pctr@pneumatic_86")[0],
delta=1.0e-6,
)
self.assertAlmostEqual(
properties.T,
self.results.series("tctr@pneumatic_86")[0],
delta=1.0e-12,
)
self.assertAlmostEqual(
pipe.gas_mass_g(),
self.results.series("mgas@pneumatic_86")[0],
delta=0.001,
)
def test_amesim_mass_balance_uses_opposite_saved_port_flow_signs(self) -> None:
index = 500
times = self.results.times
mass = self.results.series("mgas@pneumatic_86")
finite_difference_g_s = (
mass[index + 1] - mass[index - 1]
) / (times[index + 1] - times[index - 1])
saved_port_flow_g_s = (
self.results.series("dm1@pneumatic_86")[index]
+ self.results.series("dm2@pneumatic_86")[index]
)
self.assertAlmostEqual(
finite_difference_g_s,
-saved_port_flow_g_s,
delta=1.0e-5,
)
def test_system_exposes_real_pnl0002_assembly(self) -> None:
system = TestMqlSystem()
self.assertEqual(system.typed_pnl0002_line_count, 8)
self.assertIn("pneumatic_86", system.pnl0002_assembly.lines)
if __name__ == "__main__":
unittest.main()
-94
View File
@@ -1,94 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.examples.test_mql.system import TestMqlSystem
from app.simulation.examples.test_mql.line_parameters import (
load_test_mql_pnl0003_specs,
load_test_mql_pnl00r_specs,
)
from app.simulation.examples.test_mql.pneumatic_lines import (
build_test_mql_pnl0003_assembly,
build_test_mql_pnl00r_assembly,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlPnl0003AndPnl00rTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.pnl0003_specs = load_test_mql_pnl0003_specs(TEST_MQL_AME)
cls.pnl00r_specs = load_test_mql_pnl00r_specs(TEST_MQL_AME)
cls.pnl0003_assembly = build_test_mql_pnl0003_assembly(TEST_MQL_AME)
cls.pnl00r_assembly = build_test_mql_pnl00r_assembly(TEST_MQL_AME)
cls.results = load_test_mql_amesim_results(TEST_MQL_AME)
def test_loads_all_real_pnl0003_parameters_from_cir(self) -> None:
self.assertEqual(len(self.pnl0003_specs), 8)
spec = self.pnl0003_assembly.spec("pneumatic_88")
self.assertEqual(spec.source_component, "pn_node3_9")
self.assertEqual(spec.target_component, "pn_morifice_9")
self.assertEqual(spec.diameter_mm, 20.0)
self.assertEqual(spec.length_m, 0.3)
self.assertAlmostEqual(spec.relative_roughness, 0.045 / 20.0)
self.assertEqual(spec.gas_type_index, 1)
self.assertEqual(spec.mode, 2)
self.assertAlmostEqual(spec.initial_gauge_pressure_1_pa, 15_198_700.0)
self.assertAlmostEqual(spec.initial_gauge_pressure_2_pa, 15_198_700.0)
self.assertAlmostEqual(spec.initial_absolute_pressure_1_pa, 15_300_000.0)
self.assertAlmostEqual(spec.initial_absolute_pressure_2_pa, 15_300_000.0)
def test_loads_all_real_pnl00r_parameters_from_cir(self) -> None:
self.assertEqual(len(self.pnl00r_specs), 4)
spec = self.pnl00r_assembly.spec("pneumatic_100")
self.assertEqual(spec.source_component, "pn_node3_9")
self.assertEqual(spec.target_component, "pn_node3_10")
self.assertEqual(spec.diameter_mm, 14.0)
self.assertEqual(spec.length_m, 1.0)
self.assertAlmostEqual(spec.relative_roughness, 0.045 / 14.0)
self.assertEqual(spec.gas_type_index, 1)
def test_builds_pnl0003_and_pnl00r_physical_line_components(self) -> None:
self.assertEqual(len(self.pnl0003_assembly.lines), 8)
self.assertEqual(len(self.pnl00r_assembly.lines), 4)
self.assertTrue(
all(len(line.get_state_vector()) == 4 for line in self.pnl0003_assembly.lines.values())
)
def test_pneumatic_88_initial_observables_match_amesim_baseline(self) -> None:
pipe = self.pnl0003_assembly.lines["pneumatic_88"]
self.assertAlmostEqual(
pipe.properties_1().p - 101_300.0,
self.results.series("p1@pneumatic_88")[0],
delta=1.0e-5,
)
self.assertAlmostEqual(
pipe.properties_2().p - 101_300.0,
self.results.series("p2@pneumatic_88")[0],
delta=1.0e-5,
)
self.assertAlmostEqual(
pipe.gas_mass_g(),
self.results.series("mgas@pneumatic_88")[0],
delta=0.005,
)
def test_system_exposes_real_pnl0003_and_pnl00r_assemblies(self) -> None:
system = TestMqlSystem()
self.assertEqual(system.typed_pnl0003_line_count, 8)
self.assertEqual(system.typed_pnl00r_line_count, 4)
self.assertIn("pneumatic_88", system.pnl0003_assembly.lines)
self.assertIn("pneumatic_100", system.pnl00r_assembly.lines)
if __name__ == "__main__":
unittest.main()
-117
View File
@@ -1,117 +0,0 @@
from __future__ import annotations
import unittest
from app.simulation.examples.test_mql.primitives.pneumatic import (
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
AmesimVariablePneumaticVolume,
)
from app.simulation.examples.test_mql.system import GLOBAL_PARAMETERS, SUBMODEL_COUNTS, TestMqlSystem
class TestMqlStructuralTest(unittest.TestCase):
def test_snapshot_matches_amesim_archive_metadata(self) -> None:
snapshot = TestMqlSystem().snapshot()
self.assertEqual(snapshot.model_name, "test_mql")
self.assertEqual(snapshot.component_count, 117)
self.assertEqual(snapshot.connection_count, 84)
self.assertEqual(snapshot.continuous_state_count, 132)
self.assertEqual(snapshot.discrete_state_count, 24)
self.assertEqual(snapshot.global_parameters["P0"], "153")
self.assertEqual(snapshot.global_parameters["cf"], "0.45")
def test_network_preserves_component_and_connection_counts(self) -> None:
system = TestMqlSystem()
self.assertEqual(len(system.network.components), 117)
self.assertEqual(len(system.network.connections), 84)
self.assertIn("pn_brp2_8", system.network.components)
self.assertIn("pn_gas_data", system.network.components)
def test_network_uses_typed_pneumatic_components_where_available(self) -> None:
system = TestMqlSystem()
self.assertEqual(system.typed_pneumatic_component_count, 28)
self.assertIsInstance(
system.network.components["pn_general_chamber"],
AmesimPneumaticVolume,
)
self.assertIsInstance(
system.network.components["pn_c1_8"],
AmesimVariablePneumaticVolume,
)
self.assertIsInstance(
system.network.components["pn_orifice_18"],
AmesimPneumaticOrifice,
)
self.assertIsInstance(
system.network.components["pn_morifice_11"],
AmesimPneumaticOrifice,
)
self.assertEqual(len(system.pneumatic_state_vector()), 24)
def test_can_build_pneumatic_branch_closure_from_typed_components(self) -> None:
system = TestMqlSystem()
closure = system.pneumatic_branch_closure(
name="typed_branch",
upstream_volume_alias="pn_general_chamber",
orifice_alias="pn_orifice_18",
downstream_volume_alias="pn_c1_8",
)
snapshot = closure.snapshot()
self.assertGreater(snapshot.flow, 0.0)
self.assertIs(
closure.components.upstream_volume,
system.network.components["pn_general_chamber"],
)
self.assertIs(
closure.components.orifice,
system.network.components["pn_orifice_18"],
)
self.assertIs(
closure.components.downstream_volume,
system.network.components["pn_c1_8"],
)
self.assertAlmostEqual(
closure.components.upstream_volume.port_a.m_flow,
-snapshot.flow,
)
self.assertAlmostEqual(
closure.components.downstream_volume.port_a.m_flow,
snapshot.flow,
)
def test_pneumatic_branch_closure_rejects_structural_components(self) -> None:
system = TestMqlSystem()
with self.assertRaises(TypeError):
system.pneumatic_branch_closure(
name="invalid_branch",
upstream_volume_alias="pn_brp2_8",
orifice_alias="pn_orifice_18",
downstream_volume_alias="pn_c1_8",
)
def test_submodel_counts_keep_key_amesim_families(self) -> None:
self.assertEqual(SUBMODEL_COUNTS["PNRP17"], 8)
self.assertEqual(SUBMODEL_COUNTS["PNCH012"], 8)
self.assertEqual(SUBMODEL_COUNTS["PNOR001"], 8)
self.assertEqual(SUBMODEL_COUNTS["PNVO001"], 8)
self.assertEqual(SUBMODEL_COUNTS["PNGD00"], 1)
self.assertEqual(GLOBAL_PARAMETERS["V"], "15")
def test_structural_simulation_shape(self) -> None:
result = TestMqlSystem().simulate()
self.assertEqual(len(result.t), 101)
self.assertEqual(len(result.y), 132)
self.assertEqual(result.series["component_count"][0], 117.0)
self.assertEqual(result.series["connection_count"][0], 84.0)
if __name__ == "__main__":
unittest.main()
-45
View File
@@ -1,45 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.examples.test_mql.topology import load_test_mql_cir_topology
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlCirTopologyTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.topology = load_test_mql_cir_topology(TEST_MQL_AME)
def test_recovers_direct_component_contacts_omitted_from_line_specs(self) -> None:
self.assertEqual(len(self.topology.component_contacts), 54)
contacts = self.topology.contacts_for("pn_general_chamber")
self.assertEqual(len(contacts), 2)
self.assertEqual(
{
(
contact.port_for("pn_general_chamber"),
*contact.other_endpoint("pn_general_chamber"),
)
for contact in contacts
},
{
("port_1", "pn_orifice_19", "port_2"),
("port_2", "pn_orifice_18", "port_1"),
},
)
def test_component_contact_lookup_rejects_unrelated_alias(self) -> None:
contact = self.topology.contacts_for("pn_general_chamber")[0]
with self.assertRaises(KeyError):
contact.other_endpoint("not-an-endpoint")
if __name__ == "__main__":
unittest.main()
-72
View File
@@ -1,72 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
from app.simulation.reporting.test_mql_variables import (
build_test_mql_variable_catalog,
split_data_path,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlVariableCatalogTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME)
cls.catalog = build_test_mql_variable_catalog(cls.amesim_results)
def test_catalog_covers_all_amesim_data_paths(self) -> None:
self.assertEqual(self.catalog.data_path_count, 1100)
self.assertEqual(self.catalog.saved_data_path_count, 1100)
self.assertEqual(self.catalog.counts_by_owner_kind(), {"component": 712, "connection": 388})
def test_component_variable_mapping_preserves_alias_and_submodel(self) -> None:
variable = self.catalog.by_data_path("temp3@pn_c1_8")
self.assertEqual(variable.index, 0)
self.assertEqual(variable.signal_name, "temp3")
self.assertEqual(variable.owner_alias, "pn_c1_8")
self.assertEqual(variable.owner_kind, "component")
self.assertEqual(variable.submodel, "PNCH012")
self.assertEqual(variable.units, "K")
self.assertTrue(variable.saved)
def test_connection_variable_mapping_preserves_line_submodel(self) -> None:
variable = self.catalog.by_data_path("dm1@pneumatic_69")
self.assertEqual(variable.signal_name, "dm1")
self.assertEqual(variable.owner_alias, "pneumatic_69")
self.assertEqual(variable.owner_kind, "connection")
self.assertEqual(variable.submodel, "PNL0001")
self.assertEqual(variable.units, "g/s")
def test_counts_by_submodel_include_components_and_connections(self) -> None:
counts = self.catalog.counts_by_submodel()
self.assertEqual(counts["PNL0001"], 180)
self.assertEqual(counts["PNCH012"], 80)
self.assertEqual(counts["PNRP17"], 72)
self.assertEqual(counts["PNVO001"], 56)
self.assertEqual(counts["PNOR001"], 48)
self.assertEqual(counts["PNCH023"], 20)
def test_owner_lookup_returns_data_paths_for_component(self) -> None:
paths = self.catalog.data_paths_for_owner("pn_c1_8")
self.assertIn("temp3@pn_c1_8", paths)
self.assertIn("press3@pn_c1_8", paths)
self.assertEqual(len(paths), 10)
def test_split_data_path_rejects_invalid_values(self) -> None:
self.assertEqual(split_data_path("press3@pn_c1_8"), ("press3", "pn_c1_8"))
with self.assertRaises(ValueError):
split_data_path("press3")
if __name__ == "__main__":
unittest.main()
-642
View File
@@ -1,642 +0,0 @@
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._resistance_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()
-339
View File
@@ -1,339 +0,0 @@
from __future__ import annotations
from time import perf_counter
import unittest
from unittest.mock import patch
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.pipes import AmesimPnl00r
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.core.errors import RecoverableTrialStateError
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.solvers.stream import StreamSolveDiagnostics, StreamSolveError
from app.simulation.solvers.thermofluid import (
ThermofluidClosureError,
ThermofluidTransactionPlan,
)
from app.simulation.systems.generic import GenericFluidSystem
from app.simulation.systems.network import SimulationNetwork
def _pnl00r_system() -> tuple[GenericFluidSystem, AmesimPnl00r]:
medium = IdealGasMedium()
source = Cylinder(
"source",
medium,
V=0.02,
p0=500_000.0,
T0=310.0,
)
resistance = AmesimPnl00r("resistance", medium)
resistance._causal_test_seed = {"accepted": 7.0}
plug = AmesimPnpl01("plug")
network = SimulationNetwork("thermofluid-rollback")
for component in (source, resistance, plug):
network.add_component(component)
network.connect("source", "port_b", "resistance", "port_1")
network.connect("resistance", "port_2", "plug", "port_1")
return GenericFluidSystem(network), resistance
def _physical_values(system: GenericFluidSystem) -> tuple[float, ...]:
return tuple(
float(getattr(binding.state, binding.variable))
for binding in system._thermofluid_transaction_plan.port_value_bindings
)
class ThermofluidRecoveryTests(unittest.TestCase):
def test_nonconvergence_is_recoverable_diagnostic_and_fully_restored(self) -> None:
system, resistance = _pnl00r_system()
state = system.consistent_initial_state_vector()
system.rhs(0.01, state)
secondary = system._thermofluid_closure_plan.secondary_block_solvers[0]
target = resistance.port_1
expected_ports = _physical_values(system)
expected_connected_h = dict(resistance._connected_h)
expected_causal_seed = dict(resistance._causal_test_seed)
expected_owner_diagnostics = tuple(
owner.last_diagnostics
for owner in system._thermofluid_transaction_plan.diagnostic_owners
)
expected_last_algebraic = system._last_algebraic_diagnostics
expected_last_scope = system._last_algebraic_scope
expected_last_success = (
system._thermofluid_closure_diagnostics.as_dict()["lastSuccess"]
)
fake_diagnostics = AlgebraicSolveDiagnostics(
success=True,
message="forced divergent thermofluid pass",
evaluations=0,
pressure_scale=1.0,
flow_scale=1.0,
max_scaled_residual=0.0,
max_raw_residual=0.0,
)
call_count = 0
def divergent_solve(*, scale_context=None):
del scale_context
nonlocal call_count
call_count += 1
target.m_flow += 1.0
resistance._connected_h = {
"port_1": -float(call_count),
"port_2": -2.0 * float(call_count),
}
resistance._causal_test_seed["polluted"] = float(call_count)
return StreamBlockSolveResult(
diagnostics=(fake_diagnostics,),
scopes=(
system._thermofluid_closure_plan.secondary_component_groups[0],
),
used_global_fallback=False,
)
with patch.object(secondary, "solve", side_effect=divergent_solve):
with self.assertRaises(ThermofluidClosureError) as raised:
system.rhs(0.125, state)
error = raised.exception
self.assertIsInstance(error, RecoverableTrialStateError)
failure = error.diagnostics
self.assertEqual(call_count, 25)
self.assertEqual(failure.failed_rhs_time, 0.125)
self.assertEqual(failure.iterations, 25)
self.assertEqual(failure.failure_count, 1)
self.assertEqual(len(failure.delta_tail), 8)
self.assertEqual(
[item.iteration for item in failure.delta_tail],
list(range(18, 26)),
)
self.assertEqual(failure.max_delta, 1.0)
self.assertEqual(failure.scale, 25.0)
self.assertEqual(failure.tolerance, 25.0e-12)
self.assertIsNotNone(failure.worst_port)
assert failure.worst_port is not None
self.assertEqual(failure.worst_port.component, "resistance")
self.assertEqual(failure.worst_port.port, "port_1")
self.assertEqual(failure.worst_port.signed_delta, 1.0)
self.assertEqual(_physical_values(system), expected_ports)
self.assertEqual(resistance._connected_h, expected_connected_h)
self.assertEqual(resistance._causal_test_seed, expected_causal_seed)
self.assertEqual(
tuple(
owner.last_diagnostics
for owner in system._thermofluid_transaction_plan.diagnostic_owners
),
expected_owner_diagnostics,
)
self.assertIs(system._last_algebraic_diagnostics, expected_last_algebraic)
self.assertEqual(system._last_algebraic_scope, expected_last_scope)
self.assertTrue(system.pressure_flow_solver._causal_audit_required)
self.assertTrue(secondary._causal_audit_required)
outcomes = system._thermofluid_closure_diagnostics.as_dict()
self.assertEqual(outcomes["failureCount"], 1)
self.assertEqual(outcomes["lastSuccess"], expected_last_success)
self.assertEqual(outcomes["lastFailure"], failure.as_dict())
# A successful maintenance/postprocessing closure must not masquerade
# as the last successful integrator RHS.
system._close_current_state(0.5)
self.assertEqual(
system._thermofluid_closure_diagnostics.as_dict()["lastSuccess"],
expected_last_success,
)
# The restored seed is safe to replay at the failed time.
retried = system.rhs(0.125, state)
self.assertEqual(len(retried), len(state))
outcomes = system._thermofluid_closure_diagnostics.as_dict()
self.assertEqual(outcomes["failureCount"], 1)
self.assertEqual(outcomes["lastFailure"], failure.as_dict())
self.assertEqual(outcomes["lastSuccess"]["rhsTime"], 0.125)
def test_simulation_result_exposes_closure_and_transaction_diagnostics(self) -> None:
system, _resistance = _pnl00r_system()
result = system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=1.0e-4,
method="RK45",
max_step=1.0e-4,
),
sample_step=1.0e-4,
)
self.assertTrue(result.success)
closure = result.diagnostics["stream"]["thermofluidClosure"]
self.assertEqual(closure["failureCount"], 0)
self.assertIsNone(closure["lastFailure"])
self.assertIsNotNone(closure["lastSuccess"])
self.assertEqual(
closure["transaction"]["physicalPortValueSlotCount"],
20,
)
self.assertEqual(
closure["transaction"]["physicalFlowPortCount"],
4,
)
def test_eventless_generic_simulation_retries_a_recoverable_trial(self) -> None:
system, _resistance = _pnl00r_system()
self.assertFalse(system.mechanical_state_reducer.has_state_events)
self.assertEqual(system.signal_resolver.event_times(0.0, 1.0e-4), ())
original_rhs = system.rhs
failed_once = False
def fail_first_positive_trial(time, state):
nonlocal failed_once
if time > 0.0 and not failed_once:
failed_once = True
raise RecoverableTrialStateError("forced recoverable trial")
return original_rhs(time, state)
with patch.object(system, "rhs", side_effect=fail_first_positive_trial):
result = system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=1.0e-4,
method="RK45",
max_step=1.0e-4,
),
sample_step=1.0e-4,
)
self.assertTrue(failed_once)
self.assertTrue(result.success)
totals = result.diagnostics["integration"]["totals"]
self.assertEqual(totals["recoverableRetryCount"], 1)
self.assertEqual(totals["stateTransitionCount"], 0)
segment = result.diagnostics["integration"]["segments"][0]
self.assertEqual(len(segment["recoverableRetries"]), 1)
self.assertEqual(
segment["recoverableRetries"][0]["reason"],
"forced recoverable trial",
)
def test_other_closure_failures_restore_but_remain_nonrecoverable(self) -> None:
fake_algebraic_diagnostics = AlgebraicSolveDiagnostics(
success=False,
message="forced algebraic failure",
evaluations=1,
pressure_scale=1.0,
flow_scale=1.0,
max_scaled_residual=2.0,
max_raw_residual=2.0,
)
for failure_kind in ("stream", "secondaryAlgebraic"):
with self.subTest(failure_kind=failure_kind):
system, resistance = _pnl00r_system()
state = system.consistent_initial_state_vector()
system.rhs(0.01, state)
expected_ports = _physical_values(system)
expected_cache = dict(resistance._connected_h)
expected_success = (
system._thermofluid_closure_diagnostics.as_dict()[
"lastSuccess"
]
)
def pollute() -> None:
resistance.port_1.p = -9.0
resistance.port_1.m_flow = 99.0
resistance.port_1.h_outflow = -999.0
resistance._connected_h = {
"port_1": -1.0,
"port_2": -2.0,
}
if failure_kind == "stream":
def failed_stream(*, dynamic_ports_are_current=False):
del dynamic_ports_are_current
pollute()
diagnostics = StreamSolveDiagnostics(
converged=False,
iterations=100,
max_delta=1.0,
)
raise StreamSolveError("forced stream failure", diagnostics)
context = patch.object(
system.stream_resolver,
"solve",
side_effect=failed_stream,
)
expected_error = StreamSolveError
else:
secondary = (
system._thermofluid_closure_plan.secondary_block_solvers[0]
)
def failed_secondary(*, scale_context=None):
del scale_context
pollute()
raise AlgebraicSolveError(
"forced secondary failure",
fake_algebraic_diagnostics,
scope_kind="physicalIsland",
)
context = patch.object(
secondary,
"solve",
side_effect=failed_secondary,
)
expected_error = AlgebraicSolveError
with context:
with self.assertRaises(expected_error) as raised:
system.rhs(0.25, state)
self.assertNotIsInstance(
raised.exception,
RecoverableTrialStateError,
)
self.assertEqual(_physical_values(system), expected_ports)
self.assertEqual(resistance._connected_h, expected_cache)
outcomes = system._thermofluid_closure_diagnostics.as_dict()
self.assertEqual(outcomes["failureCount"], 0)
self.assertIsNone(outcomes["lastFailure"])
self.assertEqual(outcomes["lastSuccess"], expected_success)
def test_transaction_microbenchmark_stays_scoped_to_numeric_slots(self) -> None:
medium = IdealGasMedium()
network = SimulationNetwork("transaction-microbenchmark")
for index in range(120):
network.add_component(AmesimPnl00r(f"resistance_{index}", medium))
plan = ThermofluidTransactionPlan.compile(network)
diagnostics = plan.diagnostics()
self.assertEqual(diagnostics["physicalPortValueSlotCount"], 1_200)
self.assertEqual(diagnostics["streamAndCausalCacheSlotCount"], 120)
for _ in range(5):
plan.capture().restore()
repetitions = 200
started = perf_counter()
for _ in range(repetitions):
plan.capture().restore()
seconds_per_transaction = (perf_counter() - started) / repetitions
# This deliberately generous guard detects accidental full component/
# network deepcopy while remaining stable on slow CI workers.
self.assertLess(seconds_per_transaction, 0.01)
if __name__ == "__main__":
unittest.main()
-337
View File
@@ -1,337 +0,0 @@
from pathlib import Path
import os
import unittest
from unittest.mock import patch
import numpy as np
from app.main import compile_system_xml_network
from app.simulation.solvers.jacobian import ExactColumnsUnavailable
from app.simulation.solvers.mechanical import MechanicalConstraintGroup
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig
from app.simulation.solvers.tangent import compile_three_piston_tangent_provider
from app.simulation.systems.generic import (
ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE,
GenericFluidSystem,
_requested_ode_jacobian_mode,
)
from app.system_xml import validate_system_xml_document
TARGET_XML = Path("tests/data/test_mql-full-branches-01-04.xml")
TARGET_MASS_NAMES = (
"mass_friction_endstops_10",
"mass_friction_endstops_11",
"mass_friction_endstops_12",
)
class ThreePistonTangentCompilerTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
report = validate_system_xml_document(TARGET_XML.read_bytes())
assert report.valid
with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}):
cls.system = GenericFluidSystem(
compile_system_xml_network(report.document)
)
cls.initial_state = np.asarray(
cls.system.consistent_initial_state_vector(0.0),
dtype=float,
)
def setUp(self) -> None:
self.system.mechanical_state_reducer.reset_constraint_modes()
self.system.apply_state_vector(self.initial_state.tolist())
def _closed_primal(
self,
state: np.ndarray,
) -> tuple[dict[str, dict[str, float]], np.ndarray]:
self.system.apply_state_vector(state.tolist())
connected_h = self.system._close_current_state(0.0)
derivative = np.asarray(
self.system._state_derivatives(connected_h),
dtype=float,
)
return connected_h, derivative
def test_target_layout_is_resolved_from_owner_and_slot(self) -> None:
compilation = compile_three_piston_tangent_provider(self.system)
self.assertTrue(compilation.eligible, compilation.reason)
owner_slots: dict[str, tuple[int, int]] = {}
cursor = 0
for entry in self.system.mechanical_state_reducer.state_entries:
if isinstance(entry, MechanicalConstraintGroup):
owner_slots[entry.representative.name] = (cursor, cursor + 1)
cursor += 2
else:
cursor += entry.state_size
resolved = tuple(
sorted(
index
for name in TARGET_MASS_NAMES
for index in owner_slots[name]
)
)
self.assertEqual(compilation.columns, resolved)
# This is a fixture drift guard, not the provider's lookup mechanism.
self.assertEqual(resolved, (20, 21, 38, 39, 54, 55))
self.assertEqual(compilation.reached_assignment_count, 34)
def test_stale_primal_context_requests_typed_fallback(self) -> None:
compilation = compile_three_piston_tangent_provider(self.system)
self.assertTrue(compilation.eligible, compilation.reason)
assert compilation.provider is not None
with self.assertRaises(ExactColumnsUnavailable) as captured:
compilation.provider(
0.0,
self.initial_state.copy(),
compilation.columns,
)
self.assertEqual(captured.exception.reason, "stalePrimalContext")
def test_initial_contact_boundary_requests_numerical_columns(self) -> None:
compilation = compile_three_piston_tangent_provider(self.system)
self.assertTrue(compilation.eligible, compilation.reason)
assert compilation.provider is not None
connected_h, _derivative = self._closed_primal(
self.initial_state.copy()
)
compilation.provider.request_primal_capture()
compilation.provider.record_primal(
0.0,
self.initial_state,
connected_h,
)
with self.assertRaises(ExactColumnsUnavailable) as captured:
compilation.provider(
0.0,
self.initial_state.copy(),
compilation.columns,
)
self.assertEqual(
captured.exception.reason,
"contactMode:contact_mode_boundary",
)
def test_smooth_six_columns_match_full_rhs_centered_difference(self) -> None:
compilation = compile_three_piston_tangent_provider(self.system)
self.assertTrue(compilation.eligible, compilation.reason)
provider = compilation.provider
assert provider is not None
state = self.initial_state.copy()
# Move all three contacts away from gap==0 and all three PNL0001 laws
# away from equal-pressure/zero-flow. The selected columns themselves
# remain the six mechanical [v, x] seeds.
for branch in provider.branches:
chamber_offset, chamber_size = provider.state_offsets[
branch.chamber.name
]
self.assertEqual(chamber_size, 2)
state[chamber_offset] *= 1.01
state[branch.position_index] -= 1.0e-3
connected_h, _base = self._closed_primal(state)
provider.request_primal_capture()
provider.record_primal(0.0, state, connected_h)
exact = provider(0.0, state.copy(), compilation.columns)
numerical = np.empty_like(exact)
for local_column, state_index in enumerate(compilation.columns):
step = 1.0e-7 * max(abs(state[state_index]), 1.0)
lower = state.copy()
upper = state.copy()
lower[state_index] -= step
upper[state_index] += step
lower_rhs = np.asarray(
self.system.rhs(0.0, lower.tolist()),
dtype=float,
)
upper_rhs = np.asarray(
self.system.rhs(0.0, upper.tolist()),
dtype=float,
)
numerical[:, local_column] = (
upper_rhs - lower_rhs
) / (2.0 * step)
np.testing.assert_allclose(
exact,
numerical,
rtol=2.0e-6,
atol=1.0e-5,
)
for downstream_mass in (
"mass_friction_endstops_18",
"mass_friction_endstops_19",
):
offset, size = provider.state_offsets[downstream_mass]
self.assertEqual(size, 2)
# Causal force reach includes both remote masses, but this fixture
# holds their discrete constraints fixed, so final ODE rows are zero.
np.testing.assert_allclose(
exact[offset : offset + size, :],
numerical[offset : offset + size, :],
rtol=0.0,
atol=1.0e-12,
)
np.testing.assert_allclose(
exact[offset : offset + size, :],
0.0,
rtol=0.0,
atol=1.0e-12,
)
def test_disabled_causal_path_is_not_eligible(self) -> None:
solver = self.system.pressure_flow_solver
original = solver._causal_fast_path_environment_enabled
try:
solver._causal_fast_path_environment_enabled = False
compilation = compile_three_piston_tangent_provider(self.system)
finally:
solver._causal_fast_path_environment_enabled = original
self.assertFalse(compilation.eligible)
self.assertEqual(compilation.reason, "causalFastPathDisabled")
def test_semi_analytic_mode_is_explicit_opt_in(self) -> None:
with patch.dict(
os.environ,
{ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE: "semi-analytic"},
):
self.assertEqual(
_requested_ode_jacobian_mode(),
"semi-analytic",
)
with patch.dict(os.environ, {}, clear=True):
self.assertEqual(_requested_ode_jacobian_mode(), "scipy")
def test_generic_simulation_wires_exact_columns_and_cleans_provider(
self,
) -> None:
report = validate_system_xml_document(TARGET_XML.read_bytes())
assert report.valid and report.document is not None
with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}):
system = GenericFluidSystem(
compile_system_xml_network(report.document)
)
captured: dict[str, object] = {}
def fake_integrate_ode(**options) -> ODESolution:
captured.update(options)
captured["activeProvider"] = system._ode_tangent_provider
initial = [float(value) for value in options["initial_state"]]
stop = float(options["config"].t_stop)
return ODESolution(
t=[0.0, stop],
y=[[value, value] for value in initial],
success=True,
message="test",
)
with patch.dict(
os.environ,
{ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE: "semi-analytic"},
), patch(
"app.simulation.systems.generic.integrate_ode",
side_effect=fake_integrate_ode,
):
result = system.simulate(
SolveIVPConfig(t_stop=1.0e-3, method="BDF"),
sample_step=1.0e-3,
)
jacobian = captured["jac"]
self.assertIsNotNone(jacobian)
self.assertIsNotNone(captured["activeProvider"])
self.assertIsNone(system._ode_tangent_provider)
runtime = result.diagnostics["integration"]["jacobian"]
self.assertEqual(runtime["mode"], "semiAnalyticExactColumns")
self.assertEqual(runtime["effectiveMode"], "notEvaluated")
self.assertEqual(runtime["originalColorGroupCount"], 31)
# Generic wiring uses topology discovery rather than the legacy
# three-name wrapper. This fixture contains one additional supported
# branch, so all four branches are compiled automatically.
self.assertEqual(runtime["remainingColorGroupCount"], 24)
self.assertEqual(runtime["exactColumnCount"], 8)
def test_generic_simulation_ineligible_path_uses_native_scipy(self) -> None:
report = validate_system_xml_document(TARGET_XML.read_bytes())
assert report.valid and report.document is not None
with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}):
system = GenericFluidSystem(
compile_system_xml_network(report.document)
)
system.pressure_flow_solver._causal_fast_path_environment_enabled = False
captured: dict[str, object] = {}
def fake_integrate_ode(**options) -> ODESolution:
captured.update(options)
captured["activeProvider"] = system._ode_tangent_provider
initial = [float(value) for value in options["initial_state"]]
stop = float(options["config"].t_stop)
return ODESolution(
t=[0.0, stop],
y=[[value, value] for value in initial],
success=True,
message="test",
)
with patch.dict(
os.environ,
{ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE: "semi-analytic"},
), patch(
"app.simulation.systems.generic.integrate_ode",
side_effect=fake_integrate_ode,
):
result = system.simulate(
SolveIVPConfig(t_stop=1.0e-3, method="BDF"),
sample_step=1.0e-3,
)
self.assertIsNone(captured["jac"])
self.assertIsNone(captured["activeProvider"])
self.assertIsNone(system._ode_tangent_provider)
runtime = result.diagnostics["integration"]["jacobian"]
self.assertEqual(runtime["mode"], "scipySparseFiniteDifference")
self.assertEqual(
runtime["fallbackReason"],
"semiAnalytic:causalFastPathDisabled",
)
def test_generic_simulation_cleans_provider_after_integration_error(
self,
) -> None:
report = validate_system_xml_document(TARGET_XML.read_bytes())
assert report.valid and report.document is not None
with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}):
system = GenericFluidSystem(
compile_system_xml_network(report.document)
)
def fail_integration(**_options):
self.assertIsNotNone(system._ode_tangent_provider)
raise RuntimeError("injected integration failure")
with patch.dict(
os.environ,
{ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE: "semi-analytic"},
), patch(
"app.simulation.systems.generic.integrate_ode",
side_effect=fail_integration,
), self.assertRaisesRegex(RuntimeError, "injected integration failure"):
system.simulate(
SolveIVPConfig(t_stop=1.0e-3, method="BDF"),
sample_step=1.0e-3,
)
self.assertIsNone(system._ode_tangent_provider)
if __name__ == "__main__":
unittest.main()