完善通用求解器回归与前端交互
- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
This commit is contained in:
1 parent
143e8dd309
commit
b435daecf2
65 files changed
+172271
-701
No files matched your search
@@ -0,0 +1,592 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "test_mql_8-production-0.2s-physical-state-v2",
|
||||
"caseId": "0.2s",
|
||||
"lane": "production",
|
||||
"sourceXmlSha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
|
||||
"approval": {
|
||||
"status": "approved",
|
||||
"approvedAt": "2026-08-17T14:11:10.904892+00:00",
|
||||
"basis": "Initial P0 bounded production baseline passed finite-series, checkpoint, signal-segment, mechanical-event, residual, audit, and fallback gates."
|
||||
},
|
||||
"provenance": {
|
||||
"sourceReport": {
|
||||
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-0.2.json",
|
||||
"sha256": "93baac975413703bebe940308ccd0de5dbd753f7c1c4fe015907315f112618a5",
|
||||
"generatedAt": "2026-08-17T14:11:10.904892+00:00",
|
||||
"metadataCompatibility": {
|
||||
"status": "current",
|
||||
"differences": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"physicalLayout": {
|
||||
"projectionCategories": [
|
||||
"state"
|
||||
],
|
||||
"stateKeys": [
|
||||
"amesim_mecmas21_1.a",
|
||||
"amesim_mecmas21_1.v",
|
||||
"amesim_mecmas21_1.x",
|
||||
"amesim_mecmas21_10.a",
|
||||
"amesim_mecmas21_10.v",
|
||||
"amesim_mecmas21_10.x",
|
||||
"amesim_mecmas21_2.a",
|
||||
"amesim_mecmas21_2.v",
|
||||
"amesim_mecmas21_2.x",
|
||||
"amesim_mecmas21_3.a",
|
||||
"amesim_mecmas21_3.v",
|
||||
"amesim_mecmas21_3.x",
|
||||
"amesim_mecmas21_4.a",
|
||||
"amesim_mecmas21_4.v",
|
||||
"amesim_mecmas21_4.x",
|
||||
"amesim_mecmas21_5.a",
|
||||
"amesim_mecmas21_5.v",
|
||||
"amesim_mecmas21_5.x",
|
||||
"amesim_mecmas21_6.a",
|
||||
"amesim_mecmas21_6.v",
|
||||
"amesim_mecmas21_6.x",
|
||||
"amesim_mecmas21_7.a",
|
||||
"amesim_mecmas21_7.v",
|
||||
"amesim_mecmas21_7.x",
|
||||
"amesim_mecmas21_8.a",
|
||||
"amesim_mecmas21_8.v",
|
||||
"amesim_mecmas21_8.x",
|
||||
"amesim_mecmas21_9.a",
|
||||
"amesim_mecmas21_9.v",
|
||||
"amesim_mecmas21_9.x",
|
||||
"amesim_pnch012_10.U",
|
||||
"amesim_pnch012_10.m",
|
||||
"amesim_pnch012_11.U",
|
||||
"amesim_pnch012_11.m",
|
||||
"amesim_pnch012_12.U",
|
||||
"amesim_pnch012_12.m",
|
||||
"amesim_pnch012_13.U",
|
||||
"amesim_pnch012_13.m",
|
||||
"amesim_pnch012_14.U",
|
||||
"amesim_pnch012_14.m",
|
||||
"amesim_pnch012_15.U",
|
||||
"amesim_pnch012_15.m",
|
||||
"amesim_pnch012_8.U",
|
||||
"amesim_pnch012_8.m",
|
||||
"amesim_pnch012_9.U",
|
||||
"amesim_pnch012_9.m",
|
||||
"amesim_pnch023_1.U",
|
||||
"amesim_pnch023_1.m",
|
||||
"amesim_pnch023_2.U",
|
||||
"amesim_pnch023_2.m",
|
||||
"amesim_pnch023_3.U",
|
||||
"amesim_pnch023_3.m",
|
||||
"amesim_pnch023_4.U",
|
||||
"amesim_pnch023_4.m",
|
||||
"amesim_pnl0001_1.U",
|
||||
"amesim_pnl0001_1.m",
|
||||
"amesim_pnl0001_10.U",
|
||||
"amesim_pnl0001_10.m",
|
||||
"amesim_pnl0001_11.U",
|
||||
"amesim_pnl0001_11.m",
|
||||
"amesim_pnl0001_13.U",
|
||||
"amesim_pnl0001_13.m",
|
||||
"amesim_pnl0001_14.U",
|
||||
"amesim_pnl0001_14.m",
|
||||
"amesim_pnl0001_15.U",
|
||||
"amesim_pnl0001_15.m",
|
||||
"amesim_pnl0001_16.U",
|
||||
"amesim_pnl0001_16.m",
|
||||
"amesim_pnl0001_17.U",
|
||||
"amesim_pnl0001_17.m",
|
||||
"amesim_pnl0001_18.U",
|
||||
"amesim_pnl0001_18.m",
|
||||
"amesim_pnl0001_19.U",
|
||||
"amesim_pnl0001_19.m",
|
||||
"amesim_pnl0001_2.U",
|
||||
"amesim_pnl0001_2.m",
|
||||
"amesim_pnl0001_20.U",
|
||||
"amesim_pnl0001_20.m",
|
||||
"amesim_pnl0001_4.U",
|
||||
"amesim_pnl0001_4.m",
|
||||
"amesim_pnl0001_5.U",
|
||||
"amesim_pnl0001_5.m",
|
||||
"amesim_pnl0001_7.U",
|
||||
"amesim_pnl0001_7.m",
|
||||
"amesim_pnl0001_9.U",
|
||||
"amesim_pnl0001_9.m",
|
||||
"amesim_pnl0002_1.U",
|
||||
"amesim_pnl0002_1.m",
|
||||
"amesim_pnl0002_2.U",
|
||||
"amesim_pnl0002_2.m",
|
||||
"amesim_pnl0002_3.U",
|
||||
"amesim_pnl0002_3.m",
|
||||
"amesim_pnl0002_4.U",
|
||||
"amesim_pnl0002_4.m",
|
||||
"amesim_pnl0002_5.U",
|
||||
"amesim_pnl0002_5.m",
|
||||
"amesim_pnl0002_6.U",
|
||||
"amesim_pnl0002_6.m",
|
||||
"amesim_pnl0002_7.U",
|
||||
"amesim_pnl0002_7.m",
|
||||
"amesim_pnl0002_8.U",
|
||||
"amesim_pnl0002_8.m",
|
||||
"amesim_pnl0003_1.U1",
|
||||
"amesim_pnl0003_1.U2",
|
||||
"amesim_pnl0003_1.m1",
|
||||
"amesim_pnl0003_1.m2",
|
||||
"amesim_pnl0003_2.U1",
|
||||
"amesim_pnl0003_2.U2",
|
||||
"amesim_pnl0003_2.m1",
|
||||
"amesim_pnl0003_2.m2",
|
||||
"amesim_pnl0003_3.U1",
|
||||
"amesim_pnl0003_3.U2",
|
||||
"amesim_pnl0003_3.m1",
|
||||
"amesim_pnl0003_3.m2",
|
||||
"amesim_pnl0003_4.U1",
|
||||
"amesim_pnl0003_4.U2",
|
||||
"amesim_pnl0003_4.m1",
|
||||
"amesim_pnl0003_4.m2",
|
||||
"amesim_pnl0003_5.U1",
|
||||
"amesim_pnl0003_5.U2",
|
||||
"amesim_pnl0003_5.m1",
|
||||
"amesim_pnl0003_5.m2",
|
||||
"amesim_pnl0003_6.U1",
|
||||
"amesim_pnl0003_6.U2",
|
||||
"amesim_pnl0003_6.m1",
|
||||
"amesim_pnl0003_6.m2",
|
||||
"amesim_pnl0003_7.U1",
|
||||
"amesim_pnl0003_7.U2",
|
||||
"amesim_pnl0003_7.m1",
|
||||
"amesim_pnl0003_7.m2",
|
||||
"amesim_pnl0003_8.U1",
|
||||
"amesim_pnl0003_8.U2",
|
||||
"amesim_pnl0003_8.m1",
|
||||
"amesim_pnl0003_8.m2"
|
||||
],
|
||||
"stateKeyLayoutSha256": "12e216303d33639a5f53ef83b6b9fc5851beffb1222a0ed4bb5e1a2432b3d739"
|
||||
},
|
||||
"tolerance": {
|
||||
"relative": 0.0002,
|
||||
"absolute": 1e-09,
|
||||
"checkpointTimeAbsoluteSeconds": 1e-12
|
||||
},
|
||||
"physicalCheckpoints": [
|
||||
{
|
||||
"requestedTime": 0.0,
|
||||
"values": [
|
||||
-0.8167936695810981,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
-0.8167936695810981,
|
||||
0.0,
|
||||
0.0,
|
||||
-0.8167936695810981,
|
||||
0.0,
|
||||
0.0,
|
||||
-0.8167936695810981,
|
||||
0.0,
|
||||
0.0,
|
||||
-0.8167936695810981,
|
||||
0.0,
|
||||
0.0,
|
||||
-0.8167936695810981,
|
||||
0.0,
|
||||
0.0,
|
||||
-0.8167936695810981,
|
||||
0.0,
|
||||
0.0,
|
||||
-0.8167936695810981,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
-1563.893548405841,
|
||||
0.002462855848963059,
|
||||
-1563.893548405841,
|
||||
0.002462855848963059,
|
||||
-1563.893548405841,
|
||||
0.002462855848963059,
|
||||
-1563.893548405841,
|
||||
0.002462855848963059,
|
||||
-1563.893548405841,
|
||||
0.002462855848963059,
|
||||
-1563.893548405841,
|
||||
0.002462855848963059,
|
||||
-1563.893548405841,
|
||||
0.002462855848963059,
|
||||
-1563.893548405841,
|
||||
0.002462855848963059,
|
||||
-886868.3937230138,
|
||||
1.374510850335198,
|
||||
-886868.3937230138,
|
||||
1.374510850335198,
|
||||
-886868.3937230138,
|
||||
1.374510850335198,
|
||||
-886868.3937230138,
|
||||
1.374510850335198,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-32.754109884455104,
|
||||
5.158193227968668e-05,
|
||||
-32.754109884455104,
|
||||
5.158193227968668e-05,
|
||||
-32.754109884455104,
|
||||
5.158193227968668e-05,
|
||||
-32.754109884455104,
|
||||
5.158193227968668e-05,
|
||||
-32.754109884455104,
|
||||
5.158193227968668e-05,
|
||||
-32.754109884455104,
|
||||
5.158193227968668e-05,
|
||||
-32.754109884455104,
|
||||
5.158193227968668e-05,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-32.754109884455104,
|
||||
5.158193227968668e-05,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-65.50821976891021,
|
||||
0.00010316386455937335,
|
||||
-65.50821976891021,
|
||||
0.00010316386455937335,
|
||||
-65.50821976891021,
|
||||
0.00010316386455937335,
|
||||
-65.50821976891021,
|
||||
0.00010316386455937335,
|
||||
-65.50821976891021,
|
||||
0.00010316386455937335,
|
||||
-65.50821976891021,
|
||||
0.00010316386455937335,
|
||||
-65.50821976891021,
|
||||
0.00010316386455937335,
|
||||
-65.50821976891021,
|
||||
0.00010316386455937335,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627
|
||||
]
|
||||
},
|
||||
{
|
||||
"requestedTime": 0.04,
|
||||
"values": [
|
||||
-0.7201778435512728,
|
||||
-0.03131260841232088,
|
||||
-0.0006393186923261844,
|
||||
0.0,
|
||||
6.421450189245049e-22,
|
||||
0.0,
|
||||
-0.7201733833792814,
|
||||
-0.031312355811718325,
|
||||
-0.0006393136520185212,
|
||||
-0.7201718196391171,
|
||||
-0.03131233882672457,
|
||||
-0.0006393140197282858,
|
||||
-0.7201722508603697,
|
||||
-0.031312278047383596,
|
||||
-0.0006393124896720936,
|
||||
-0.7201744791266093,
|
||||
-0.03131218496519997,
|
||||
-0.0006393087827851101,
|
||||
-0.7201783488656359,
|
||||
-0.031314094334835896,
|
||||
-0.0006393469119830168,
|
||||
-0.7201775030345068,
|
||||
-0.031312389100822,
|
||||
-0.0006393118191591414,
|
||||
-0.7201771803079525,
|
||||
-0.031312911030261764,
|
||||
-0.0006393266351878673,
|
||||
0.0,
|
||||
0.0,
|
||||
1.120876769981429e-26,
|
||||
-1563.233367712832,
|
||||
0.0024627146762544114,
|
||||
-1563.2343003288415,
|
||||
0.0024627154840700283,
|
||||
-1563.2343310045646,
|
||||
0.002462715544028645,
|
||||
-1563.234317887853,
|
||||
0.002462715540851028,
|
||||
-1563.2342832229026,
|
||||
0.0024627154834822402,
|
||||
-1563.234128076657,
|
||||
0.0024627152649548067,
|
||||
-1563.2340241256454,
|
||||
0.0024627151889310217,
|
||||
-1563.234154194445,
|
||||
0.0024627153106296135,
|
||||
-886868.3937230138,
|
||||
1.374510850335198,
|
||||
-886868.3937230138,
|
||||
1.374510850335198,
|
||||
-886868.3937230138,
|
||||
1.374510850335198,
|
||||
-886868.3937230138,
|
||||
1.374510850335198,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-32.755346551358684,
|
||||
5.162917551035796e-05,
|
||||
-32.7553713095164,
|
||||
5.162919361109966e-05,
|
||||
-32.755371520495,
|
||||
5.1629194498694165e-05,
|
||||
-32.7553740517406,
|
||||
5.162919592464222e-05,
|
||||
-32.75549815317228,
|
||||
5.16292749500248e-05,
|
||||
-32.7557671078149,
|
||||
5.1629447239129814e-05,
|
||||
-32.755537600705786,
|
||||
5.162929920389579e-05,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-32.7554490658762,
|
||||
5.1629242163634275e-05,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-65.50992369305769,
|
||||
0.0001032573549102297,
|
||||
-65.50924422087368,
|
||||
0.00010325691856038897,
|
||||
-65.50924420634506,
|
||||
0.00010325691908177892,
|
||||
-65.50924789343988,
|
||||
0.00010325692011611614,
|
||||
-65.50952699017678,
|
||||
0.00010325709663260638,
|
||||
-65.50953538930851,
|
||||
0.00010325710204152489,
|
||||
-65.50932045418728,
|
||||
0.00010325696357174907,
|
||||
-65.50995769649337,
|
||||
0.00010325737519785258,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
0.0011363561025506627
|
||||
]
|
||||
},
|
||||
{
|
||||
"requestedTime": 0.2,
|
||||
"values": [
|
||||
2.6370963314548135e-06,
|
||||
1.1907436243229137e-06,
|
||||
1.5914303294371521e-07,
|
||||
0.0,
|
||||
6.530827087152731e-22,
|
||||
6.105198566652894e-27,
|
||||
2.6364088989794256e-06,
|
||||
1.1908086349821896e-06,
|
||||
1.5912507766913015e-07,
|
||||
2.636304998304695e-06,
|
||||
1.1908227907789107e-06,
|
||||
1.5912158717247314e-07,
|
||||
2.6363827055320143e-06,
|
||||
1.1908285194387939e-06,
|
||||
1.5912015990557016e-07,
|
||||
2.6363664073869586e-06,
|
||||
1.1908301556228963e-06,
|
||||
1.5911975015798518e-07,
|
||||
2.6362371863797307e-06,
|
||||
1.1908285541103296e-06,
|
||||
1.5912015181363026e-07,
|
||||
2.6363282813690604e-06,
|
||||
1.190822777157153e-06,
|
||||
1.5912159035091038e-07,
|
||||
2.636463905218989e-06,
|
||||
1.1908086605158301e-06,
|
||||
1.5912507172531535e-07,
|
||||
0.0,
|
||||
1.409251022153514e-21,
|
||||
2.583112155689631e-23,
|
||||
-21824.884876930104,
|
||||
0.06638877583716687,
|
||||
-21824.8394513789,
|
||||
0.06638874206868575,
|
||||
-21824.88503639051,
|
||||
0.06638877594459791,
|
||||
-21825.04659229254,
|
||||
0.06638889465525798,
|
||||
-21825.449220760413,
|
||||
0.06638918449710254,
|
||||
-21860.3893940624,
|
||||
0.06641207557540514,
|
||||
-21825.44912779144,
|
||||
0.06638918443617388,
|
||||
-21825.046674584086,
|
||||
0.06638889470996256,
|
||||
-877968.2311707166,
|
||||
1.2399623843859275,
|
||||
-877968.2314488764,
|
||||
1.239962386668905,
|
||||
-877968.231641228,
|
||||
1.2399623882475983,
|
||||
-877968.2313630553,
|
||||
1.23996238596452,
|
||||
-2232.5529371119105,
|
||||
0.0031580856642891347,
|
||||
-2232.552938579765,
|
||||
0.003158085669643602,
|
||||
-2232.552934122746,
|
||||
0.0031580856635922043,
|
||||
-1392.944729430734,
|
||||
0.002032422883230558,
|
||||
-1391.7199124494437,
|
||||
0.0020316357664157303,
|
||||
-1391.7199702457754,
|
||||
0.002031635646355635,
|
||||
-1391.7199593465837,
|
||||
0.00203163554240735,
|
||||
-1391.7199471015297,
|
||||
0.0020316355018333084,
|
||||
-1391.719956464844,
|
||||
0.0020316355405555213,
|
||||
-1391.719971723481,
|
||||
0.0020316356473075348,
|
||||
-2232.5529379989352,
|
||||
0.0031580856654872167,
|
||||
-1391.7199093676563,
|
||||
0.0020316357644361133,
|
||||
-2232.552933678534,
|
||||
0.003158085664480916,
|
||||
-2232.552938069336,
|
||||
0.0031580856704486304,
|
||||
-2232.5529342591235,
|
||||
0.003158085668637221,
|
||||
-2232.5529350799816,
|
||||
0.003158085669751649,
|
||||
-145.14613577820415,
|
||||
0.0023684760609916228,
|
||||
-234.44464213523725,
|
||||
0.002425822913152506,
|
||||
-234.44208866732288,
|
||||
0.0024258210179891326,
|
||||
-234.43920235483773,
|
||||
0.0024258190341378137,
|
||||
-234.43948538748822,
|
||||
0.0024258192159024635,
|
||||
-234.4423567512829,
|
||||
0.0024258211901560865,
|
||||
-234.44466632040084,
|
||||
0.002425822928687371,
|
||||
-145.14616131190638,
|
||||
0.0023684760773897006,
|
||||
-665.721080447237,
|
||||
-664.9052921895398,
|
||||
0.0009423231603427254,
|
||||
0.0009411988264117098,
|
||||
-665.721080812701,
|
||||
-664.9052927495628,
|
||||
0.0009423231608531282,
|
||||
0.0009411988270450505,
|
||||
-665.7210807465625,
|
||||
-664.9052926367955,
|
||||
0.0009423231622138283,
|
||||
0.0009411988283754829,
|
||||
-665.7210793863333,
|
||||
-664.9052912142575,
|
||||
0.0009423231613560865,
|
||||
0.0009411988274784932,
|
||||
-665.7210790083244,
|
||||
-664.9052908966471,
|
||||
0.0009423231608560905,
|
||||
0.0009411988270168083,
|
||||
-665.721081019834,
|
||||
-664.9052928469027,
|
||||
0.0009423231621245239,
|
||||
0.0009411988282463315,
|
||||
-665.7210790496954,
|
||||
-664.9052910102263,
|
||||
0.0009423231594614548,
|
||||
0.0009411988256686023,
|
||||
-665.7210788464794,
|
||||
-664.9052906188476,
|
||||
0.0009423231596132578,
|
||||
0.0009411988257017965
|
||||
]
|
||||
}
|
||||
],
|
||||
"outputContract": {
|
||||
"sha256": "3a5cdcf25a6b841f7963044c5ecd1e8a3744fac98000eda53df2956ad0ac6506"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "test_mql_8-progressive-v2",
|
||||
"description": "Primary generic-solver regression target for the renamed 0.01 s-grid authoritative inputs, with a short smoke gate before staged extension.",
|
||||
"source": {
|
||||
"path": "tests/data/test-mql-8.xml",
|
||||
"sha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
|
||||
"bytes": 101013,
|
||||
"xmlIsAuthoritative": true,
|
||||
"companionProject": {
|
||||
"path": "tests/data/test-mql-8.json",
|
||||
"sha256": "258c50ee4850baa72fb7c2cc24536d0a631fc6a7f1fa6cedb7b6eea7c857cbaa",
|
||||
"bytes": 264675,
|
||||
"executionInput": false
|
||||
},
|
||||
"simulation": {
|
||||
"tStart": 0.0,
|
||||
"tStop": 0.2,
|
||||
"sampleStep": 0.01,
|
||||
"maxStep": 0.01,
|
||||
"method": "BDF"
|
||||
}
|
||||
},
|
||||
"historicalReports": [
|
||||
{
|
||||
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-solver-only-v1.json",
|
||||
"sha256": "ed0e8514c5fc083af6d403270eb0617fc20672ebb96460c355757d28d7059d0f",
|
||||
"sourceXmlSha256": "42e2d6277d39bd5990cb322243bec8d71c40ff0696b440db3c8623f2ab15c2ee",
|
||||
"status": "historicalOnly",
|
||||
"compatibleWithCurrentSource": false,
|
||||
"reason": "This report used the former spaced filename and 0.002 s sampleStep/maxStep. It is retained only as historical performance evidence and must never seed a v2 golden."
|
||||
},
|
||||
{
|
||||
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-extension-decision.json",
|
||||
"sha256": "d067bab69418ec21a11481f4a2c1fcb1ba1fc9528faeeb163ea5f466c2e2987f",
|
||||
"sourceXmlSha256": "42e2d6277d39bd5990cb322243bec8d71c40ff0696b440db3c8623f2ab15c2ee",
|
||||
"status": "historicalOnly",
|
||||
"compatibleWithCurrentSource": false,
|
||||
"reason": "This deferred-stage decision was calculated from the former 0.002 s source report and is not a runtime prediction for the renamed v2 source."
|
||||
}
|
||||
],
|
||||
"structure": {
|
||||
"componentCount": 152,
|
||||
"connectionCount": 174,
|
||||
"dynamicComponentCount": 54,
|
||||
"stateCount": 124,
|
||||
"resultVariableCount": 1716,
|
||||
"pressureFlowUnknownCount": 760,
|
||||
"pressureFlowEquationCount": 760,
|
||||
"pressureFlowIsSquare": true,
|
||||
"logicalEffortCoordinateCount": 112,
|
||||
"eliminatedEffortAliasCount": 320,
|
||||
"canonicalCoordinateCount": 440,
|
||||
"compatibilityScatterCount": 760,
|
||||
"jacobianNonzeroCount": 3296,
|
||||
"jacobianColorGroupCount": 52,
|
||||
"hasMechanicalStateEvents": true
|
||||
},
|
||||
"sequence": [
|
||||
"0.01s-smoke",
|
||||
"0.2s",
|
||||
"1s",
|
||||
"5s",
|
||||
"10s"
|
||||
],
|
||||
"lanes": {
|
||||
"solver-only": {
|
||||
"description": "Algorithm iteration and staged-extension lane: same XML and method, with an in-memory 0.02 s output grid and 0.05 s maximum step; signal breakpoints remain explicit solver segments.",
|
||||
"samplingMode": "fixed",
|
||||
"sampleStep": 0.02,
|
||||
"maxStepMode": "fixed",
|
||||
"maxStep": 0.05,
|
||||
"instrumentationMode": "standard",
|
||||
"productionEquivalentOutput": false
|
||||
},
|
||||
"production": {
|
||||
"description": "Acceptance lane: changes only tStop and preserves the source 0.01 s output and internal-step grid.",
|
||||
"samplingMode": "source",
|
||||
"maxStepMode": "source",
|
||||
"instrumentationMode": "standard",
|
||||
"productionEquivalentOutput": true
|
||||
}
|
||||
},
|
||||
"variants": {
|
||||
"0.01s-smoke": {
|
||||
"stopTime": 0.01,
|
||||
"softTimeoutSeconds": 60.0,
|
||||
"hardTimeoutSeconds": 90.0,
|
||||
"useForRuntimePrediction": false,
|
||||
"checkpointTimes": [
|
||||
0.0,
|
||||
0.01
|
||||
],
|
||||
"expectedSignalEventTimes": []
|
||||
},
|
||||
"0.2s": {
|
||||
"stopTime": 0.2,
|
||||
"softTimeoutSeconds": 300.0,
|
||||
"hardTimeoutSeconds": 360.0,
|
||||
"checkpointTimes": [
|
||||
0.0,
|
||||
0.04,
|
||||
0.2
|
||||
],
|
||||
"expectedSignalEventTimes": [
|
||||
0.04
|
||||
],
|
||||
"expectedMechanicalTransitionTimes": [],
|
||||
"goldens": {
|
||||
"production": {
|
||||
"path": "tests/baselines/simulation/test_mql_8/goldens/production-0.2s-v1.json",
|
||||
"sha256": "99fa7b3631a89f59f86551847175f3701a1567d8d0bd10d87545734f17515d72"
|
||||
}
|
||||
}
|
||||
},
|
||||
"1s": {
|
||||
"stopTime": 1.0,
|
||||
"softTimeoutSeconds": 1200.0,
|
||||
"hardTimeoutSeconds": 1260.0,
|
||||
"checkpointTimes": [
|
||||
0.0,
|
||||
0.04,
|
||||
0.8,
|
||||
1.0
|
||||
],
|
||||
"expectedSignalEventTimes": [
|
||||
0.04,
|
||||
0.8
|
||||
]
|
||||
},
|
||||
"5s": {
|
||||
"stopTime": 5.0,
|
||||
"softTimeoutSeconds": 2700.0,
|
||||
"hardTimeoutSeconds": 2760.0,
|
||||
"checkpointTimes": [
|
||||
0.0,
|
||||
0.04,
|
||||
0.8,
|
||||
1.0,
|
||||
2.0,
|
||||
5.0
|
||||
],
|
||||
"expectedSignalEventTimes": [
|
||||
0.04,
|
||||
0.8
|
||||
]
|
||||
},
|
||||
"10s": {
|
||||
"stopTime": 10.0,
|
||||
"softTimeoutSeconds": 5400.0,
|
||||
"hardTimeoutSeconds": 5460.0,
|
||||
"checkpointTimes": [
|
||||
0.0,
|
||||
0.04,
|
||||
0.8,
|
||||
1.0,
|
||||
2.0,
|
||||
5.0,
|
||||
10.0
|
||||
],
|
||||
"expectedSignalEventTimes": [
|
||||
0.04,
|
||||
0.8
|
||||
]
|
||||
}
|
||||
},
|
||||
"correctness": {
|
||||
"status": "partial",
|
||||
"maximumScaledResidual": 1e-7,
|
||||
"requireFiniteSeries": true,
|
||||
"requireStrictlyIncreasingTimes": true,
|
||||
"physicalProjectionCategories": [
|
||||
"state"
|
||||
],
|
||||
"stateRelativeTolerance": 0.0002,
|
||||
"stateAbsoluteTolerance": 1e-9,
|
||||
"checkpointTimeAbsoluteToleranceSeconds": 1e-12,
|
||||
"eventTimeAbsoluteToleranceSeconds": 0.00002,
|
||||
"signalEventTimeAbsoluteToleranceSeconds": 1e-12,
|
||||
"mechanicalTransitionTimesAvailable": true,
|
||||
"note": "Physical state checkpoints/events and the value-free output-shape contract are separate. The production 0.2 s variant has an approved state golden; longer horizons and critical algebraic projections remain pending."
|
||||
},
|
||||
"execution": {
|
||||
"causalExecutorV2Default": true,
|
||||
"environment": {
|
||||
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
|
||||
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
|
||||
"SIMULATION_CAUSAL_FAST_PATH": "1",
|
||||
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
|
||||
"SIMULATIONAPP_PROPERTY_CACHE": "on"
|
||||
},
|
||||
"predictionSafetyFactor": 1.5,
|
||||
"terminationGraceSeconds": 10.0,
|
||||
"deferAfterFailureOrTimeout": true,
|
||||
"deferWhenPredictedWallExceedsSoftTimeout": true,
|
||||
"longTestEnvironmentVariable": "RUN_TEST_MQL_8_LONG_REGRESSION"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "progressive-extension-decision",
|
||||
"sourceReport": "2026-08-17-solver-only-v1.json",
|
||||
"sourceReportSha256": "ed0e8514c5fc083af6d403270eb0617fc20672ebb96460c355757d28d7059d0f",
|
||||
"lane": "solver-only",
|
||||
"observedCase": {
|
||||
"caseId": "0.2s",
|
||||
"outcome": "completed",
|
||||
"wallSeconds": 132.30459557846189,
|
||||
"acceptancePassed": true
|
||||
},
|
||||
"predictionSafetyFactor": 1.5,
|
||||
"decisions": [
|
||||
{
|
||||
"caseId": "1s",
|
||||
"outcome": "deferred",
|
||||
"predictedWallSeconds": 992.2844668384642,
|
||||
"softTimeoutSeconds": 900.0,
|
||||
"reason": "predictedWallExceedsSoftBudget"
|
||||
},
|
||||
{
|
||||
"caseId": "5s",
|
||||
"outcome": "deferred",
|
||||
"reason": "predecessorDeferred"
|
||||
},
|
||||
{
|
||||
"caseId": "10s",
|
||||
"outcome": "deferred",
|
||||
"reason": "predecessorDeferred"
|
||||
}
|
||||
],
|
||||
"simulationWasNotStartedForDeferredCases": true
|
||||
}
|
||||
+6623
File diff suppressed because it is too large.
Load diff
+6496
File diff suppressed because it is too large.
Load diff
+5041
File diff suppressed because it is too large.
Load diff
+34745
File diff suppressed because it is too large.
Load diff
+26298
File diff suppressed because it is too large.
Load diff
+11969
File diff suppressed because it is too large.
Load diff
+6211
File diff suppressed because it is too large.
Load diff
+6301
File diff suppressed because it is too large.
Load diff
+6305
File diff suppressed because it is too large.
Load diff
+3083
File diff suppressed because it is too large.
Load diff
+6283
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "progressive-extension-decision",
|
||||
"manifestId": "test_mql_8-progressive-v2",
|
||||
"sourceXmlSha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
|
||||
"sourceReport": "2026-08-17-production-v2-0.2.json",
|
||||
"sourceReportSha256": "93baac975413703bebe940308ccd0de5dbd753f7c1c4fe015907315f112618a5",
|
||||
"lane": "production",
|
||||
"observedCase": {
|
||||
"caseId": "0.2s",
|
||||
"outcome": "completed",
|
||||
"workerWallSeconds": 135.82402778230608,
|
||||
"orchestrationWallSeconds": 136.63526012934744,
|
||||
"acceptancePassed": true
|
||||
},
|
||||
"predictionSafetyFactor": 1.5,
|
||||
"decisions": [
|
||||
{
|
||||
"caseId": "1s",
|
||||
"outcome": "deferred",
|
||||
"predictedWallSeconds": 1018.6802083672956,
|
||||
"softTimeoutSeconds": 900.0,
|
||||
"reason": "predictedWallExceedsSoftBudget"
|
||||
},
|
||||
{
|
||||
"caseId": "5s",
|
||||
"outcome": "deferred",
|
||||
"reason": "predecessorDeferred"
|
||||
},
|
||||
{
|
||||
"caseId": "10s",
|
||||
"outcome": "deferred",
|
||||
"reason": "predecessorDeferred"
|
||||
}
|
||||
],
|
||||
"simulationWasNotStartedForDeferredCases": true
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+13123
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "test_mql_full_branches-historical-v1",
|
||||
"description": "Historical slow-region and 2.05 s regression target for the generic solver.",
|
||||
"source": {
|
||||
"path": "tests/data/test_mql-full-branches-01-04.xml",
|
||||
"sha256": "2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48",
|
||||
"bytes": 58860,
|
||||
"xmlIsAuthoritative": true,
|
||||
"simulation": {
|
||||
"tStart": 0.0,
|
||||
"tStop": 0.81,
|
||||
"sampleStep": 0.01,
|
||||
"maxStep": 0.81,
|
||||
"method": "BDF"
|
||||
}
|
||||
},
|
||||
"structure": {
|
||||
"componentCount": 98,
|
||||
"connectionCount": 106,
|
||||
"dynamicComponentCount": 33,
|
||||
"stateCount": 74,
|
||||
"resultVariableCount": 1021,
|
||||
"pressureFlowUnknownCount": 472,
|
||||
"pressureFlowEquationCount": 472,
|
||||
"pressureFlowIsSquare": true,
|
||||
"logicalEffortCoordinateCount": 68,
|
||||
"eliminatedEffortAliasCount": 204,
|
||||
"canonicalCoordinateCount": 268,
|
||||
"compatibilityScatterCount": 472,
|
||||
"jacobianNonzeroCount": 1284,
|
||||
"jacobianColorGroupCount": 31,
|
||||
"hasMechanicalStateEvents": true
|
||||
},
|
||||
"sequence": [
|
||||
"0.81s",
|
||||
"2.10s"
|
||||
],
|
||||
"lanes": {
|
||||
"solver-only": {
|
||||
"description": "Historical solver regression using the authoritative 0.01 s output grid.",
|
||||
"samplingMode": "fixed",
|
||||
"sampleStep": 0.01,
|
||||
"instrumentationMode": "standard",
|
||||
"productionEquivalentOutput": true
|
||||
},
|
||||
"production": {
|
||||
"description": "Acceptance lane preserving the authoritative XML output grid.",
|
||||
"samplingMode": "source",
|
||||
"instrumentationMode": "standard",
|
||||
"productionEquivalentOutput": true
|
||||
}
|
||||
},
|
||||
"variants": {
|
||||
"0.81s": {
|
||||
"stopTime": 0.81,
|
||||
"softTimeoutSeconds": 180.0,
|
||||
"hardTimeoutSeconds": 240.0,
|
||||
"checkpointTimes": [
|
||||
0.0,
|
||||
0.04,
|
||||
0.69,
|
||||
0.70,
|
||||
0.8,
|
||||
0.81
|
||||
],
|
||||
"expectedSignalEventTimes": [
|
||||
0.04,
|
||||
0.8
|
||||
],
|
||||
"expectedMechanicalTransitionTimes": []
|
||||
},
|
||||
"2.10s": {
|
||||
"stopTime": 2.1,
|
||||
"softTimeoutSeconds": 300.0,
|
||||
"hardTimeoutSeconds": 360.0,
|
||||
"checkpointTimes": [
|
||||
0.0,
|
||||
0.04,
|
||||
0.69,
|
||||
0.70,
|
||||
0.8,
|
||||
1.0,
|
||||
2.0,
|
||||
2.05,
|
||||
2.1
|
||||
],
|
||||
"expectedSignalEventTimes": [
|
||||
0.04,
|
||||
0.8
|
||||
]
|
||||
}
|
||||
},
|
||||
"correctness": {
|
||||
"status": "partial",
|
||||
"maximumScaledResidual": 1e-7,
|
||||
"requireFiniteSeries": true,
|
||||
"requireStrictlyIncreasingTimes": true,
|
||||
"stateRelativeTolerance": 0.0002,
|
||||
"eventTimeAbsoluteToleranceSeconds": 0.00002,
|
||||
"signalEventTimeAbsoluteToleranceSeconds": 1e-12,
|
||||
"mechanicalTransitionTimesAvailable": true,
|
||||
"note": "Historical checkpoints are recorded; approved physical-state-v2 golden values remain pending."
|
||||
},
|
||||
"execution": {
|
||||
"causalExecutorV2Default": true,
|
||||
"environment": {
|
||||
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
|
||||
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
|
||||
"SIMULATION_CAUSAL_FAST_PATH": "1",
|
||||
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
|
||||
"SIMULATIONAPP_PROPERTY_CACHE": "on"
|
||||
},
|
||||
"predictionSafetyFactor": 1.5,
|
||||
"terminationGraceSeconds": 10.0,
|
||||
"deferAfterFailureOrTimeout": true,
|
||||
"deferWhenPredictedWallExceedsSoftTimeout": true,
|
||||
"longTestEnvironmentVariable": "RUN_TEST_MQL_FULL_BRANCHES_LONG_REGRESSION"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -3,8 +3,13 @@ from __future__ import annotations
|
||||
import unittest
|
||||
|
||||
from app.simulation.components.amesim.flow.pipes import AmesimPnl00r
|
||||
from app.simulation.components.amesim.junctions.nodes import AmesimPn3Node2
|
||||
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):
|
||||
@@ -32,6 +37,23 @@ class AmesimPnl00rComponentTests(unittest.TestCase):
|
||||
{"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
|
||||
@@ -85,14 +107,24 @@ class AmesimPnl00rComponentTests(unittest.TestCase):
|
||||
le=1.0,
|
||||
rr=0.045 / 14.0,
|
||||
)
|
||||
pipe.port_1.h_outflow = self.medium.specific_enthalpy(300.0)
|
||||
pipe.update_flow_temperature_references(
|
||||
{
|
||||
"port_1": self.medium.specific_enthalpy(300.0),
|
||||
"port_2": self.medium.specific_enthalpy(300.0),
|
||||
}
|
||||
)
|
||||
pipe._mass_flow_for_pressure_drop.cache_clear()
|
||||
|
||||
first = pipe.mass_flow(501000.0, 500000.0)
|
||||
after_first = pipe._mass_flow_for_pressure_drop.cache_info()
|
||||
second = pipe.mass_flow(501000.0, 500000.0)
|
||||
after_second = pipe._mass_flow_for_pressure_drop.cache_info()
|
||||
pipe.port_1.h_outflow = self.medium.specific_enthalpy(400.0)
|
||||
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 = pipe._mass_flow_for_pressure_drop.cache_info()
|
||||
|
||||
@@ -105,6 +137,230 @@ class AmesimPnl00rComponentTests(unittest.TestCase):
|
||||
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,
|
||||
)
|
||||
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
|
||||
/ (pipe.area * upstream_pressure),
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
results["ff"],
|
||||
min(pipe.friction_factor(expected_reynolds), 64_000_000.0),
|
||||
)
|
||||
|
||||
def test_friction_factor_transitions_from_laminar_to_turbulent(self) -> None:
|
||||
pipe = AmesimPnl00r("pnl_1", self.medium, rr=1e-5)
|
||||
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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"],
|
||||
"170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
|
||||
)
|
||||
self.assertEqual(Path(manifest["_sourcePath"]).name, "test-mql-8.xml")
|
||||
self.assertEqual(
|
||||
manifest["source"]["companionProject"]["sha256"],
|
||||
"258c50ee4850baa72fb7c2cc24536d0a631fc6a7f1fa6cedb7b6eea7c857cbaa",
|
||||
)
|
||||
self.assertFalse(
|
||||
manifest["source"]["companionProject"]["executionInput"]
|
||||
)
|
||||
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)
|
||||
|
||||
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"], 0.2)
|
||||
self.assertEqual(source_simulation_config(before)["sampleStep"], 0.01)
|
||||
self.assertEqual(source_simulation_config(before)["maxStep"], 0.01)
|
||||
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):
|
||||
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_FAST_PATH": "1",
|
||||
"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.01)],
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,281 @@
|
||||
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_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")
|
||||
|
||||
|
||||
def _system(project) -> GenericFluidSystem:
|
||||
return GenericFluidSystem(compile_reactflow_network(project))
|
||||
|
||||
|
||||
class CausalNumericIRTests(unittest.TestCase):
|
||||
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), 760)
|
||||
self.assertEqual(len(program.canonical_slots), 440)
|
||||
self.assertEqual(program.effort_group_count, 112)
|
||||
self.assertEqual(program.flow_assignment_count, 328)
|
||||
self.assertEqual(program.effort_scatter_count, 432)
|
||||
self.assertEqual(program.eliminated_effort_replica_count, 320)
|
||||
self.assertEqual(
|
||||
tuple(slot.slot for slot in program.canonical_slots),
|
||||
tuple(range(440)),
|
||||
)
|
||||
|
||||
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), 72)
|
||||
self.assertEqual(len(pressure_stage.evaluations), 36)
|
||||
self.assertTrue(
|
||||
all(
|
||||
evaluation.opcode
|
||||
is CausalIROpcode.EFFORT_COMPONENT_RESIDUAL
|
||||
for evaluation in pressure_stage.evaluations
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
[len(stage.target_slots) for stage in program.flow_stages],
|
||||
[110, 130, 49, 33, 5, 1],
|
||||
)
|
||||
|
||||
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()
|
||||
+477
-1
@@ -203,6 +203,82 @@ class IntegrateOdeTests(unittest.TestCase):
|
||||
self.assertEqual(calls[0]["max_step"], 1.0e-3)
|
||||
self.assertNotIn("first_step", calls[0])
|
||||
|
||||
def test_recoverable_trial_retries_default_keeps_direct_scipy_route(
|
||||
self,
|
||||
) -> None:
|
||||
import scipy.integrate
|
||||
|
||||
expected = object()
|
||||
with patch.object(
|
||||
scipy.integrate,
|
||||
"solve_ivp",
|
||||
return_value=expected,
|
||||
) as direct_solve:
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, state: state,
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(t_start=0.0, t_stop=1.0),
|
||||
)
|
||||
|
||||
self.assertIs(result, expected)
|
||||
direct_solve.assert_called_once()
|
||||
|
||||
def test_recoverable_stepwise_route_matches_direct_scipy_without_failures(
|
||||
self,
|
||||
) -> None:
|
||||
import numpy as np
|
||||
|
||||
sample_times = [0.0, 0.025, 0.05, 0.075, 0.1]
|
||||
|
||||
def rhs(time, state):
|
||||
return [-2.0 * float(state[0]) + math.sin(float(time))]
|
||||
|
||||
for method in ("RK45", "BDF"):
|
||||
with self.subTest(method=method):
|
||||
config = SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=0.1,
|
||||
method=method,
|
||||
rtol=1.0e-9,
|
||||
atol=1.0e-12,
|
||||
max_step=0.01,
|
||||
)
|
||||
direct = integrate_ode(
|
||||
rhs=rhs,
|
||||
initial_state=[1.0],
|
||||
config=config,
|
||||
t_eval=sample_times,
|
||||
)
|
||||
stepwise = integrate_ode(
|
||||
rhs=rhs,
|
||||
initial_state=[1.0],
|
||||
config=config,
|
||||
t_eval=sample_times,
|
||||
recoverable_trial_retries=True,
|
||||
)
|
||||
|
||||
self.assertTrue(direct.success, direct.message)
|
||||
self.assertTrue(stepwise.success, stepwise.message)
|
||||
np.testing.assert_array_equal(direct.t, stepwise.t)
|
||||
np.testing.assert_allclose(
|
||||
direct.y,
|
||||
stepwise.y,
|
||||
rtol=1.0e-12,
|
||||
atol=1.0e-14,
|
||||
)
|
||||
self.assertEqual(
|
||||
int(direct.nfev),
|
||||
sum(segment.nfev for segment in stepwise.solver_segments),
|
||||
)
|
||||
self.assertEqual(
|
||||
int(getattr(direct, "njev", 0)),
|
||||
sum(segment.njev for segment in stepwise.solver_segments),
|
||||
)
|
||||
self.assertEqual(
|
||||
int(getattr(direct, "nlu", 0)),
|
||||
sum(segment.nlu for segment in stepwise.solver_segments),
|
||||
)
|
||||
|
||||
def test_stepwise_solver_rebuilds_after_recoverable_trial_failure(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
@@ -241,7 +317,7 @@ class IntegrateOdeTests(unittest.TestCase):
|
||||
max_step=1.0,
|
||||
),
|
||||
t_eval=[0.0, 1.0],
|
||||
cancel_check=lambda: False,
|
||||
recoverable_trial_retries=True,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
@@ -249,6 +325,392 @@ class IntegrateOdeTests(unittest.TestCase):
|
||||
self.assertEqual(result.t, [0.0, 1.0])
|
||||
self.assertEqual(result.y, [[1.0, 1.0]])
|
||||
|
||||
def test_stepwise_solver_retries_recoverable_constructor_failure(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
attempts: list[dict[str, object]] = []
|
||||
|
||||
class ConstructorRetryBdf:
|
||||
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
|
||||
attempts.append(dict(kwargs))
|
||||
if float(kwargs["max_step"]) > 0.25:
|
||||
raise RecoverableTrialStateError("constructor trial failed")
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.t_bound = float(t_bound)
|
||||
self.status = "running"
|
||||
|
||||
def step(self):
|
||||
self.t = self.t_bound
|
||||
self.status = "finished"
|
||||
return None
|
||||
|
||||
def dense_output(self):
|
||||
state = self.y.copy()
|
||||
return lambda _time: state.copy()
|
||||
|
||||
with patch.object(scipy.integrate, "BDF", ConstructorRetryBdf):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=1.0,
|
||||
),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertNotIn("first_step", attempts[0])
|
||||
self.assertEqual(
|
||||
[attempt["max_step"] for attempt in attempts],
|
||||
[1.0, 0.5, 0.25],
|
||||
)
|
||||
self.assertEqual(
|
||||
[attempt.get("first_step") for attempt in attempts],
|
||||
[None, 0.5, 0.25],
|
||||
)
|
||||
segment = result.solver_segments[0]
|
||||
self.assertEqual(segment.recoverable_retry_count, 2)
|
||||
self.assertEqual(
|
||||
[retry.phase for retry in segment.recoverable_retries],
|
||||
["constructor", "constructor"],
|
||||
)
|
||||
self.assertEqual(
|
||||
segment.as_dict()["recoverableRetries"][0],
|
||||
{
|
||||
"phase": "constructor",
|
||||
"attemptedStep": 1.0,
|
||||
"reason": "constructor trial failed",
|
||||
"nextMaxStep": 0.5,
|
||||
"nextFirstStep": 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
def test_stepwise_retry_uses_solver_actual_step_not_segment_maximum(
|
||||
self,
|
||||
) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
attempts: list[dict[str, object]] = []
|
||||
|
||||
class ActualStepRetryBdf:
|
||||
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
|
||||
self.attempt_index = len(attempts)
|
||||
attempts.append(dict(kwargs))
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.t_bound = float(t_bound)
|
||||
self.h_abs = min(0.04, float(kwargs["max_step"]))
|
||||
self.step_size = min(0.03, float(kwargs["max_step"]))
|
||||
self.status = "running"
|
||||
|
||||
def step(self):
|
||||
if self.attempt_index == 0:
|
||||
raise RecoverableTrialStateError("small actual trial failed")
|
||||
self.t = self.t_bound
|
||||
self.status = "finished"
|
||||
return None
|
||||
|
||||
def dense_output(self):
|
||||
state = self.y.copy()
|
||||
return lambda _time: state.copy()
|
||||
|
||||
with patch.object(scipy.integrate, "BDF", ActualStepRetryBdf):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=1.0,
|
||||
),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertNotIn("first_step", attempts[0])
|
||||
self.assertEqual(attempts[1]["max_step"], 0.02)
|
||||
self.assertEqual(attempts[1]["first_step"], 0.02)
|
||||
retry = result.solver_segments[0].recoverable_retries[0]
|
||||
self.assertEqual(retry.phase, "step")
|
||||
self.assertEqual(retry.attempted_step, 0.04)
|
||||
self.assertEqual(retry.next_max_step, 0.02)
|
||||
|
||||
def test_stepwise_retry_falls_back_to_previous_step_size_without_h_abs(
|
||||
self,
|
||||
) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
for invalid_h_abs in (None, math.nan):
|
||||
with self.subTest(h_abs=invalid_h_abs):
|
||||
attempts: list[dict[str, object]] = []
|
||||
|
||||
class StepSizeFallbackBdf:
|
||||
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
|
||||
self.attempt_index = len(attempts)
|
||||
attempts.append(dict(kwargs))
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.t_bound = float(t_bound)
|
||||
self.h_abs = invalid_h_abs
|
||||
self.step_size = 0.06
|
||||
self.status = "running"
|
||||
|
||||
def step(self):
|
||||
if self.attempt_index == 0:
|
||||
raise RecoverableTrialStateError(
|
||||
"trial without valid h_abs"
|
||||
)
|
||||
self.t = self.t_bound
|
||||
self.status = "finished"
|
||||
return None
|
||||
|
||||
def dense_output(self):
|
||||
state = self.y.copy()
|
||||
return lambda _time: state.copy()
|
||||
|
||||
with patch.object(
|
||||
scipy.integrate,
|
||||
"BDF",
|
||||
StepSizeFallbackBdf,
|
||||
):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=1.0,
|
||||
),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertEqual(attempts[1]["max_step"], 0.03)
|
||||
self.assertEqual(attempts[1]["first_step"], 0.03)
|
||||
retry = result.solver_segments[0].recoverable_retries[0]
|
||||
self.assertEqual(retry.attempted_step, 0.06)
|
||||
self.assertEqual(retry.next_max_step, 0.03)
|
||||
|
||||
def test_accepted_step_clears_recoverable_retry_state(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
attempts: list[dict[str, object]] = []
|
||||
caps_after_accepted_retry: list[float] = []
|
||||
|
||||
class FailureAfterAcceptedRetryBdf:
|
||||
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
|
||||
self.attempt_index = len(attempts)
|
||||
attempts.append(dict(kwargs))
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.t_bound = float(t_bound)
|
||||
self.h_abs = min(0.2, float(kwargs["max_step"]))
|
||||
self.status = "running"
|
||||
self.step_count = 0
|
||||
|
||||
def step(self):
|
||||
self.step_count += 1
|
||||
if self.attempt_index == 0:
|
||||
raise RecoverableTrialStateError("retry once")
|
||||
if self.step_count == 1:
|
||||
self.t = 0.25
|
||||
return None
|
||||
caps_after_accepted_retry.append(float(self.max_step))
|
||||
self.status = "failed"
|
||||
return "ordinary failure after accepted step"
|
||||
|
||||
with patch.object(
|
||||
scipy.integrate,
|
||||
"BDF",
|
||||
FailureAfterAcceptedRetryBdf,
|
||||
):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=1.0,
|
||||
),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.message, "ordinary failure after accepted step")
|
||||
self.assertEqual(len(attempts), 2)
|
||||
self.assertEqual(caps_after_accepted_retry, [1.0])
|
||||
self.assertEqual(result.solver_segments[0].accepted_step_count, 1)
|
||||
self.assertEqual(result.solver_segments[0].recoverable_retry_count, 1)
|
||||
|
||||
def test_transition_restart_does_not_inherit_retry_first_step(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
attempts: list[dict[str, object]] = []
|
||||
transition_pending = True
|
||||
|
||||
class TransitionAfterRetryBdf:
|
||||
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
|
||||
self.attempt_index = len(attempts)
|
||||
attempts.append(dict(kwargs))
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.t_bound = float(t_bound)
|
||||
self.h_abs = min(0.2, float(kwargs["max_step"]))
|
||||
self.status = "running"
|
||||
|
||||
def step(self):
|
||||
if self.attempt_index == 0:
|
||||
raise RecoverableTrialStateError("retry before transition")
|
||||
self.t = self.t_bound
|
||||
self.y = np.asarray([self.t], dtype=float)
|
||||
self.status = "finished"
|
||||
return None
|
||||
|
||||
def dense_output(self):
|
||||
return lambda time: np.asarray([float(time)], dtype=float)
|
||||
|
||||
def transition_handler(
|
||||
previous_time,
|
||||
_previous_state,
|
||||
current_time,
|
||||
_current_state,
|
||||
_dense_state,
|
||||
):
|
||||
nonlocal transition_pending
|
||||
if transition_pending and previous_time <= 0.25 <= current_time:
|
||||
transition_pending = False
|
||||
return StateTransition(time=0.25, state=[0.25])
|
||||
return None
|
||||
|
||||
with patch.object(scipy.integrate, "BDF", TransitionAfterRetryBdf):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [1.0],
|
||||
initial_state=[0.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=1.0,
|
||||
),
|
||||
state_transition_handler=transition_handler,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertEqual(len(attempts), 3)
|
||||
self.assertNotIn("first_step", attempts[0])
|
||||
self.assertEqual(attempts[1]["first_step"], 0.1)
|
||||
self.assertEqual(attempts[1]["max_step"], 0.1)
|
||||
self.assertNotIn("first_step", attempts[2])
|
||||
self.assertEqual(attempts[2]["max_step"], 1.0)
|
||||
|
||||
def test_recoverable_retry_stops_at_minimum_step(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
minimum_step = 64.0 * math.ulp(1.0)
|
||||
attempts = 0
|
||||
|
||||
class MinimumStepBdf:
|
||||
def __init__(self, _fun, t0, y0, _t_bound, **_kwargs):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.h_abs = 2.0 * minimum_step
|
||||
self.status = "running"
|
||||
|
||||
def step(self):
|
||||
raise RecoverableTrialStateError("minimum step reached")
|
||||
|
||||
with patch.object(scipy.integrate, "BDF", MinimumStepBdf):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(t_stop=1.0, method="BDF", max_step=1.0),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.message, "minimum step reached")
|
||||
self.assertEqual(attempts, 1)
|
||||
retry = result.solver_segments[0].recoverable_retries[0]
|
||||
self.assertEqual(retry.attempted_step, 2.0 * minimum_step)
|
||||
self.assertIsNone(retry.next_max_step)
|
||||
|
||||
def test_recoverable_retry_has_sixteen_retry_limit(self) -> None:
|
||||
import scipy.integrate
|
||||
|
||||
attempts: list[dict[str, object]] = []
|
||||
|
||||
class AlwaysFailingConstructorBdf:
|
||||
def __init__(self, _fun, _t0, _y0, _t_bound, **kwargs):
|
||||
attempts.append(dict(kwargs))
|
||||
raise RecoverableTrialStateError("persistent trial failure")
|
||||
|
||||
with patch.object(
|
||||
scipy.integrate,
|
||||
"BDF",
|
||||
AlwaysFailingConstructorBdf,
|
||||
):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(t_stop=1.0, method="BDF", max_step=1.0),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(len(attempts), 17)
|
||||
diagnostics = result.solver_segments[0].recoverable_retries
|
||||
self.assertEqual(len(diagnostics), 17)
|
||||
self.assertEqual(
|
||||
sum(retry.next_max_step is not None for retry in diagnostics),
|
||||
16,
|
||||
)
|
||||
self.assertIsNone(diagnostics[-1].next_max_step)
|
||||
|
||||
def test_recoverable_retry_does_not_swallow_cancellation(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
attempts = 0
|
||||
|
||||
class CancellingBdf:
|
||||
def __init__(self, _fun, t0, y0, _t_bound, **_kwargs):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.status = "running"
|
||||
|
||||
def step(self):
|
||||
raise IntegrationCancelled
|
||||
|
||||
with patch.object(scipy.integrate, "BDF", CancellingBdf):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(t_stop=1.0, method="BDF", max_step=1.0),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.status, "cancelled")
|
||||
self.assertEqual(attempts, 1)
|
||||
self.assertEqual(result.solver_segments[0].recoverable_retry_count, 0)
|
||||
|
||||
def test_stepwise_solver_does_not_retry_ordinary_model_errors(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
@@ -743,6 +1205,20 @@ class IntegrateOdeTests(unittest.TestCase):
|
||||
self.assertAlmostEqual(result.y[0][1], 0.0, places=12)
|
||||
self.assertAlmostEqual(result.y[0][2], 0.05, places=8)
|
||||
self.assertAlmostEqual(result.y[0][-1], 0.65, places=8)
|
||||
transition_segments = [
|
||||
segment
|
||||
for segment in result.solver_segments
|
||||
if segment.state_transition_count
|
||||
]
|
||||
self.assertEqual(len(transition_segments), 1)
|
||||
self.assertEqual(
|
||||
transition_segments[0].state_transition_times,
|
||||
(event_time,),
|
||||
)
|
||||
self.assertEqual(
|
||||
transition_segments[0].as_dict()["stateTransitionTimes"],
|
||||
[event_time],
|
||||
)
|
||||
|
||||
def test_state_transitions_chain_at_same_time_until_state_repeats(self) -> None:
|
||||
event_time = 0.25
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import metadata
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
REQUIREMENTS_PATH = REPOSITORY_ROOT / "requirements.txt"
|
||||
CONSTRAINTS_PATH = (
|
||||
REPOSITORY_ROOT / "constraints" / "python312-direct.txt"
|
||||
)
|
||||
PYTHON_VERSION_PATH = REPOSITORY_ROOT / ".python-version"
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
def _active_lines(path: Path) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
line
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines()
|
||||
if (line := raw_line.strip()) and not line.startswith("#")
|
||||
)
|
||||
|
||||
|
||||
def _normalized_name(requirement: str) -> str:
|
||||
match = re.match(r"[A-Za-z0-9_.-]+", requirement)
|
||||
if match is None:
|
||||
raise AssertionError(f"Invalid requirement line: {requirement!r}")
|
||||
return match.group(0).lower().replace("_", "-")
|
||||
|
||||
|
||||
def _constraint_versions() -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for line in _active_lines(CONSTRAINTS_PATH):
|
||||
parts = line.split("==")
|
||||
if len(parts) != 2 or not all(parts):
|
||||
raise AssertionError(
|
||||
f"Direct constraint must be an exact package pin: {line!r}"
|
||||
)
|
||||
name, version = parts
|
||||
normalized = _normalized_name(name)
|
||||
if normalized in result:
|
||||
raise AssertionError(f"Duplicate direct constraint: {normalized}")
|
||||
result[normalized] = version
|
||||
return result
|
||||
|
||||
|
||||
class DependencyConstraintContractTests(unittest.TestCase):
|
||||
def test_python_reference_version_is_explicit(self) -> None:
|
||||
self.assertEqual(
|
||||
PYTHON_VERSION_PATH.read_text(encoding="utf-8").strip(),
|
||||
"3.12.3",
|
||||
)
|
||||
|
||||
def test_requirements_remain_ranges_and_declare_every_direct_package(
|
||||
self,
|
||||
) -> None:
|
||||
requirements = _active_lines(REQUIREMENTS_PATH)
|
||||
names = {_normalized_name(line) for line in requirements}
|
||||
|
||||
self.assertEqual(names, set(REFERENCE_DIRECT_VERSIONS))
|
||||
self.assertTrue(all("==" not in line for line in requirements))
|
||||
|
||||
def test_reference_constraints_pin_only_direct_cross_platform_packages(
|
||||
self,
|
||||
) -> None:
|
||||
self.assertEqual(_constraint_versions(), REFERENCE_DIRECT_VERSIONS)
|
||||
self.assertTrue(
|
||||
{
|
||||
"httptools",
|
||||
"pyyaml",
|
||||
"uvloop",
|
||||
"watchfiles",
|
||||
"websockets",
|
||||
}.isdisjoint(_constraint_versions())
|
||||
)
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.getenv(VERIFY_ENVIRONMENT_VARIABLE, "").strip().lower()
|
||||
in {"1", "true", "yes", "on"},
|
||||
f"Set {VERIFY_ENVIRONMENT_VARIABLE}=1 to verify installed versions.",
|
||||
)
|
||||
def test_installed_environment_matches_reference_constraints(self) -> None:
|
||||
self.assertEqual(sys.version_info[:3], (3, 12, 3))
|
||||
installed = {
|
||||
name: metadata.version(name)
|
||||
for name in REFERENCE_DIRECT_VERSIONS
|
||||
}
|
||||
self.assertEqual(installed, REFERENCE_DIRECT_VERSIONS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,275 @@
|
||||
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.0885267285, 1.0997760276),
|
||||
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.0885267285, 1.0997760276, 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_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,
|
||||
)
|
||||
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()
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from app.main import compile_system_xml_network
|
||||
from app.simulation.benchmark_regression import (
|
||||
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_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="solver-only")
|
||||
outcomes = [case["outcome"] for case in report["cases"]]
|
||||
self.assertEqual(outcomes, ["completed", "completed"], report["cases"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,8 @@ from unittest.mock import patch
|
||||
|
||||
from app.main import compile_reactflow_network
|
||||
from app.simulation.solvers.algebraic import (
|
||||
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
|
||||
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE,
|
||||
CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE,
|
||||
)
|
||||
from app.simulation.systems.generic import GenericFluidSystem
|
||||
@@ -16,11 +18,275 @@ from tests.test_amesim_pnvo001_signal_xml import (
|
||||
from tests.test_generic_system_xml_simulation import chain_project
|
||||
|
||||
|
||||
def _system(project) -> GenericFluidSystem:
|
||||
return GenericFluidSystem(compile_reactflow_network(project))
|
||||
def _system(
|
||||
project,
|
||||
*,
|
||||
executor_v2: bool | None = False,
|
||||
coordinate_kernel: 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"
|
||||
)
|
||||
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,
|
||||
)
|
||||
return GenericFluidSystem(compile_reactflow_network(project))
|
||||
|
||||
|
||||
class PressureFlowCausalExecutionTests(unittest.TestCase):
|
||||
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:
|
||||
|
||||
@@ -5,7 +5,6 @@ import unittest
|
||||
from app.simulation.examples.testmodel.run import prepare_testmodel_run
|
||||
from app.simulation.paths import (
|
||||
PROJECT_ROOT,
|
||||
SIMULATION_BASELINES_DIR,
|
||||
SIMULATION_RUNS_DIR,
|
||||
)
|
||||
|
||||
@@ -18,7 +17,7 @@ class SimulationPathTests(unittest.TestCase):
|
||||
self.assertEqual(prepared.output_dir.parent, SIMULATION_RUNS_DIR)
|
||||
|
||||
def test_testmodel_baselines_live_with_the_test_assets(self) -> None:
|
||||
baseline_dir = SIMULATION_BASELINES_DIR / "testmodel"
|
||||
baseline_dir = PROJECT_ROOT / "tests" / "data" / "testmodel"
|
||||
|
||||
self.assertTrue((baseline_dir / "testmodel_primary_series.csv").is_file())
|
||||
self.assertTrue(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
@@ -13,6 +14,7 @@ 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,
|
||||
)
|
||||
@@ -26,6 +28,7 @@ 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)
|
||||
@@ -51,12 +54,157 @@ def _pnl0002_solver(
|
||||
|
||||
for component in network.dynamic_components():
|
||||
component.refresh_thermodynamic_ports()
|
||||
solver = PressureFlowSolver(network)
|
||||
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",))
|
||||
|
||||
@@ -65,6 +65,25 @@ class _PassThrough(AlgebraicComponent):
|
||||
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,
|
||||
@@ -132,6 +151,53 @@ class StreamResolverExecutionPlanTests(unittest.TestCase):
|
||||
["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()
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
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, 3296)
|
||||
self.assertEqual(columns_only.nnz, 2464)
|
||||
self.assertEqual(len(active_columns), 108)
|
||||
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"], 108)
|
||||
self.assertEqual(
|
||||
jacobian._remaining_finite_difference_sparsity.nnz,
|
||||
2176,
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,237 @@
|
||||
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_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_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"], 402)
|
||||
self.assertEqual(audit["maxAbsoluteError"], 0.0)
|
||||
self.assertEqual(audit["maxToleranceRatio"], 0.0)
|
||||
|
||||
def test_extension_decision_is_bound_to_the_current_report_and_budget(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"], 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_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()
|
||||
@@ -0,0 +1,339 @@
|
||||
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()
|
||||
@@ -254,8 +254,11 @@ class ThreePistonTangentCompilerTests(unittest.TestCase):
|
||||
self.assertEqual(runtime["mode"], "semiAnalyticExactColumns")
|
||||
self.assertEqual(runtime["effectiveMode"], "notEvaluated")
|
||||
self.assertEqual(runtime["originalColorGroupCount"], 31)
|
||||
self.assertEqual(runtime["remainingColorGroupCount"], 25)
|
||||
self.assertEqual(runtime["exactColumnCount"], 6)
|
||||
# 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())
|
||||
|
||||
Reference in new issue
Block a user