Replace Python numerical kernels with native C execution
This commit is contained in:
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
@@ -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
|
||||
@@ -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
@@ -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,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"/>
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user