同步仿真框架并接入AMESim气动组件
This commit is contained in:
1 parent
420bafeb4e
commit
db4bdb4b70
109 files changed
+26920
-420
No files matched your search
@@ -0,0 +1,4 @@
|
||||
mytank.p: max_abs_error=134.960858, max_rel_error=0.006798%
|
||||
mytank.T: max_abs_error=0.035507, max_rel_error=0.009016%
|
||||
mycylinder.p: max_abs_error=1391.986349, max_rel_error=0.009447%
|
||||
mycylinder.T: max_abs_error=0.009069, max_rel_error=0.003870%
|
||||
@@ -0,0 +1,202 @@
|
||||
time_s,mytank.p,mytank.T,mycylinder.p,mycylinder.T
|
||||
0.0,99999.9999998181,299.9999999994543,35000000.0,300.0
|
||||
0.1,113767.94796194455,310.9637987101763,34857995.191961475,299.65190130981887
|
||||
0.2,127490.75529043324,320.1741038867186,34716455.97160761,299.3039342009557
|
||||
0.30000000000000004,141168.22832317703,327.9540327439692,34575384.336400226,298.9561065982938
|
||||
0.4,154800.2272891848,334.6028385113769,34434781.727959625,298.60842486666263
|
||||
0.5,168386.6833862148,340.3440160419025,34294648.85592305,298.26089368263627
|
||||
0.6000000000000001,181927.56886228317,345.34597934661645,34154986.006528884,297.9135169662631
|
||||
0.7000000000000001,195422.8867284584,349.7379443903134,34015793.14872051,297.5662980942661
|
||||
0.8,208872.67226727083,353.62071952544704,33877069.91858829,297.2192381374397
|
||||
0.9,222277.06297793236,357.0742683513725,33738814.897943415,296.8723276411647
|
||||
1.0,235635.98150118813,360.16219760966084,33601028.88468161,296.5255848498291
|
||||
1.1,248949.42783703818,362.9362624751423,33463711.878802836,296.1790180498007
|
||||
1.2000000000000002,262217.75812283135,365.43943715392686,33326860.20704978,295.8325994898035
|
||||
1.3,275440.80324992316,367.7064864598734,33190475.613635924,295.48635453607636
|
||||
1.4000000000000001,288618.55460473493,369.766650344722,33054558.187403098,295.14029246566224
|
||||
1.5,301751.3171281971,371.6448887937757,32919104.783141974,294.79439468638526
|
||||
1.6,314839.14341954247,373.36206261131525,32784114.858335685,294.44866485903736
|
||||
1.7000000000000002,327882.06907680153,374.93594217338176,32649588.04582047,294.10310819940065
|
||||
1.8,340880.20077679906,376.3818124575356,32515523.2453146,293.7577259967503
|
||||
1.9000000000000001,353833.6270518691,377.7128659999238,32381919.54368144,293.41251959012544
|
||||
2.0,366742.42291137256,378.94054912194076,32248776.16726251,293.06749036423287
|
||||
2.1,379606.6730674497,380.0748368029417,32116092.24232331,292.72263968200787
|
||||
2.2,392426.50707225554,381.12448095768656,31983866.432642274,292.37796667994184
|
||||
2.3000000000000003,405202.0671563814,382.0971825271288,31852097.271230437,292.03346983037886
|
||||
2.4000000000000004,417933.4155500976,382.9997115435689,31720784.11623488,291.6891515780112
|
||||
2.5,430620.6510589185,383.838081019396,31589925.948559783,291.3450125479824
|
||||
2.6,443263.883710829,384.61764092425494,31459521.633358993,291.0010529534012
|
||||
2.7,455863.2542884696,385.3431706778388,31329569.71857793,290.6572718762472
|
||||
2.8000000000000003,468418.84583467664,386.0189391423481,31200069.34769955,290.31367046787994
|
||||
2.9000000000000004,480930.76401047717,386.64878296917834,31071019.430919208,289.9702490545257
|
||||
3.0,493399.1147767602,387.2361564731079,30942418.8753394,289.627007973335
|
||||
3.1,505824.0120818939,387.7841699997202,30814266.50567852,289.283948167411
|
||||
3.2,518205.5585633492,388.29565044444143,30686561.263317347,288.9410696919777
|
||||
3.3000000000000003,530543.860941083,388.7731587646163,30559302.04752924,288.5983729053581
|
||||
3.4000000000000004,542839.025935053,389.21902406251183,30432487.75758757,288.2558581528899
|
||||
3.5,555091.1532282452,389.63535331051384,30306117.365346134,287.9135275289302
|
||||
3.6,567300.3523937837,390.0240928732544,30180189.74065028,287.5713806480376
|
||||
3.7,579466.730902255,390.3870196149787,30054703.77503127,287.2294176336731
|
||||
3.8000000000000003,591590.3951155862,390.7257605811333,29929658.371455234,286.88763887333175
|
||||
3.9000000000000004,603671.4513957056,391.0418105423231,29805052.432888325,286.5460447413264
|
||||
4.0,615710.0061045411,391.3365451815722,29680884.862296656,286.20463559857706
|
||||
4.1000000000000005,627706.1656040212,391.61123273483645,29557154.562646367,285.8634117923989
|
||||
4.2,639660.0356829006,391.86704273384044,29433860.4428154,285.5223738511199
|
||||
4.3,651571.716232906,392.1050410519721,29311001.472504564,285.1815242995298
|
||||
4.4,663441.3179459961,392.32624321858174,29188576.510019373,284.840861980108
|
||||
4.5,675268.9468771729,392.53157932311984,29066584.46149164,284.5003872946073
|
||||
4.6000000000000005,687054.7090814385,392.72191331429804,28945024.2330532,284.16010063101663
|
||||
4.7,698798.7106137947,392.8980487868277,28823894.730835862,283.8200023633402
|
||||
4.800000000000001,710501.057529244,393.06073417111315,28703194.86097143,283.48009285137505
|
||||
4.9,722161.8558827877,393.21066739655134,28582923.529591747,283.1403724404863
|
||||
5.0,733781.2088290841,393.3484951327475,28463079.672743235,282.8008422184954
|
||||
5.1000000000000005,745359.2175579917,393.47481852250473,28343662.24673756,282.4615037792963
|
||||
5.2,756895.9922909128,393.5902150621596,28224670.114733644,282.1223563449153
|
||||
5.300000000000001,768391.6386869826,393.6952183545109,28106102.186946325,281.78340031124986
|
||||
5.4,779846.2624053361,393.7903288312906,27987957.373590477,281.44463605959197
|
||||
5.5,791259.96910511,393.8760162992802,27870234.58488091,281.10606395639473
|
||||
5.6000000000000005,802632.8644454395,393.9527222554955,27752932.731032487,280.76768435303745
|
||||
5.7,813965.0540854601,394.02086199551263,27636050.72226007,280.4294975855874
|
||||
5.800000000000001,825256.6344365194,394.08081904264304,27519587.564161647,280.0915054160418
|
||||
5.9,836507.7093909897,394.13296077650165,27403542.18517475,279.75370843660113
|
||||
6.0,847718.3891245491,394.17763867488134,27287913.44892988,279.4161062418491
|
||||
6.1000000000000005,858888.7786008355,394.2151805066602,27172700.272815377,279.0786992215644
|
||||
6.2,870018.9827834871,394.2458959595685,27057901.57421954,278.7414877501898
|
||||
6.300000000000001,881109.1066361419,394.27007787647636,26943516.2705307,278.40447218658807
|
||||
6.4,892159.2551224378,394.2880033916485,26829543.279137183,278.0676528737956
|
||||
6.5,903169.5332060129,394.2999349762812,26715981.5174273,277.73103013877227
|
||||
6.6000000000000005,914139.9829839376,394.30609016406663,26602830.551205155,277.3946116451124
|
||||
6.7,925070.7482002805,394.30672506364067,26490088.897871558,277.0583932025842
|
||||
6.800000000000001,935961.9434794089,394.30206871636136,26377755.375172496,276.7223739769823
|
||||
6.9,946813.6729747849,394.2923336718555,26265828.9088526,276.3865543393284
|
||||
7.0,957626.040839869,394.2777219546483,26154308.424656466,276.05093464451556
|
||||
7.1000000000000005,968399.1512281233,394.2584257130283,26043192.84832872,275.7155152310525
|
||||
7.2,979133.1082930088,394.23462782052076,25932481.10561397,275.3802964208052
|
||||
7.300000000000001,989828.0161879869,394.2065024339623,25822172.12225682,275.04527851873627
|
||||
7.4,1000483.9790665191,394.17421551178546,25712264.824001882,274.71046181264023
|
||||
7.5,1011101.1010820667,394.13792529578427,25602758.136593778,274.3758465728772
|
||||
7.6000000000000005,1021679.4863880915,394.09778275932865,25493650.985777102,274.04143305210266
|
||||
7.7,1032219.239138054,394.0539320247227,25384942.297296483,273.707221484995
|
||||
7.800000000000001,1042720.4634854163,394.0065107521557,25276630.996896524,273.37321208797937
|
||||
7.9,1053183.2635836392,393.95565050247825,25168716.01032184,273.0394050589494
|
||||
8.0,1063607.7435861847,393.9014770758345,25061196.263317037,272.705800576985
|
||||
8.1,1073994.0076465139,393.8441108280058,24954070.681626726,272.3723988020679
|
||||
8.200000000000001,1084342.0075784405,393.78360959211744,24847339.76225091,272.03921728278755
|
||||
8.3,1094651.950514763,393.7201237653114,24741001.368788917,271.7062444960846
|
||||
8.4,1104923.9566483083,393.65376548392067,24635054.261552785,271.3734787513138
|
||||
8.5,1115158.1282653515,393.5846357175065,24529497.385545585,271.0409203804046
|
||||
8.6,1125354.567652169,393.5128313057523,24424329.685770374,270.7085696976142
|
||||
8.700000000000001,1135513.3770950353,393.43844517133164,24319550.10723021,270.37642699925055
|
||||
8.8,1145634.6588802272,393.3615665197664,24215157.594928175,270.0444925633909
|
||||
8.9,1155718.5152940191,393.2822810271899,24111151.093867306,269.712766649598
|
||||
9.0,1165765.0486226876,393.2006710168648,24007529.54905069,269.3812494986329
|
||||
9.1,1175774.361152508,393.11681562522904,23904291.90548136,269.04994133216485
|
||||
9.200000000000001,1185746.555169756,393.03079095819226,23801437.108162407,268.7188423524781
|
||||
9.3,1195681.732960707,392.94267023834027,23698964.10209688,268.3879527421748
|
||||
9.4,1205579.9968116365,392.8525239436615,23596871.83228785,268.05727266387584
|
||||
9.5,1215441.4490088206,392.7604199383583,23495159.243738372,267.72680225991735
|
||||
9.600000000000001,1225266.1918385345,392.66642359626576,23393825.281451505,267.39654165204473
|
||||
9.700000000000001,1235054.3275870536,392.57059791736026,23292868.89043032,267.0664909411029
|
||||
9.8,1244805.7424247153,392.47293675670085,23192291.244732097,266.7366751290975
|
||||
9.9,1254520.6963966596,392.3735487251935,23092089.662209835,266.40707625875064
|
||||
10.0,1264199.3049931854,392.2724947546991,22992262.951678198,266.07769289844276
|
||||
10.100000000000001,1273841.6679760527,392.1698289153906,22892810.0841785,265.74852540655036
|
||||
10.200000000000001,1283447.8851070204,392.0656034102887,22793730.030752078,265.41957412301247
|
||||
10.3,1293018.0561478487,391.9598686580462,22695021.76244025,265.0908393690368
|
||||
10.4,1302552.2808602974,391.8526733713811,22596684.250284337,264.76232144680336
|
||||
10.5,1312050.6590061258,391.7440646314241,22498716.465325654,264.4340206391643
|
||||
10.600000000000001,1321513.290347094,391.63408795822716,22401117.37860553,264.10593720934105
|
||||
10.700000000000001,1330940.2746449616,391.5227873776613,22303885.961165283,263.7780714006174
|
||||
10.8,1340331.711661488,391.41020548491883,22207021.184046242,263.4504234360302
|
||||
10.9,1349687.7011584332,391.29638350481804,22110522.01828973,263.1229935180553
|
||||
11.0,1359008.342897557,391.18136134909884,22014387.434937052,262.79578182829164
|
||||
11.100000000000001,1368293.7366406189,391.0651776708804,21918616.405029543,262.4687885271409
|
||||
11.200000000000001,1377543.9821493784,390.9478699164463,21823207.89960853,262.1420137534841
|
||||
11.3,1386759.1791855951,390.82947437450605,21728160.88971532,261.81545762435434
|
||||
11.4,1395939.2416852904,390.70997702085253,21633476.26302751,261.4891421257888
|
||||
11.5,1405084.4058089943,390.5894488404711,21539151.583747786,261.1630514204039
|
||||
11.600000000000001,1414194.7727817593,390.46792355037144,21445185.807824824,260.83718544108797
|
||||
11.700000000000001,1423270.4378832965,390.3454323238288,21351577.952528503,260.5115448028764
|
||||
11.8,1432311.4962235806,390.22200536933536,21258327.036879413,260.1861301247683
|
||||
11.9,1441318.042742851,390.0976719679259,21165432.0816488,259.86094202976824
|
||||
12.0,1450290.1722116095,389.97246050877925,21072892.1093586,259.5359811449295
|
||||
12.100000000000001,1459227.9792306225,389.84639852319333,20980706.14428146,259.2112481013979
|
||||
12.200000000000001,1468131.5582309205,389.71951271701596,20888873.212440677,258.88674353445447
|
||||
12.3,1477001.0034737969,389.59182900161403,20797392.34161027,258.5624680835606
|
||||
12.4,1485836.4090508097,389.46337252345796,20706262.561314918,258.23842239240224
|
||||
12.5,1494637.8688837802,389.3341676923915,20615482.902829994,257.9146071089349
|
||||
12.600000000000001,1503405.4767247946,389.20423820865625,20525052.399181567,257.5910228854296
|
||||
12.700000000000001,1512139.3261562001,389.07360708873284,20434970.085146382,257.26767037851835
|
||||
12.8,1520839.5105906113,388.94229669006046,20345234.997251876,256.9445502492407
|
||||
12.9,1529506.1233409408,388.81032876227204,20255846.173053782,256.62166315000906
|
||||
13.0,1538139.2687433015,388.67772882293264,20166802.53641284,256.2990076411302
|
||||
13.100000000000001,1546739.030251722,388.5445136335602,20078103.22657088,255.97658615427704
|
||||
13.200000000000001,1555305.5007360894,388.4107031827765,19989747.285653118,255.65439933992917
|
||||
13.3,1563838.7729005008,388.27631690939495,19901733.757494725,255.332447852161
|
||||
13.4,1572338.9392832702,388.14137372147235,19814061.687640797,255.0107323486784
|
||||
13.5,1580806.0922569225,388.0058920145713,19726730.123346385,254.68925349085504
|
||||
13.600000000000001,1589240.3240281995,387.86988968927403,19639738.113576483,254.36801194376957
|
||||
13.700000000000001,1597641.7266380545,387.73338416798015,19553084.709006038,254.04700837624307
|
||||
13.8,1606010.3919616563,387.59639241102605,19466768.962019928,253.72624346087662
|
||||
13.9,1614346.4117083861,387.458930932153,19380789.926712975,253.40571787408948
|
||||
14.0,1622649.8774218408,387.32101581335974,19295146.658889957,253.0854322961577
|
||||
14.100000000000001,1630920.8804798292,387.18266271916264,19209838.21606559,252.76538741125262
|
||||
14.200000000000001,1639159.5120943757,387.0438869102949,19124863.657464534,252.4455839074805
|
||||
14.3,1647365.8633117182,386.90470325686863,19040222.0440214,252.12602247692183
|
||||
14.4,1655540.0250123062,386.76512625102305,18955912.438380726,251.8067038156715
|
||||
14.5,1663682.087910807,386.6251700190859,18871933.904897016,251.48762862387906
|
||||
14.600000000000001,1671792.1425560997,386.4848483332645,18788285.509634707,251.16879760578948
|
||||
14.700000000000001,1679870.2793312764,386.3441746228917,18704966.320368182,250.85021146978437
|
||||
14.8,1687916.588453644,386.2031619852454,18621975.406581767,250.5318709284234
|
||||
14.9,1695931.1599747243,386.06182319595837,18539311.83946974,250.21377669848638
|
||||
15.0,1703914.0837802505,385.920170719039,18456974.691936314,249.8959295010154
|
||||
15.100000000000001,1711865.4494154856,385.77821670970445,18374963.040397402,249.57833006564294
|
||||
15.200000000000001,1719785.3379250832,385.6359727064743,18293276.048945524,249.26097933285033
|
||||
15.3,1727673.8459201432,385.4934505781353,18211912.721118417,248.94387786218365
|
||||
15.4,1735531.0625176337,385.3506616315768,18130872.137749474,248.6270263958575
|
||||
15.5,1743357.0766656764,385.2076169067951,18050153.381413613,248.31042568075102
|
||||
15.600000000000001,1751151.9771435438,385.06432718490083,17969755.53642727,247.99407646845765
|
||||
15.700000000000001,1758915.8525616606,384.9208029958382,17889677.688848406,247.67797951533504
|
||||
15.8,1766648.7913616048,384.77705462583015,17809918.926476505,247.36213558255577
|
||||
15.9,1774350.881816107,384.63309212455715,17730478.33885257,247.04654543615806
|
||||
16.0,1782022.2120290494,384.4889253120841,17651355.017259125,246.73120984709743
|
||||
16.1,1789662.8699354655,384.3445637855432,17572548.05472022,246.41612959129878
|
||||
16.2,1797272.9433015438,384.2000169255848,17494056.546001427,246.10130544970855
|
||||
16.3,1804852.5197246224,384.05529390260284,17415879.58760983,245.78673820834797
|
||||
16.400000000000002,1812401.6866331936,383.9104036827472,17338016.277794052,245.47242865836654
|
||||
16.5,1819920.5312869006,383.76535503372884,17260465.716544226,245.15837759609565
|
||||
16.6,1827409.1407765402,383.6201565304272,17183227.005592,244.84458582310356
|
||||
16.7,1834867.6020240602,383.47481656030743,17106299.24841057,244.53105414625009
|
||||
16.8,1842296.0017825624,383.32934332865534,17029681.550214626,244.2177833777421
|
||||
16.900000000000002,1849694.4266362996,383.1837448636361,16953373.017960392,243.9047743351897
|
||||
17.0,1857062.9630006768,383.03802902118434,16877372.76034562,243.59202784166294
|
||||
17.1,1864401.697122252,382.8922034897328,16801679.887809563,243.27954472574828
|
||||
17.2,1871710.7150787353,382.7462757947841,16726293.512533028,242.96732582160686
|
||||
17.3,1878990.1030524147,382.60025337999576,16651212.745618157,242.65537190477806
|
||||
17.400000000000002,1886239.954455942,382.4541455990818,16576436.623591991,242.343682012199
|
||||
17.5,1893460.3482686526,382.3079576538116,16501964.331849081,242.03225853722532
|
||||
17.6,1900651.3699906773,382.16169648335216,16427794.988527464,241.72110231102408
|
||||
17.7,1907813.104956132,382.0153688850827,16353927.713477474,241.41021416968596
|
||||
17.8,1914945.6383331183,381.86898151834066,16280361.628261749,241.09959495427398
|
||||
17.900000000000002,1922049.0551237254,381.7225409080495,16207095.856155202,240.78924551087363
|
||||
18.0,1929123.4401640275,381.57605344823065,16134129.522145053,240.47916669064318
|
||||
18.1,1936168.8781240864,381.4295254054061,16061461.752930798,240.16935934986387
|
||||
18.2,1943185.4535079484,381.28296292189384,15989091.67692425,239.8598243499916
|
||||
18.3,1950173.2506536485,381.1363720190009,15917018.424249483,239.55056255770802
|
||||
18.400000000000002,1957132.3537332045,380.98975860011734,15845241.126742886,239.24157484497272
|
||||
18.5,1964062.8467526236,380.84312845371574,15773758.917953137,238.93286208907583
|
||||
18.6,1970964.8135518986,380.6964872562572,15702570.933141202,238.6244251726908
|
||||
18.7,1977838.3378050062,380.54984057500997,15631676.309280336,238.31626498392774
|
||||
18.8,1984683.5030199126,380.4031938707821,15561074.185056096,238.00838241638746
|
||||
18.900000000000002,1991500.3925385692,380.25655250057133,15490763.700866321,237.7007783692156
|
||||
19.0,1998289.0895369116,380.10992172013556,15420743.998821149,237.39345374715774
|
||||
19.1,2005049.677024864,379.96330668648653,15351014.222743012,237.08640946061425
|
||||
19.200000000000003,2011782.237846337,379.81671246030885,15281573.518166626,236.77964642569634
|
||||
19.3,2018486.8546792252,379.67014400830794,15212421.032339014,236.47316556428217
|
||||
19.400000000000002,2025163.610035411,379.5236062054879,15143555.914219463,236.16696780407335
|
||||
19.5,2031812.5863479478,379.3771038539995,15074977.31358037,235.861054060844
|
||||
19.6,2038433.8680519294,379.2306420805889,15006684.359544702,235.55542481191287
|
||||
19.700000000000003,2045027.536485746,379.084225365391,14938676.213175347,235.25008113730536
|
||||
19.8,2051593.6739729291,378.9378583245209,14870952.025374293,234.9450238874766
|
||||
19.900000000000002,2058132.3627231135,378.79154550141357,14803510.948218277,234.64025390703284
|
||||
20.0,2064643.6848320398,378.64529136859073,14736352.134958768,234.33577203462033
|
||||
|
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.systems import test_mql
|
||||
|
||||
|
||||
MATRIX_PATH = Path(__file__).resolve().parents[1] / "docs" / "amesim-component-migration-matrix.md"
|
||||
|
||||
|
||||
class AmesimComponentMigrationMatrixTests(unittest.TestCase):
|
||||
def test_matrix_covers_every_test_mql_submodel_family(self) -> None:
|
||||
text = MATRIX_PATH.read_text(encoding="utf-8")
|
||||
families = set(test_mql.SUBMODEL_COUNTS) | set(test_mql.LINE_SUBMODEL_COUNTS)
|
||||
|
||||
missing = sorted(family for family in families if f"`{family}`" not in text)
|
||||
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
def test_unsupported_domains_are_not_marked_as_public_candidates(self) -> None:
|
||||
text = MATRIX_PATH.read_text(encoding="utf-8")
|
||||
unsupported = {
|
||||
"F000",
|
||||
"FORC",
|
||||
"LMECHN1",
|
||||
"LSTP00A",
|
||||
"MECMAS21",
|
||||
"PNRP17",
|
||||
"STEP0",
|
||||
"UD00",
|
||||
}
|
||||
|
||||
for family in unsupported:
|
||||
with self.subTest(family=family):
|
||||
row = next(line for line in text.splitlines() if f"`{family}`" in line)
|
||||
self.assertIn("暂不公开", row)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.simulation.components.amesim.storage.chambers import AmesimPnch023
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
|
||||
|
||||
|
||||
class AmesimPnch023ComponentTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.medium = IdealGasMedium()
|
||||
|
||||
def test_default_create_preserves_amesim_parameter_contract(self) -> None:
|
||||
chamber = COMPONENT_MODEL_REGISTRY["amesim_pnch023"].create(
|
||||
"chamber_1",
|
||||
self.medium,
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertIsInstance(chamber, AmesimPnch023)
|
||||
self.assertEqual(set(chamber.ports), {"port_1", "port_2"})
|
||||
self.assertEqual(
|
||||
set(chamber.parameter_values),
|
||||
{"cvol", "kth", "sth", "extemp", "gi", "p0", "T0"},
|
||||
)
|
||||
self.assertEqual(chamber.parameter_values["cvol"], 0.057)
|
||||
self.assertEqual(chamber.gi, 1)
|
||||
|
||||
def test_rejects_fractional_gas_index(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "gi must be an integer"):
|
||||
COMPONENT_MODEL_REGISTRY["amesim_pnch023"].create(
|
||||
"chamber_1",
|
||||
self.medium,
|
||||
{"gi": 1.5},
|
||||
)
|
||||
|
||||
def test_initial_state_matches_requested_pressure_temperature(self) -> None:
|
||||
chamber = AmesimPnch023(
|
||||
"chamber_1",
|
||||
self.medium,
|
||||
cvol=0.057,
|
||||
p0=15300000.0,
|
||||
T0=293.15,
|
||||
)
|
||||
|
||||
props = chamber.properties()
|
||||
|
||||
self.assertAlmostEqual(props.p, 15300000.0)
|
||||
self.assertAlmostEqual(props.T, 293.15)
|
||||
self.assertAlmostEqual(chamber.port_1.p, props.p)
|
||||
self.assertAlmostEqual(chamber.port_2.p, props.p)
|
||||
|
||||
def test_two_port_derivative_conserves_mass_and_adds_heat_transfer(self) -> None:
|
||||
chamber = AmesimPnch023(
|
||||
"chamber_1",
|
||||
self.medium,
|
||||
kth=10.0,
|
||||
sth=2.0,
|
||||
extemp=310.0,
|
||||
T0=300.0,
|
||||
)
|
||||
props = chamber.properties()
|
||||
chamber.port_1.m_flow = 0.2
|
||||
chamber.port_2.m_flow = -0.1
|
||||
|
||||
derivative = chamber.state_derivative_from_ports(
|
||||
{
|
||||
"port_1": props.h + 1000.0,
|
||||
"port_2": props.h - 1000.0,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(derivative[0], 0.1)
|
||||
self.assertAlmostEqual(
|
||||
derivative[1],
|
||||
0.2 * (props.h + 1000.0) - 0.1 * props.h + 10.0 * 2.0 * 10.0,
|
||||
)
|
||||
|
||||
def test_pressure_residuals_bind_both_ports_to_state(self) -> None:
|
||||
chamber = AmesimPnch023("chamber_1", self.medium, p0=200000.0, T0=300.0)
|
||||
chamber.port_1.p = 200100.0
|
||||
chamber.port_2.p = 199900.0
|
||||
|
||||
residuals = {
|
||||
residual.id.rsplit(":", 1)[1]: residual
|
||||
for residual in chamber.pressure_flow_equation_residuals()
|
||||
}
|
||||
|
||||
self.assertEqual(set(residuals), {"port_1_pressure_state", "port_2_pressure_state"})
|
||||
self.assertAlmostEqual(residuals["port_1_pressure_state"].value, 100.0)
|
||||
self.assertAlmostEqual(residuals["port_2_pressure_state"].value, -100.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.main import (
|
||||
ReactFlowProjectPayload,
|
||||
build_reactflow_system_xml,
|
||||
compile_reactflow_network,
|
||||
run_system_xml_simulation,
|
||||
)
|
||||
from app.system_xml import validate_system_xml_document
|
||||
from tests.test_generic_system_xml_simulation import component_node, physical_edge
|
||||
from tests.test_system_xml_protocol import physical_port
|
||||
|
||||
|
||||
def amesim_pnch023_project() -> ReactFlowProjectPayload:
|
||||
return ReactFlowProjectPayload(
|
||||
name="amesim-pnch023-smoke",
|
||||
nodes=[
|
||||
component_node(
|
||||
"cylinder_1",
|
||||
"cylinder",
|
||||
[physical_port("port_b", "outlet", "right")],
|
||||
{"volume": 0.01, "p0": 101000.0, "T0": 300.0},
|
||||
),
|
||||
component_node(
|
||||
"orifice_1",
|
||||
"orifice",
|
||||
[
|
||||
physical_port("port_a", "inlet", "left"),
|
||||
physical_port("port_b", "outlet", "right"),
|
||||
],
|
||||
{"K": 1e-8, "opening": 1.0},
|
||||
),
|
||||
component_node(
|
||||
"chamber_1",
|
||||
"amesim_pnch023",
|
||||
[
|
||||
physical_port("port_1", "bidirectional", "left"),
|
||||
physical_port("port_2", "bidirectional", "right"),
|
||||
],
|
||||
{
|
||||
"cvol": 0.057,
|
||||
"kth": 0.0,
|
||||
"sth": 0.1,
|
||||
"extemp": 293.15,
|
||||
"gi": 1.0,
|
||||
"p0": 100000.0,
|
||||
"T0": 293.15,
|
||||
},
|
||||
),
|
||||
component_node(
|
||||
"pipe_1",
|
||||
"amesim_pnl00r",
|
||||
[
|
||||
physical_port("port_1", "bidirectional", "left"),
|
||||
physical_port("port_2", "bidirectional", "right"),
|
||||
],
|
||||
{"diam": 0.02, "le": 1.0, "rr": 1.0e-5, "gi": 1.0},
|
||||
),
|
||||
component_node(
|
||||
"tank_1",
|
||||
"tank",
|
||||
[physical_port("port_a", "inlet", "left")],
|
||||
{"volume": 0.1, "p0": 100000.0, "T0": 300.0},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
physical_edge("edge-1", "cylinder_1", "port_b", "orifice_1", "port_a"),
|
||||
physical_edge("edge-2", "orifice_1", "port_b", "chamber_1", "port_1"),
|
||||
physical_edge("edge-3", "chamber_1", "port_2", "pipe_1", "port_1"),
|
||||
physical_edge("edge-4", "pipe_1", "port_2", "tank_1", "port_a"),
|
||||
],
|
||||
simulation={
|
||||
"t_start": 0.0,
|
||||
"t_stop": 0.002,
|
||||
"step": 0.001,
|
||||
"max_step": 0.001,
|
||||
"method": "BDF",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AmesimPnch023XmlTests(unittest.TestCase):
|
||||
def test_pnch023_reactflow_project_compiles(self) -> None:
|
||||
network = compile_reactflow_network(amesim_pnch023_project())
|
||||
|
||||
self.assertIn("chamber_1", network.components)
|
||||
self.assertEqual(network.components["chamber_1"].model_type, "amesim_pnch023")
|
||||
self.assertTrue(network.pressure_flow_structure_dict()["isSquare"])
|
||||
|
||||
def test_pnch023_system_xml_validates_and_simulates(self) -> None:
|
||||
xml = build_reactflow_system_xml(amesim_pnch023_project())
|
||||
report = validate_system_xml_document(xml)
|
||||
|
||||
self.assertTrue(report.valid, report.as_dict())
|
||||
|
||||
result = run_system_xml_simulation(xml)
|
||||
|
||||
self.assertTrue(result["success"], result["message"])
|
||||
self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002])
|
||||
self.assertIn("chamber_1.p", result["series"])
|
||||
self.assertIn("chamber_1.port_1.m_flow", result["series"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.simulation.components.amesim.junctions.nodes import (
|
||||
AmesimP4Node2,
|
||||
AmesimPn3Node2,
|
||||
)
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
|
||||
|
||||
class AmesimPneumaticNodeComponentTests(unittest.TestCase):
|
||||
def test_pn3node2_declares_bidirectional_pneumatic_ports(self) -> None:
|
||||
node = AmesimPn3Node2.create(
|
||||
name="pn3_1",
|
||||
medium=IdealGasMedium(),
|
||||
parameters={},
|
||||
)
|
||||
|
||||
self.assertEqual(set(node.ports), {"port_1", "port_2", "port_3"})
|
||||
self.assertTrue(
|
||||
all(
|
||||
definition.domain == "pneumatic"
|
||||
and definition.nominal_role == "bidirectional"
|
||||
for definition in node.port_definitions
|
||||
)
|
||||
)
|
||||
self.assertEqual(node.parameter_values, {})
|
||||
|
||||
def test_pn3node2_residuals_use_port_2_as_pressure_reference(self) -> None:
|
||||
node = AmesimPn3Node2("pn3_1")
|
||||
node.port_1.p = 101000.0
|
||||
node.port_2.p = 100000.0
|
||||
node.port_3.p = 99000.0
|
||||
node.port_1.m_flow = 0.2
|
||||
node.port_2.m_flow = -0.3
|
||||
node.port_3.m_flow = 0.1
|
||||
|
||||
residuals = {residual.id.rsplit(":", 1)[1]: residual for residual in node.pressure_flow_equation_residuals()}
|
||||
|
||||
self.assertEqual(set(residuals), {"port_1_pressure_reference", "port_3_pressure_reference", "mass_flow_balance"})
|
||||
self.assertEqual(residuals["port_1_pressure_reference"].value, 1000.0)
|
||||
self.assertEqual(residuals["port_3_pressure_reference"].value, -1000.0)
|
||||
self.assertAlmostEqual(residuals["mass_flow_balance"].value, 0.0)
|
||||
|
||||
def test_p4node2_residuals_cover_four_ports(self) -> None:
|
||||
node = AmesimP4Node2("p4_1")
|
||||
node.port_1.p = 100000.0
|
||||
node.port_2.p = 100000.0
|
||||
node.port_3.p = 100000.0
|
||||
node.port_4.p = 100000.0
|
||||
node.port_1.m_flow = 0.1
|
||||
node.port_2.m_flow = 0.2
|
||||
node.port_3.m_flow = -0.4
|
||||
node.port_4.m_flow = 0.1
|
||||
|
||||
residuals = node.pressure_flow_equation_residuals()
|
||||
|
||||
self.assertEqual(len(residuals), 4)
|
||||
self.assertTrue(all(residual.value == 0.0 for residual in residuals))
|
||||
self.assertEqual(
|
||||
residuals[-1].variables,
|
||||
(
|
||||
"p4_1.port_1.m_flow",
|
||||
"p4_1.port_2.m_flow",
|
||||
"p4_1.port_3.m_flow",
|
||||
"p4_1.port_4.m_flow",
|
||||
),
|
||||
)
|
||||
|
||||
def test_node_stream_outflow_uses_incoming_weighted_mix(self) -> None:
|
||||
node = AmesimP4Node2("p4_1")
|
||||
node.port_1.m_flow = 0.25
|
||||
node.port_2.m_flow = 0.75
|
||||
node.port_3.m_flow = -0.5
|
||||
node.port_4.m_flow = -0.5
|
||||
|
||||
node.update_stream_outflows(
|
||||
{
|
||||
"port_1": 100.0,
|
||||
"port_2": 300.0,
|
||||
"port_3": 900.0,
|
||||
"port_4": 1200.0,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(node.port_1.h_outflow, 250.0)
|
||||
self.assertEqual(node.port_2.h_outflow, 250.0)
|
||||
self.assertEqual(node.port_3.h_outflow, 250.0)
|
||||
self.assertEqual(node.port_4.h_outflow, 250.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.main import (
|
||||
ReactFlowProjectPayload,
|
||||
build_reactflow_system_xml,
|
||||
compile_reactflow_network,
|
||||
run_system_xml_simulation,
|
||||
)
|
||||
from app.system_xml import validate_system_xml_document
|
||||
from tests.test_generic_system_xml_simulation import component_node, physical_edge
|
||||
from tests.test_system_xml_protocol import physical_port
|
||||
|
||||
|
||||
def amesim_pn3node_project() -> ReactFlowProjectPayload:
|
||||
return ReactFlowProjectPayload(
|
||||
name="amesim-pn3node-smoke",
|
||||
nodes=[
|
||||
component_node(
|
||||
"cylinder_1",
|
||||
"cylinder",
|
||||
[physical_port("port_b", "outlet", "right")],
|
||||
{"volume": 0.01, "p0": 500000.0, "T0": 300.0},
|
||||
),
|
||||
component_node(
|
||||
"orifice_1",
|
||||
"orifice",
|
||||
[
|
||||
physical_port("port_a", "inlet", "left"),
|
||||
physical_port("port_b", "outlet", "right"),
|
||||
],
|
||||
{"K": 1e-5, "opening": 1.0},
|
||||
),
|
||||
component_node(
|
||||
"node_1",
|
||||
"amesim_pn3node2",
|
||||
[
|
||||
physical_port("port_1", "bidirectional", "left"),
|
||||
physical_port("port_2", "bidirectional", "right"),
|
||||
physical_port("port_3", "bidirectional", "right"),
|
||||
],
|
||||
),
|
||||
component_node(
|
||||
"pipe_1",
|
||||
"pipe",
|
||||
[
|
||||
physical_port("port_a", "inlet", "left"),
|
||||
physical_port("port_b", "outlet", "right"),
|
||||
],
|
||||
{
|
||||
"length": 1.0,
|
||||
"diameter": 0.02,
|
||||
"lambda_darcy": 0.02,
|
||||
"p0": 100000.0,
|
||||
"T0": 300.0,
|
||||
},
|
||||
),
|
||||
component_node(
|
||||
"tank_1",
|
||||
"tank",
|
||||
[physical_port("port_a", "inlet", "left")],
|
||||
{"volume": 0.1, "p0": 100000.0, "T0": 300.0},
|
||||
),
|
||||
component_node(
|
||||
"pipe_2",
|
||||
"pipe",
|
||||
[
|
||||
physical_port("port_a", "inlet", "left"),
|
||||
physical_port("port_b", "outlet", "right"),
|
||||
],
|
||||
{
|
||||
"length": 1.0,
|
||||
"diameter": 0.02,
|
||||
"lambda_darcy": 0.02,
|
||||
"p0": 100000.0,
|
||||
"T0": 300.0,
|
||||
},
|
||||
),
|
||||
component_node(
|
||||
"tank_2",
|
||||
"tank",
|
||||
[physical_port("port_a", "inlet", "left")],
|
||||
{"volume": 0.1, "p0": 100000.0, "T0": 300.0},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
physical_edge("edge-1", "cylinder_1", "port_b", "orifice_1", "port_a"),
|
||||
physical_edge("edge-2", "orifice_1", "port_b", "node_1", "port_2"),
|
||||
physical_edge("edge-3", "node_1", "port_1", "pipe_1", "port_a"),
|
||||
physical_edge("edge-4", "pipe_1", "port_b", "tank_1", "port_a"),
|
||||
physical_edge("edge-5", "node_1", "port_3", "pipe_2", "port_a"),
|
||||
physical_edge("edge-6", "pipe_2", "port_b", "tank_2", "port_a"),
|
||||
],
|
||||
simulation={
|
||||
"t_start": 0.0,
|
||||
"t_stop": 0.002,
|
||||
"step": 0.001,
|
||||
"max_step": 0.001,
|
||||
"method": "BDF",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AmesimPneumaticNodeXmlTests(unittest.TestCase):
|
||||
def test_pn3node_reactflow_project_compiles(self) -> None:
|
||||
network = compile_reactflow_network(amesim_pn3node_project())
|
||||
|
||||
self.assertIn("node_1", network.components)
|
||||
self.assertEqual(network.components["node_1"].model_type, "amesim_pn3node2")
|
||||
self.assertTrue(network.pressure_flow_structure_dict()["isSquare"])
|
||||
|
||||
def test_pn3node_system_xml_validates_and_simulates(self) -> None:
|
||||
xml = build_reactflow_system_xml(amesim_pn3node_project())
|
||||
report = validate_system_xml_document(xml)
|
||||
|
||||
self.assertTrue(report.valid, report.as_dict())
|
||||
|
||||
result = run_system_xml_simulation(xml)
|
||||
|
||||
self.assertTrue(result["success"], result["message"])
|
||||
self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002])
|
||||
self.assertIn("node_1.port_2.p", result["series"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.simulation.components.amesim.flow.pipes import AmesimPnl00r
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
|
||||
|
||||
|
||||
class AmesimPnl00rComponentTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.medium = IdealGasMedium()
|
||||
|
||||
def test_default_create_preserves_amesim_parameter_contract(self) -> None:
|
||||
pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl00r"].create(
|
||||
"pnl_1",
|
||||
self.medium,
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertIsInstance(pipe, AmesimPnl00r)
|
||||
self.assertEqual(set(pipe.ports), {"port_1", "port_2"})
|
||||
self.assertEqual(set(pipe.parameter_values), {"diam", "le", "rr", "gi"})
|
||||
self.assertEqual(pipe.parameter_values["diam"], 0.01)
|
||||
self.assertEqual(pipe.gi, 1)
|
||||
|
||||
def test_rejects_fractional_gas_index(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "gi must be an integer"):
|
||||
COMPONENT_MODEL_REGISTRY["amesim_pnl00r"].create(
|
||||
"pnl_1",
|
||||
self.medium,
|
||||
{"gi": 1.5},
|
||||
)
|
||||
|
||||
def test_zero_pressure_drop_has_zero_flow_and_finite_results(self) -> None:
|
||||
pipe = AmesimPnl00r("pnl_1", self.medium)
|
||||
pipe.port_1.p = 100000.0
|
||||
pipe.port_2.p = 100000.0
|
||||
pipe.port_1.m_flow = 0.0
|
||||
pipe.port_2.m_flow = 0.0
|
||||
|
||||
residuals = {
|
||||
residual.id.rsplit(":", 1)[1]: residual
|
||||
for residual in pipe.pressure_flow_equation_residuals()
|
||||
}
|
||||
|
||||
self.assertEqual(pipe.mass_flow(100000.0, 100000.0), 0.0)
|
||||
self.assertEqual(residuals["mass_flow_balance"].value, 0.0)
|
||||
self.assertEqual(residuals["pressure_flow_relation"].value, 0.0)
|
||||
self.assertEqual(set(pipe.component_result_values()), {"re", "cm", "v", "ff"})
|
||||
|
||||
def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None:
|
||||
pipe = AmesimPnl00r("pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0)
|
||||
|
||||
forward = pipe.mass_flow(501000.0, 500000.0)
|
||||
reverse = pipe.mass_flow(500000.0, 501000.0)
|
||||
|
||||
self.assertGreater(forward, 0.0)
|
||||
self.assertLess(reverse, 0.0)
|
||||
self.assertAlmostEqual(forward, -reverse, delta=abs(forward) * 0.02)
|
||||
|
||||
def test_friction_factor_transitions_from_laminar_to_turbulent(self) -> None:
|
||||
pipe = AmesimPnl00r("pnl_1", self.medium, rr=1e-5)
|
||||
|
||||
self.assertAlmostEqual(pipe.friction_factor(100.0), 64.0 / 100.0)
|
||||
self.assertGreater(pipe.friction_factor(100000.0), 0.0)
|
||||
self.assertLess(pipe.friction_factor(100000.0), 0.1)
|
||||
|
||||
def test_darcy_pressure_drop_uses_flow_sign(self) -> None:
|
||||
pipe = AmesimPnl00r("pnl_1", self.medium)
|
||||
density = self.medium.density(500000.0, 300.0)
|
||||
|
||||
forward = pipe.darcy_pressure_drop(0.01, density=density, temperature=300.0)
|
||||
reverse = pipe.darcy_pressure_drop(-0.01, density=density, temperature=300.0)
|
||||
|
||||
self.assertGreater(forward, 0.0)
|
||||
self.assertLess(reverse, 0.0)
|
||||
self.assertAlmostEqual(forward, -reverse)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.main import (
|
||||
ReactFlowProjectPayload,
|
||||
build_reactflow_system_xml,
|
||||
compile_reactflow_network,
|
||||
run_system_xml_simulation,
|
||||
)
|
||||
from app.system_xml import validate_system_xml_document
|
||||
from tests.test_generic_system_xml_simulation import component_node, physical_edge
|
||||
from tests.test_system_xml_protocol import physical_port
|
||||
|
||||
|
||||
def amesim_pnl00r_project() -> ReactFlowProjectPayload:
|
||||
return ReactFlowProjectPayload(
|
||||
name="amesim-pnl00r-smoke",
|
||||
nodes=[
|
||||
component_node(
|
||||
"cylinder_1",
|
||||
"cylinder",
|
||||
[physical_port("port_b", "outlet", "right")],
|
||||
{"volume": 0.01, "p0": 500000.0, "T0": 300.0},
|
||||
),
|
||||
component_node(
|
||||
"pnl_1",
|
||||
"amesim_pnl00r",
|
||||
[
|
||||
physical_port("port_1", "bidirectional", "left"),
|
||||
physical_port("port_2", "bidirectional", "right"),
|
||||
],
|
||||
{"diam": 0.01, "le": 1.0, "rr": 1.0e-5, "gi": 1.0},
|
||||
),
|
||||
component_node(
|
||||
"tank_1",
|
||||
"tank",
|
||||
[physical_port("port_a", "inlet", "left")],
|
||||
{"volume": 0.1, "p0": 100000.0, "T0": 300.0},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
physical_edge("edge-1", "cylinder_1", "port_b", "pnl_1", "port_1"),
|
||||
physical_edge("edge-2", "pnl_1", "port_2", "tank_1", "port_a"),
|
||||
],
|
||||
simulation={
|
||||
"t_start": 0.0,
|
||||
"t_stop": 0.002,
|
||||
"step": 0.001,
|
||||
"max_step": 0.001,
|
||||
"method": "BDF",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AmesimPnl00rXmlTests(unittest.TestCase):
|
||||
def test_pnl00r_reactflow_project_compiles(self) -> None:
|
||||
network = compile_reactflow_network(amesim_pnl00r_project())
|
||||
|
||||
self.assertIn("pnl_1", network.components)
|
||||
self.assertEqual(network.components["pnl_1"].model_type, "amesim_pnl00r")
|
||||
self.assertTrue(network.pressure_flow_structure_dict()["isSquare"])
|
||||
|
||||
def test_pnl00r_system_xml_validates_and_simulates(self) -> None:
|
||||
xml = build_reactflow_system_xml(amesim_pnl00r_project())
|
||||
report = validate_system_xml_document(xml)
|
||||
|
||||
self.assertTrue(report.valid, report.as_dict())
|
||||
|
||||
result = run_system_xml_simulation(xml)
|
||||
|
||||
self.assertTrue(result["success"], result["message"])
|
||||
self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002])
|
||||
self.assertIn("pnl_1.port_1.m_flow", result["series"])
|
||||
self.assertIn("pnl_1.re", result["series"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.simulation.components.amesim.flow.orifices import AmesimPnor001
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
|
||||
|
||||
|
||||
class AmesimPnor001ComponentTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.medium = IdealGasMedium()
|
||||
|
||||
def test_default_create_preserves_amesim_parameter_contract(self) -> None:
|
||||
orifice = COMPONENT_MODEL_REGISTRY["amesim_pnor001"].create(
|
||||
"pnor_1",
|
||||
self.medium,
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertIsInstance(orifice, AmesimPnor001)
|
||||
self.assertEqual(set(orifice.ports), {"port_1", "port_2"})
|
||||
self.assertEqual(
|
||||
set(orifice.parameter_values),
|
||||
{"cq", "area", "Cv", "Kv", "gi", "flowset"},
|
||||
)
|
||||
self.assertEqual(orifice.parameter_values["area"], 5.0e-6)
|
||||
self.assertEqual(orifice.flowset, 1)
|
||||
|
||||
def test_rejects_fractional_integer_parameters(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "flowset must be an integer"):
|
||||
COMPONENT_MODEL_REGISTRY["amesim_pnor001"].create(
|
||||
"pnor_1",
|
||||
self.medium,
|
||||
{"flowset": 1.5},
|
||||
)
|
||||
|
||||
def test_zero_pressure_drop_has_zero_flow_and_finite_results(self) -> None:
|
||||
orifice = AmesimPnor001("pnor_1", self.medium)
|
||||
orifice.port_1.p = 100000.0
|
||||
orifice.port_2.p = 100000.0
|
||||
orifice.port_1.m_flow = 0.0
|
||||
orifice.port_2.m_flow = 0.0
|
||||
|
||||
residuals = {
|
||||
residual.id.rsplit(":", 1)[1]: residual
|
||||
for residual in orifice.pressure_flow_equation_residuals()
|
||||
}
|
||||
|
||||
self.assertEqual(orifice.mass_flow(100000.0, 100000.0), 0.0)
|
||||
self.assertEqual(residuals["mass_flow_balance"].value, 0.0)
|
||||
self.assertEqual(residuals["pressure_flow_relation"].value, 0.0)
|
||||
self.assertEqual(set(orifice.component_result_values()), {"cm", "gasvel"})
|
||||
|
||||
def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None:
|
||||
orifice = AmesimPnor001("pnor_1", self.medium)
|
||||
|
||||
forward = orifice.mass_flow(500000.0, 100000.0)
|
||||
reverse = orifice.mass_flow(100000.0, 500000.0)
|
||||
|
||||
self.assertGreater(forward, 0.0)
|
||||
self.assertLess(reverse, 0.0)
|
||||
self.assertAlmostEqual(forward, -reverse)
|
||||
|
||||
def test_cv_and_kv_modes_produce_positive_equivalent_area(self) -> None:
|
||||
cv_orifice = AmesimPnor001(
|
||||
"pnor_cv",
|
||||
self.medium,
|
||||
flowset=2.0,
|
||||
Cv=0.5,
|
||||
)
|
||||
kv_orifice = AmesimPnor001(
|
||||
"pnor_kv",
|
||||
self.medium,
|
||||
flowset=3.0,
|
||||
Kv=0.4,
|
||||
)
|
||||
|
||||
self.assertGreater(cv_orifice.effective_area, 0.0)
|
||||
self.assertGreater(kv_orifice.effective_area, 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.main import (
|
||||
ReactFlowProjectPayload,
|
||||
build_reactflow_system_xml,
|
||||
compile_reactflow_network,
|
||||
run_system_xml_simulation,
|
||||
)
|
||||
from app.system_xml import validate_system_xml_document
|
||||
from tests.test_generic_system_xml_simulation import component_node, physical_edge
|
||||
from tests.test_system_xml_protocol import physical_port
|
||||
|
||||
|
||||
def amesim_pnor001_project() -> ReactFlowProjectPayload:
|
||||
return ReactFlowProjectPayload(
|
||||
name="amesim-pnor001-smoke",
|
||||
nodes=[
|
||||
component_node(
|
||||
"cylinder_1",
|
||||
"cylinder",
|
||||
[physical_port("port_b", "outlet", "right")],
|
||||
{"volume": 0.01, "p0": 500000.0, "T0": 300.0},
|
||||
),
|
||||
component_node(
|
||||
"pnor_1",
|
||||
"amesim_pnor001",
|
||||
[
|
||||
physical_port("port_1", "bidirectional", "left"),
|
||||
physical_port("port_2", "bidirectional", "right"),
|
||||
],
|
||||
{
|
||||
"cq": 0.72,
|
||||
"area": 5.0e-6,
|
||||
"Cv": 0.5,
|
||||
"Kv": 0.4,
|
||||
"gi": 1.0,
|
||||
"flowset": 1.0,
|
||||
},
|
||||
),
|
||||
component_node(
|
||||
"pipe_1",
|
||||
"pipe",
|
||||
[
|
||||
physical_port("port_a", "inlet", "left"),
|
||||
physical_port("port_b", "outlet", "right"),
|
||||
],
|
||||
{
|
||||
"length": 1.0,
|
||||
"diameter": 0.02,
|
||||
"lambda_darcy": 0.02,
|
||||
"p0": 100000.0,
|
||||
"T0": 300.0,
|
||||
},
|
||||
),
|
||||
component_node(
|
||||
"tank_1",
|
||||
"tank",
|
||||
[physical_port("port_a", "inlet", "left")],
|
||||
{"volume": 0.1, "p0": 100000.0, "T0": 300.0},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
physical_edge("edge-1", "cylinder_1", "port_b", "pnor_1", "port_1"),
|
||||
physical_edge("edge-2", "pnor_1", "port_2", "pipe_1", "port_a"),
|
||||
physical_edge("edge-3", "pipe_1", "port_b", "tank_1", "port_a"),
|
||||
],
|
||||
simulation={
|
||||
"t_start": 0.0,
|
||||
"t_stop": 0.002,
|
||||
"step": 0.001,
|
||||
"max_step": 0.001,
|
||||
"method": "BDF",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AmesimPnor001XmlTests(unittest.TestCase):
|
||||
def test_pnor001_reactflow_project_compiles(self) -> None:
|
||||
network = compile_reactflow_network(amesim_pnor001_project())
|
||||
|
||||
self.assertIn("pnor_1", network.components)
|
||||
self.assertEqual(network.components["pnor_1"].model_type, "amesim_pnor001")
|
||||
self.assertTrue(network.pressure_flow_structure_dict()["isSquare"])
|
||||
|
||||
def test_pnor001_system_xml_validates_and_simulates(self) -> None:
|
||||
xml = build_reactflow_system_xml(amesim_pnor001_project())
|
||||
report = validate_system_xml_document(xml)
|
||||
|
||||
self.assertTrue(report.valid, report.as_dict())
|
||||
|
||||
result = run_system_xml_simulation(xml)
|
||||
|
||||
self.assertTrue(result["success"], result["message"])
|
||||
self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002])
|
||||
self.assertIn("pnor_1.port_1.m_flow", result["series"])
|
||||
self.assertIn("pnor_1.gasvel", result["series"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
|
||||
|
||||
|
||||
class AmesimPnpl01ComponentTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.medium = IdealGasMedium()
|
||||
|
||||
def test_default_create_preserves_zero_flow_boundary_contract(self) -> None:
|
||||
source = COMPONENT_MODEL_REGISTRY["amesim_pnpl01"].create(
|
||||
"plug_1",
|
||||
self.medium,
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertIsInstance(source, AmesimPnpl01)
|
||||
self.assertEqual(set(source.ports), {"port_1"})
|
||||
self.assertEqual(source.parameter_values, {})
|
||||
|
||||
def test_zero_flow_residual_uses_port_mass_flow(self) -> None:
|
||||
source = AmesimPnpl01("plug_1")
|
||||
source.port_1.m_flow = 0.125
|
||||
|
||||
residuals = source.pressure_flow_equation_residuals()
|
||||
|
||||
self.assertEqual(len(residuals), 1)
|
||||
self.assertEqual(residuals[0].id, "plug_1:zero_mass_flow")
|
||||
self.assertEqual(residuals[0].variables, ("plug_1.port_1.m_flow",))
|
||||
self.assertEqual(residuals[0].value, 0.125)
|
||||
|
||||
def test_stream_outflow_tracks_connected_enthalpy_when_available(self) -> None:
|
||||
source = AmesimPnpl01("plug_1")
|
||||
|
||||
source.update_stream_outflows({"port_1": 123.0})
|
||||
|
||||
self.assertEqual(source.port_1.h_outflow, 123.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.main import (
|
||||
ReactFlowProjectPayload,
|
||||
build_reactflow_system_xml,
|
||||
compile_reactflow_network,
|
||||
run_system_xml_simulation,
|
||||
)
|
||||
from app.system_xml import validate_system_xml_document
|
||||
from tests.test_generic_system_xml_simulation import component_node, physical_edge
|
||||
from tests.test_system_xml_protocol import physical_port
|
||||
|
||||
|
||||
def amesim_pnpl01_project() -> ReactFlowProjectPayload:
|
||||
return ReactFlowProjectPayload(
|
||||
name="amesim-pnpl01-smoke",
|
||||
nodes=[
|
||||
component_node(
|
||||
"chamber_1",
|
||||
"amesim_pnch023",
|
||||
[
|
||||
physical_port("port_1", "bidirectional", "left"),
|
||||
physical_port("port_2", "bidirectional", "right"),
|
||||
],
|
||||
{
|
||||
"cvol": 0.057,
|
||||
"kth": 0.0,
|
||||
"sth": 0.1,
|
||||
"extemp": 293.15,
|
||||
"gi": 1.0,
|
||||
"p0": 100000.0,
|
||||
"T0": 293.15,
|
||||
},
|
||||
),
|
||||
component_node(
|
||||
"plug_1",
|
||||
"amesim_pnpl01",
|
||||
[physical_port("port_1", "bidirectional", "left")],
|
||||
),
|
||||
component_node(
|
||||
"plug_2",
|
||||
"amesim_pnpl01",
|
||||
[physical_port("port_1", "bidirectional", "left")],
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
physical_edge("edge-1", "chamber_1", "port_1", "plug_1", "port_1"),
|
||||
physical_edge("edge-2", "chamber_1", "port_2", "plug_2", "port_1"),
|
||||
],
|
||||
simulation={
|
||||
"t_start": 0.0,
|
||||
"t_stop": 0.002,
|
||||
"step": 0.001,
|
||||
"max_step": 0.001,
|
||||
"method": "BDF",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AmesimPnpl01XmlTests(unittest.TestCase):
|
||||
def test_pnpl01_reactflow_project_compiles(self) -> None:
|
||||
network = compile_reactflow_network(amesim_pnpl01_project())
|
||||
|
||||
self.assertIn("plug_1", network.components)
|
||||
self.assertEqual(network.components["plug_1"].model_type, "amesim_pnpl01")
|
||||
self.assertTrue(network.pressure_flow_structure_dict()["isSquare"])
|
||||
|
||||
def test_pnpl01_system_xml_validates_and_simulates(self) -> None:
|
||||
xml = build_reactflow_system_xml(amesim_pnpl01_project())
|
||||
report = validate_system_xml_document(xml)
|
||||
|
||||
self.assertTrue(report.valid, report.as_dict())
|
||||
|
||||
result = run_system_xml_simulation(xml)
|
||||
|
||||
self.assertTrue(result["success"], result["message"])
|
||||
self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002])
|
||||
self.assertTrue(
|
||||
all(abs(value) < 1.0e-12 for value in result["series"]["plug_1.port_1.m_flow"])
|
||||
)
|
||||
self.assertIn("chamber_1.p", result["series"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.simulation.components.amesim.flow.orifices import AmesimPnvo001FixedOpening
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
|
||||
|
||||
|
||||
class AmesimPnvo001FixedOpeningComponentTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.medium = IdealGasMedium()
|
||||
|
||||
def test_default_create_preserves_fixed_opening_contract(self) -> None:
|
||||
valve = COMPONENT_MODEL_REGISTRY["amesim_pnvo001_fixed"].create(
|
||||
"valve_1",
|
||||
self.medium,
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertIsInstance(valve, AmesimPnvo001FixedOpening)
|
||||
self.assertEqual(set(valve.ports), {"port_2", "port_3"})
|
||||
self.assertEqual(
|
||||
set(valve.parameter_values),
|
||||
{"cq", "area0", "Cv", "Kv", "gi", "flowset", "opening"},
|
||||
)
|
||||
self.assertEqual(valve.opening, 1.0)
|
||||
|
||||
def test_rejects_fractional_flowset(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "flowset must be an integer"):
|
||||
COMPONENT_MODEL_REGISTRY["amesim_pnvo001_fixed"].create(
|
||||
"valve_1",
|
||||
self.medium,
|
||||
{"flowset": 1.5},
|
||||
)
|
||||
|
||||
def test_opening_scales_effective_area(self) -> None:
|
||||
valve = AmesimPnvo001FixedOpening(
|
||||
"valve_1",
|
||||
self.medium,
|
||||
area0=2.0e-6,
|
||||
opening=0.25,
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(valve.maximum_area, 2.0e-6)
|
||||
self.assertAlmostEqual(valve.effective_area, 0.5e-6)
|
||||
self.assertEqual(valve.component_result_values()["xv"], 0.25)
|
||||
|
||||
def test_zero_opening_blocks_flow(self) -> None:
|
||||
valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.0)
|
||||
|
||||
self.assertEqual(valve.mass_flow(500000.0, 100000.0), 0.0)
|
||||
|
||||
def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None:
|
||||
valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5)
|
||||
|
||||
forward = valve.mass_flow(500000.0, 100000.0)
|
||||
reverse = valve.mass_flow(100000.0, 500000.0)
|
||||
|
||||
self.assertGreater(forward, 0.0)
|
||||
self.assertLess(reverse, 0.0)
|
||||
self.assertAlmostEqual(forward, -reverse)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.main import (
|
||||
ReactFlowProjectPayload,
|
||||
build_reactflow_system_xml,
|
||||
compile_reactflow_network,
|
||||
run_system_xml_simulation,
|
||||
)
|
||||
from app.system_xml import validate_system_xml_document
|
||||
from tests.test_generic_system_xml_simulation import component_node, physical_edge
|
||||
from tests.test_system_xml_protocol import physical_port
|
||||
|
||||
|
||||
def amesim_pnvo001_fixed_project() -> ReactFlowProjectPayload:
|
||||
return ReactFlowProjectPayload(
|
||||
name="amesim-pnvo001-fixed-smoke",
|
||||
nodes=[
|
||||
component_node(
|
||||
"cylinder_1",
|
||||
"cylinder",
|
||||
[physical_port("port_b", "outlet", "right")],
|
||||
{"volume": 0.01, "p0": 150000.0, "T0": 300.0},
|
||||
),
|
||||
component_node(
|
||||
"valve_1",
|
||||
"amesim_pnvo001_fixed",
|
||||
[
|
||||
physical_port("port_2", "bidirectional", "left"),
|
||||
physical_port("port_3", "bidirectional", "right"),
|
||||
],
|
||||
{
|
||||
"cq": 0.72,
|
||||
"area0": 5.0e-6,
|
||||
"Cv": 0.5,
|
||||
"Kv": 0.4,
|
||||
"gi": 1.0,
|
||||
"flowset": 1.0,
|
||||
"opening": 0.5,
|
||||
},
|
||||
),
|
||||
component_node(
|
||||
"pipe_1",
|
||||
"amesim_pnl00r",
|
||||
[
|
||||
physical_port("port_1", "bidirectional", "left"),
|
||||
physical_port("port_2", "bidirectional", "right"),
|
||||
],
|
||||
{"diam": 0.02, "le": 1.0, "rr": 1.0e-5, "gi": 1.0},
|
||||
),
|
||||
component_node(
|
||||
"tank_1",
|
||||
"tank",
|
||||
[physical_port("port_a", "inlet", "left")],
|
||||
{"volume": 0.1, "p0": 100000.0, "T0": 300.0},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
physical_edge("edge-1", "cylinder_1", "port_b", "valve_1", "port_2"),
|
||||
physical_edge("edge-2", "valve_1", "port_3", "pipe_1", "port_1"),
|
||||
physical_edge("edge-3", "pipe_1", "port_2", "tank_1", "port_a"),
|
||||
],
|
||||
simulation={
|
||||
"t_start": 0.0,
|
||||
"t_stop": 0.002,
|
||||
"step": 0.001,
|
||||
"max_step": 0.001,
|
||||
"method": "BDF",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AmesimPnvo001FixedOpeningXmlTests(unittest.TestCase):
|
||||
def test_pnvo001_fixed_reactflow_project_compiles(self) -> None:
|
||||
network = compile_reactflow_network(amesim_pnvo001_fixed_project())
|
||||
|
||||
self.assertIn("valve_1", network.components)
|
||||
self.assertEqual(network.components["valve_1"].model_type, "amesim_pnvo001_fixed")
|
||||
self.assertTrue(network.pressure_flow_structure_dict()["isSquare"])
|
||||
|
||||
def test_pnvo001_fixed_system_xml_validates_and_simulates(self) -> None:
|
||||
xml = build_reactflow_system_xml(amesim_pnvo001_fixed_project())
|
||||
report = validate_system_xml_document(xml)
|
||||
|
||||
self.assertTrue(report.valid, report.as_dict())
|
||||
|
||||
result = run_system_xml_simulation(xml)
|
||||
|
||||
self.assertTrue(result["success"], result["message"])
|
||||
self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002])
|
||||
self.assertIn("valve_1.xv", result["series"])
|
||||
self.assertIn("valve_1.port_2.m_flow", result["series"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from app.main import get_component_catalog
|
||||
from app.simulation.registry import (
|
||||
COMPONENT_MODEL_REGISTRY,
|
||||
validate_component_registries,
|
||||
)
|
||||
|
||||
|
||||
class ComponentCatalogTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.catalog = get_component_catalog()
|
||||
self.libraries = {
|
||||
library["id"]: library for library in self.catalog["libraries"]
|
||||
}
|
||||
self.library = self.libraries["experimental"]
|
||||
self.components = {
|
||||
component["modelType"]: component
|
||||
for library in self.catalog["libraries"]
|
||||
for component in library["components"]
|
||||
}
|
||||
|
||||
def test_experimental_library_is_exposed_as_temporary(self) -> None:
|
||||
self.assertEqual(self.catalog["schemaVersion"], 1)
|
||||
self.assertEqual(self.library["id"], "experimental")
|
||||
self.assertEqual(self.library["label"], "临时测试组件库")
|
||||
self.assertEqual(self.library["version"], "0.1.0")
|
||||
self.assertTrue(self.library["temporary"])
|
||||
self.assertEqual(
|
||||
self.library["sourcePackage"],
|
||||
"app.simulation.components.experimental",
|
||||
)
|
||||
self.assertEqual(
|
||||
[category["id"] for category in self.library["categories"]],
|
||||
["storage", "flow", "junctions"],
|
||||
)
|
||||
|
||||
|
||||
def test_amesim_library_exposes_pneumatic_models(self) -> None:
|
||||
library = self.libraries["amesim"]
|
||||
components = {
|
||||
component["modelType"]: component for component in library["components"]
|
||||
}
|
||||
|
||||
self.assertEqual(library["label"], "AMESim 组件库")
|
||||
self.assertEqual([category["id"] for category in library["categories"]], ["storage", "flow", "junctions", "boundary"])
|
||||
self.assertEqual(set(components), {"amesim_pnpl01", "amesim_pnch023", "amesim_pnor001", "amesim_pnvo001_fixed", "amesim_pnl00r", "amesim_pn3node2", "amesim_p4node2"})
|
||||
self.assertEqual(
|
||||
[port["name"] for port in components["amesim_p4node2"]["ports"]],
|
||||
["port_1", "port_2", "port_3", "port_4"],
|
||||
)
|
||||
self.assertEqual(components["amesim_pnpl01"]["category"]["id"], "boundary")
|
||||
self.assertEqual([port["name"] for port in components["amesim_pnpl01"]["ports"]], ["port_1"])
|
||||
|
||||
pnch_parameters = {
|
||||
parameter["name"]: parameter
|
||||
for parameter in components["amesim_pnch023"]["parameters"]
|
||||
}
|
||||
self.assertEqual(components["amesim_pnch023"]["category"]["id"], "storage")
|
||||
self.assertEqual(pnch_parameters["cvol"]["quantity"], "volume")
|
||||
self.assertEqual(pnch_parameters["kth"]["quantity"], "heat_transfer_coefficient")
|
||||
|
||||
pnor_parameters = {
|
||||
parameter["name"]: parameter
|
||||
for parameter in components["amesim_pnor001"]["parameters"]
|
||||
}
|
||||
self.assertEqual(components["amesim_pnor001"]["category"]["id"], "flow")
|
||||
self.assertEqual(pnor_parameters["area"]["quantity"], "area")
|
||||
self.assertEqual(pnor_parameters["area"]["unit"], "m2")
|
||||
pnvo_parameters = {
|
||||
parameter["name"]: parameter
|
||||
for parameter in components["amesim_pnvo001_fixed"]["parameters"]
|
||||
}
|
||||
self.assertEqual(components["amesim_pnvo001_fixed"]["category"]["id"], "flow")
|
||||
self.assertEqual(pnvo_parameters["opening"]["maximum"], 1.0)
|
||||
self.assertEqual([port["name"] for port in components["amesim_pnvo001_fixed"]["ports"]], ["port_2", "port_3"])
|
||||
|
||||
pnl_parameters = {
|
||||
parameter["name"]: parameter
|
||||
for parameter in components["amesim_pnl00r"]["parameters"]
|
||||
}
|
||||
self.assertEqual(components["amesim_pnl00r"]["category"]["id"], "flow")
|
||||
self.assertEqual(pnl_parameters["diam"]["quantity"], "length")
|
||||
self.assertEqual(pnl_parameters["le"]["unit"], "m")
|
||||
|
||||
def test_every_registered_component_is_in_the_catalog(self) -> None:
|
||||
self.assertEqual(
|
||||
set(self.components),
|
||||
set(COMPONENT_MODEL_REGISTRY),
|
||||
)
|
||||
|
||||
def test_catalog_reuses_model_parameter_contracts(self) -> None:
|
||||
cylinder = self.components["cylinder"]
|
||||
parameters = {
|
||||
parameter["name"]: parameter for parameter in cylinder["parameters"]
|
||||
}
|
||||
|
||||
self.assertEqual(cylinder["label"], "气瓶")
|
||||
self.assertEqual(cylinder["modelVersion"], "1.0.0")
|
||||
self.assertEqual(parameters["volume"]["default"], 0.01)
|
||||
self.assertEqual(parameters["volume"]["quantity"], "volume")
|
||||
self.assertEqual(parameters["p0"]["unit"], "Pa")
|
||||
self.assertTrue(parameters["p0"]["minimumExclusive"])
|
||||
|
||||
def test_catalog_adds_port_layout_without_changing_physical_contract(self) -> None:
|
||||
tee_ports = {
|
||||
port["name"]: port for port in self.components["tee"]["ports"]
|
||||
}
|
||||
|
||||
self.assertEqual(tee_ports["port_in"]["side"], "left")
|
||||
self.assertEqual(tee_ports["port_out1"]["side"], "right")
|
||||
self.assertEqual(tee_ports["port_out2"]["side"], "right")
|
||||
self.assertEqual(tee_ports["port_in"]["kind"], "physical")
|
||||
self.assertEqual(tee_ports["port_in"]["domain"], "pneumatic")
|
||||
self.assertEqual(tee_ports["port_in"]["nominalRole"], "bidirectional")
|
||||
|
||||
def test_registry_can_repeat_discovery_and_validation(self) -> None:
|
||||
validate_component_registries()
|
||||
|
||||
def test_catalog_schema_is_committed_and_targets_version_one(self) -> None:
|
||||
schema_path = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "schemas"
|
||||
/ "component-catalog-v1.schema.json"
|
||||
)
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(schema["properties"]["schemaVersion"]["const"], 1)
|
||||
self.assertIn("modelVersion", schema["$defs"]["component"]["required"])
|
||||
self.assertIn("version", schema["$defs"]["library"]["required"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.main import compile_reactflow_network
|
||||
from app.simulation.components.experimental.junctions.tee import Tee
|
||||
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||
from app.simulation.components.experimental.storage.tank import Tank
|
||||
from app.simulation.core.base import Component
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition, PortState, PortVariableDefinition
|
||||
from app.simulation.examples.testmodel.system import TestModelSystem
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
from tests.test_system_xml_protocol import physical_connection_project
|
||||
|
||||
|
||||
class ComponentInterfaceTests(unittest.TestCase):
|
||||
def test_pneumatic_port_exposes_equation_roles_and_signed_flow(self) -> None:
|
||||
port = PortState.pneumatic("port_a", nominal_role="bidirectional")
|
||||
assert port.definition is not None
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
(variable.name, variable.role, variable.connection_rule)
|
||||
for variable in port.definition.variables
|
||||
],
|
||||
[
|
||||
("p", "effort", "equal"),
|
||||
("m_flow", "flow", "sumToZero"),
|
||||
("h_outflow", "stream", "streamMix"),
|
||||
],
|
||||
)
|
||||
|
||||
port.m_flow = 10.0
|
||||
self.assertEqual(port.actual_direction(), "in")
|
||||
self.assertEqual(port.inflow_rate, 10.0)
|
||||
self.assertEqual(port.outflow_rate, 0.0)
|
||||
|
||||
port.m_flow = -4.0
|
||||
self.assertEqual(port.actual_direction(), "out")
|
||||
self.assertEqual(port.inflow_rate, 0.0)
|
||||
self.assertEqual(port.outflow_rate, 4.0)
|
||||
|
||||
def test_physical_connection_is_neutral_and_canonical(self) -> None:
|
||||
forward = compile_reactflow_network(physical_connection_project())
|
||||
reverse = compile_reactflow_network(
|
||||
physical_connection_project(reverse_edge=True)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
forward.connections[0].undirected_key,
|
||||
reverse.connections[0].undirected_key,
|
||||
)
|
||||
self.assertEqual(
|
||||
forward.connections[0].as_interface_dict(),
|
||||
reverse.connections[0].as_interface_dict(),
|
||||
)
|
||||
self.assertNotIn("source", forward.connections[0].as_interface_dict())
|
||||
|
||||
def test_compiler_instantiates_parameters_and_actual_edges(self) -> None:
|
||||
project = physical_connection_project()
|
||||
network = compile_reactflow_network(project)
|
||||
|
||||
self.assertEqual(set(network.components), {"cylinder_1", "tank_1"})
|
||||
self.assertEqual(network.components["cylinder_1"].V, 0.01)
|
||||
self.assertEqual(network.components["tank_1"].V, 0.1)
|
||||
self.assertEqual(len(network.connections), 1)
|
||||
self.assertEqual(network.connections[0].id, "edge-1")
|
||||
self.assertEqual(network.connections[0].kind, "physical")
|
||||
self.assertEqual(network.connections[0].domain, "pneumatic")
|
||||
equation_system = network.as_interface_dict()["pressureFlowSystem"]
|
||||
self.assertEqual(equation_system["unknownCount"], 4)
|
||||
self.assertEqual(equation_system["equationCount"], 4)
|
||||
self.assertTrue(equation_system["isSquare"])
|
||||
|
||||
def test_physical_port_requires_a_tee_for_branching(self) -> None:
|
||||
medium = IdealGasMedium()
|
||||
network = SimulationNetwork("branch-check")
|
||||
network.add_component(Cylinder("cylinder_1", medium))
|
||||
network.add_component(Tank("tank_1", medium))
|
||||
network.add_component(Tank("tank_2", medium))
|
||||
network.connect("cylinder_1", "port_b", "tank_1", "port_a")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Use a junction component"):
|
||||
network.connect("cylinder_1", "port_b", "tank_2", "port_a")
|
||||
|
||||
def test_connection_requires_the_same_variable_contract(self) -> None:
|
||||
medium = IdealGasMedium()
|
||||
network = SimulationNetwork("contract-check")
|
||||
network.add_component(Cylinder("cylinder_1", medium))
|
||||
incompatible = Component("incompatible_1")
|
||||
incompatible.register_port(
|
||||
PortState(
|
||||
definition=PortDefinition(
|
||||
name="port_a",
|
||||
kind="physical",
|
||||
domain="pneumatic",
|
||||
nominal_role="bidirectional",
|
||||
positive_flow_direction="intoComponent",
|
||||
variables=(PortVariableDefinition("p", "effort", "equal"),),
|
||||
)
|
||||
)
|
||||
)
|
||||
network.add_component(incompatible)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "variable contracts do not match"):
|
||||
network.connect("cylinder_1", "port_b", "incompatible_1", "port_a")
|
||||
|
||||
def test_connection_equations_are_executable_and_endpoint_neutral(self) -> None:
|
||||
medium = IdealGasMedium()
|
||||
network = SimulationNetwork("residual-check")
|
||||
cylinder = Cylinder("cylinder_1", medium)
|
||||
tank = Tank("tank_1", medium)
|
||||
network.add_component(cylinder)
|
||||
network.add_component(tank)
|
||||
network.connect("tank_1", "port_a", "cylinder_1", "port_b")
|
||||
|
||||
cylinder.port_b.p = 2.0e5
|
||||
cylinder.port_b.m_flow = -3.0
|
||||
tank.port_a.p = 2.0e5
|
||||
tank.port_a.m_flow = 3.0
|
||||
|
||||
residuals = {
|
||||
residual.variables[0].rsplit(".", 1)[-1]: residual
|
||||
for residual in network.connection_equation_residuals()
|
||||
}
|
||||
self.assertEqual(set(residuals), {"p", "m_flow"})
|
||||
self.assertEqual(residuals["p"].relation, "equal")
|
||||
self.assertEqual(residuals["m_flow"].relation, "sumToZero")
|
||||
self.assertEqual(residuals["p"].value, 0.0)
|
||||
self.assertEqual(residuals["m_flow"].value, 0.0)
|
||||
|
||||
def test_tee_ports_are_physical_and_bidirectional(self) -> None:
|
||||
tee = Tee("tee_1")
|
||||
self.assertEqual(
|
||||
{definition.name for definition in tee.port_definitions},
|
||||
{"port_in", "port_out1", "port_out2"},
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
definition.kind == "physical"
|
||||
and definition.nominal_role == "bidirectional"
|
||||
for definition in tee.port_definitions
|
||||
)
|
||||
)
|
||||
|
||||
def test_existing_testmodel_topology_still_builds(self) -> None:
|
||||
system = TestModelSystem()
|
||||
self.assertEqual(len(system.network.components), 8)
|
||||
self.assertEqual(len(system.network.connections), 8)
|
||||
self.assertTrue(
|
||||
all(connection.kind == "physical" for connection in system.network.connections)
|
||||
)
|
||||
self.assertEqual(len(system.initial_state_vector()), 8)
|
||||
|
||||
def test_testmodel_snapshot_satisfies_acausal_connection_equations(self) -> None:
|
||||
system = TestModelSystem()
|
||||
state = system.consistent_initial_state_vector()
|
||||
system.snapshot(state)
|
||||
|
||||
residuals = system.network.pressure_flow_equation_residuals()
|
||||
self.assertEqual(len(residuals), 4 * len(system.network.connections))
|
||||
self.assertEqual(len(system.network.pressure_flow_unknowns()), len(residuals))
|
||||
for residual in residuals:
|
||||
tolerance = 1e-6
|
||||
self.assertLessEqual(
|
||||
abs(residual.value),
|
||||
tolerance,
|
||||
msg=f"{residual.id} residual is {residual.value}",
|
||||
)
|
||||
|
||||
upstream_tee = system.mytee
|
||||
self.assertAlmostEqual(
|
||||
upstream_tee.port_in.m_flow
|
||||
+ upstream_tee.port_out1.m_flow
|
||||
+ upstream_tee.port_out2.m_flow,
|
||||
0.0,
|
||||
places=12,
|
||||
)
|
||||
self.assertAlmostEqual(upstream_tee.port_in.p, upstream_tee.port_out1.p)
|
||||
self.assertAlmostEqual(upstream_tee.port_in.p, upstream_tee.port_out2.p)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.simulation.components.experimental.flow.orifice import Orifice
|
||||
from app.simulation.components.experimental.flow.resistive_pipe import ResistivePipe
|
||||
from app.simulation.components.experimental.junctions.tee import Tee
|
||||
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||
from app.simulation.components.experimental.storage.tank import Tank
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortVariableDefinition
|
||||
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
|
||||
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
|
||||
|
||||
|
||||
class ComponentMetadataTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.medium = IdealGasMedium()
|
||||
|
||||
def component_instances(self):
|
||||
return (
|
||||
Cylinder("cylinder_1", self.medium),
|
||||
Tank("tank_1", self.medium),
|
||||
Pipe("pipe_dynamic_1", self.medium),
|
||||
ResistivePipe("pipe_1", self.medium),
|
||||
Orifice("orifice_1"),
|
||||
Tee("tee_1"),
|
||||
)
|
||||
|
||||
def test_every_component_declares_a_complete_model_contract(self) -> None:
|
||||
for component in self.component_instances():
|
||||
with self.subTest(component=component.name):
|
||||
for attribute in (
|
||||
"MODEL_TYPE",
|
||||
"MODEL_VERSION",
|
||||
"PORTS",
|
||||
"PARAMETERS",
|
||||
"RESULT_VARIABLES",
|
||||
):
|
||||
self.assertIn(attribute, type(component).__dict__)
|
||||
self.assertTrue(component.MODEL_TYPE)
|
||||
self.assertTrue(component.MODEL_VERSION)
|
||||
self.assertEqual(component.model_type, component.MODEL_TYPE)
|
||||
self.assertEqual(component.port_definitions, component.PORTS)
|
||||
self.assertEqual(
|
||||
set(component.parameter_values),
|
||||
{definition.name for definition in component.PARAMETERS},
|
||||
)
|
||||
self.assertTrue(
|
||||
all(definition.label for definition in component.PARAMETERS)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(definition.quantity for definition in component.PARAMETERS)
|
||||
)
|
||||
|
||||
def test_registry_reuses_component_parameter_and_port_declarations(self) -> None:
|
||||
registered_classes = {
|
||||
"cylinder": Cylinder,
|
||||
"tank": Tank,
|
||||
"pipe": ResistivePipe,
|
||||
"orifice": Orifice,
|
||||
"tee": Tee,
|
||||
}
|
||||
for model_type, component_class in registered_classes.items():
|
||||
with self.subTest(model_type=model_type):
|
||||
spec = COMPONENT_MODEL_REGISTRY[model_type]
|
||||
self.assertEqual(spec.model_type, component_class.MODEL_TYPE)
|
||||
self.assertEqual(
|
||||
spec.model_version,
|
||||
component_class.MODEL_VERSION,
|
||||
)
|
||||
self.assertEqual(spec.ports, component_class.PORTS)
|
||||
self.assertEqual(spec.parameters, component_class.PARAMETERS)
|
||||
self.assertIs(spec.display, component_class.DISPLAY)
|
||||
self.assertIs(spec.component_class, component_class)
|
||||
self.assertIn("create", component_class.__dict__)
|
||||
component = spec.create(f"{model_type}_1", self.medium, {})
|
||||
self.assertEqual(
|
||||
component.parameter_values,
|
||||
{
|
||||
definition.name: definition.default
|
||||
for definition in component_class.PARAMETERS
|
||||
},
|
||||
)
|
||||
|
||||
def test_result_metadata_exactly_describes_each_exposed_value(self) -> None:
|
||||
for component in self.component_instances():
|
||||
with self.subTest(component=component.name):
|
||||
values = component.result_values()
|
||||
metadata = component.result_variable_metadata()
|
||||
keys = [variable.key for variable in metadata]
|
||||
self.assertEqual(len(keys), len(set(keys)))
|
||||
self.assertEqual(
|
||||
set(values),
|
||||
{
|
||||
variable.key.removeprefix(f"{component.name}.")
|
||||
for variable in metadata
|
||||
},
|
||||
)
|
||||
self.assertTrue(all(variable.label for variable in metadata))
|
||||
self.assertTrue(all(variable.quantity for variable in metadata))
|
||||
|
||||
def test_storage_and_port_variables_include_display_metadata(self) -> None:
|
||||
cylinder = Cylinder("cylinder_1", self.medium)
|
||||
metadata = {
|
||||
variable.key: variable
|
||||
for variable in cylinder.result_variable_metadata()
|
||||
}
|
||||
|
||||
pressure = metadata["cylinder_1.p"]
|
||||
self.assertEqual(pressure.scope, "component")
|
||||
self.assertEqual(pressure.label, "压力")
|
||||
self.assertEqual(pressure.quantity, "pressure")
|
||||
self.assertEqual(pressure.unit, "Pa")
|
||||
|
||||
mass_flow = metadata["cylinder_1.port_b.m_flow"]
|
||||
self.assertEqual(mass_flow.scope, "port")
|
||||
self.assertEqual(mass_flow.port_name, "port_b")
|
||||
self.assertEqual(mass_flow.label, "质量流量")
|
||||
self.assertEqual(mass_flow.quantity, "mass_flow")
|
||||
self.assertEqual(mass_flow.unit, "kg/s")
|
||||
|
||||
def test_port_display_metadata_is_not_part_of_the_physical_contract(self) -> None:
|
||||
first = PortVariableDefinition(
|
||||
"p",
|
||||
"effort",
|
||||
"equal",
|
||||
label="压力",
|
||||
quantity="pressure",
|
||||
unit="Pa",
|
||||
)
|
||||
second = PortVariableDefinition(
|
||||
"p",
|
||||
"effort",
|
||||
"equal",
|
||||
label="Pressure",
|
||||
quantity="absolute_pressure",
|
||||
unit="kPa",
|
||||
)
|
||||
|
||||
self.assertEqual(first, second)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.simulation.components.experimental.library import LIBRARY
|
||||
from app.simulation.components.amesim.library import LIBRARY as AMESIM_LIBRARY
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.registry import (
|
||||
COMPONENT_LIBRARY_REGISTRY,
|
||||
COMPONENT_MODEL_REGISTRY,
|
||||
discover_component_registries,
|
||||
validate_component_model_class,
|
||||
)
|
||||
|
||||
|
||||
class ComponentRegistryTests(unittest.TestCase):
|
||||
def test_manifest_is_the_only_public_model_source(self) -> None:
|
||||
libraries, models = discover_component_registries()
|
||||
|
||||
self.assertEqual(libraries, COMPONENT_LIBRARY_REGISTRY)
|
||||
self.assertEqual(models, COMPONENT_MODEL_REGISTRY)
|
||||
self.assertEqual(
|
||||
tuple(models),
|
||||
(
|
||||
"cylinder",
|
||||
"tank",
|
||||
"pipe",
|
||||
"orifice",
|
||||
"tee",
|
||||
"amesim_pnpl01",
|
||||
"amesim_pnch023",
|
||||
"amesim_pnor001",
|
||||
"amesim_pnvo001_fixed",
|
||||
"amesim_pnl00r",
|
||||
"amesim_pn3node2",
|
||||
"amesim_p4node2",
|
||||
),
|
||||
)
|
||||
self.assertNotIn(
|
||||
"app.simulation.components.experimental.flow.pipe:Pipe",
|
||||
LIBRARY.models,
|
||||
)
|
||||
|
||||
|
||||
def test_amesim_library_is_enabled_as_separate_public_boundary(self) -> None:
|
||||
libraries, models = discover_component_registries()
|
||||
|
||||
self.assertIn("amesim", libraries)
|
||||
self.assertEqual(libraries["amesim"], AMESIM_LIBRARY)
|
||||
self.assertEqual(models["amesim_pnpl01"].library.id, "amesim")
|
||||
self.assertEqual(models["amesim_pnch023"].library.id, "amesim")
|
||||
self.assertEqual(models["amesim_pnor001"].library.id, "amesim")
|
||||
self.assertEqual(models["amesim_pnvo001_fixed"].library.id, "amesim")
|
||||
self.assertEqual(models["amesim_pnl00r"].library.id, "amesim")
|
||||
self.assertEqual(models["amesim_pn3node2"].library.id, "amesim")
|
||||
self.assertEqual(models["amesim_p4node2"].library.id, "amesim")
|
||||
|
||||
def test_every_public_model_owns_its_catalog_contract(self) -> None:
|
||||
for model_type, spec in COMPONENT_MODEL_REGISTRY.items():
|
||||
with self.subTest(model_type=model_type):
|
||||
declarations = spec.component_class.__dict__
|
||||
self.assertIn("MODEL_VERSION", declarations)
|
||||
self.assertIn("DISPLAY", declarations)
|
||||
self.assertIn("create", declarations)
|
||||
self.assertEqual(spec.display.library_id, spec.library.id)
|
||||
self.assertIn(
|
||||
spec.display.category_id,
|
||||
spec.library.category_by_id,
|
||||
)
|
||||
|
||||
def test_display_ports_must_match_physical_ports(self) -> None:
|
||||
class BrokenDisplayComponent(AlgebraicComponent):
|
||||
MODEL_TYPE = "broken_display"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic(
|
||||
"port_a",
|
||||
nominal_role="bidirectional",
|
||||
),
|
||||
)
|
||||
PARAMETERS = ()
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="错误显示模型",
|
||||
library_id="experimental",
|
||||
category_id="flow",
|
||||
symbol="generic",
|
||||
ports=(PortDisplaySpec("port_b", "right"),),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters,
|
||||
):
|
||||
return cls(name)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
ValueError,
|
||||
"display ports must exactly match",
|
||||
):
|
||||
validate_component_model_class(
|
||||
BrokenDisplayComponent,
|
||||
library=LIBRARY,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -63,6 +63,18 @@ class IntegrateOdeTests(unittest.TestCase):
|
||||
self.assertEqual(calls[0]["max_step"], 1.0e-3)
|
||||
self.assertNotIn("first_step", calls[0])
|
||||
|
||||
def test_scipy_stepwise_solver_can_cancel_before_start(self) -> None:
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, state: state,
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(t_start=0.0, t_stop=1.0),
|
||||
cancel_check=lambda: True,
|
||||
)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.status, "cancelled")
|
||||
self.assertEqual(result.t, [0.0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,579 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.main import (
|
||||
ReactFlowEdgePayload,
|
||||
ReactFlowProjectPayload,
|
||||
SimulationCancellationPayload,
|
||||
_mark_simulation_task_result,
|
||||
_register_simulation_task,
|
||||
build_reactflow_system_xml,
|
||||
cancel_system_xml_simulation,
|
||||
compile_reactflow_network,
|
||||
get_system_xml_simulation,
|
||||
simulation_event_stream,
|
||||
simulate_system_xml,
|
||||
)
|
||||
from app.simulation.components.experimental.flow.resistive_pipe import ResistivePipe
|
||||
from app.simulation.solvers.solver import SolveIVPConfig
|
||||
from app.simulation.systems.generic import (
|
||||
GenericFluidSystem,
|
||||
SimulationPreparationError,
|
||||
)
|
||||
from tests.test_system_xml_protocol import physical_port
|
||||
|
||||
|
||||
def component_node(
|
||||
component_id: str,
|
||||
model_type: str,
|
||||
ports: list[dict[str, str]],
|
||||
parameters: dict[str, float] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"id": component_id,
|
||||
"type": "simulationComponent",
|
||||
"position": {"x": 0.0, "y": 0.0},
|
||||
"data": {
|
||||
"label": component_id,
|
||||
"componentType": model_type,
|
||||
"modelType": model_type,
|
||||
"ports": ports,
|
||||
"parameters": parameters or {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def physical_edge(
|
||||
edge_id: str,
|
||||
first_component: str,
|
||||
first_port: str,
|
||||
second_component: str,
|
||||
second_port: str,
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
"id": edge_id,
|
||||
"source": first_component,
|
||||
"sourceHandle": first_port,
|
||||
"target": second_component,
|
||||
"targetHandle": second_port,
|
||||
}
|
||||
|
||||
|
||||
def two_port_definitions() -> list[dict[str, str]]:
|
||||
return [
|
||||
physical_port("port_a", "inlet", "left"),
|
||||
physical_port("port_b", "outlet", "right"),
|
||||
]
|
||||
|
||||
|
||||
def tee_port_definitions() -> list[dict[str, str]]:
|
||||
return [
|
||||
physical_port("port_in", "bidirectional", "left"),
|
||||
physical_port("port_out1", "bidirectional", "right"),
|
||||
physical_port("port_out2", "bidirectional", "right"),
|
||||
]
|
||||
|
||||
|
||||
def chain_project(*, reverse_edges: bool = False) -> ReactFlowProjectPayload:
|
||||
edges = [
|
||||
physical_edge("edge-1", "cylinder_1", "port_b", "orifice_1", "port_a"),
|
||||
physical_edge("edge-2", "orifice_1", "port_b", "pipe_1", "port_a"),
|
||||
physical_edge("edge-3", "pipe_1", "port_b", "tank_1", "port_a"),
|
||||
]
|
||||
if reverse_edges:
|
||||
edges = [
|
||||
physical_edge(
|
||||
edge["id"],
|
||||
edge["target"],
|
||||
edge["targetHandle"],
|
||||
edge["source"],
|
||||
edge["sourceHandle"],
|
||||
)
|
||||
for edge in edges
|
||||
]
|
||||
return ReactFlowProjectPayload(
|
||||
name="generic-chain",
|
||||
nodes=[
|
||||
component_node(
|
||||
"cylinder_1",
|
||||
"cylinder",
|
||||
[physical_port("port_b", "outlet", "right")],
|
||||
{"volume": 0.01, "p0": 500000.0, "T0": 300.0},
|
||||
),
|
||||
component_node(
|
||||
"orifice_1",
|
||||
"orifice",
|
||||
two_port_definitions(),
|
||||
{"K": 1e-5, "opening": 1.0},
|
||||
),
|
||||
component_node(
|
||||
"pipe_1",
|
||||
"pipe",
|
||||
two_port_definitions(),
|
||||
{
|
||||
"length": 1.0,
|
||||
"diameter": 0.02,
|
||||
"lambda_darcy": 0.02,
|
||||
"p0": 100000.0,
|
||||
"T0": 300.0,
|
||||
},
|
||||
),
|
||||
component_node(
|
||||
"tank_1",
|
||||
"tank",
|
||||
[physical_port("port_a", "inlet", "left")],
|
||||
{"volume": 0.1, "p0": 100000.0, "T0": 300.0},
|
||||
),
|
||||
],
|
||||
edges=edges,
|
||||
simulation={
|
||||
"t_start": 0.0,
|
||||
"t_stop": 0.01,
|
||||
"step": 0.005,
|
||||
"max_step": 0.001,
|
||||
"method": "BDF",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def branched_project() -> ReactFlowProjectPayload:
|
||||
return ReactFlowProjectPayload(
|
||||
name="generic-branch",
|
||||
nodes=[
|
||||
component_node(
|
||||
"cylinder_1",
|
||||
"cylinder",
|
||||
[physical_port("port_b", "outlet", "right")],
|
||||
{"volume": 0.01, "p0": 500000.0, "T0": 300.0},
|
||||
),
|
||||
component_node("tee_1", "tee", tee_port_definitions()),
|
||||
component_node(
|
||||
"orifice_1",
|
||||
"orifice",
|
||||
two_port_definitions(),
|
||||
{"K": 1e-5, "opening": 1.0},
|
||||
),
|
||||
component_node(
|
||||
"pipe_1",
|
||||
"pipe",
|
||||
two_port_definitions(),
|
||||
{
|
||||
"length": 1.0,
|
||||
"diameter": 0.02,
|
||||
"lambda_darcy": 0.02,
|
||||
"p0": 100000.0,
|
||||
"T0": 300.0,
|
||||
},
|
||||
),
|
||||
component_node(
|
||||
"orifice_2",
|
||||
"orifice",
|
||||
two_port_definitions(),
|
||||
{"K": 1e-5, "opening": 1.0},
|
||||
),
|
||||
component_node(
|
||||
"pipe_2",
|
||||
"pipe",
|
||||
two_port_definitions(),
|
||||
{
|
||||
"length": 1.0,
|
||||
"diameter": 0.02,
|
||||
"lambda_darcy": 0.02,
|
||||
"p0": 100000.0,
|
||||
"T0": 300.0,
|
||||
},
|
||||
),
|
||||
component_node("tee_2", "tee", tee_port_definitions()),
|
||||
component_node(
|
||||
"tank_1",
|
||||
"tank",
|
||||
[physical_port("port_a", "inlet", "left")],
|
||||
{"volume": 0.1, "p0": 100000.0, "T0": 300.0},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
physical_edge("edge-1", "cylinder_1", "port_b", "tee_1", "port_in"),
|
||||
physical_edge("edge-2", "tee_1", "port_out1", "orifice_1", "port_a"),
|
||||
physical_edge("edge-3", "orifice_1", "port_b", "pipe_1", "port_a"),
|
||||
physical_edge("edge-4", "pipe_1", "port_b", "tee_2", "port_out1"),
|
||||
physical_edge("edge-5", "tee_1", "port_out2", "orifice_2", "port_a"),
|
||||
physical_edge("edge-6", "orifice_2", "port_b", "pipe_2", "port_a"),
|
||||
physical_edge("edge-7", "pipe_2", "port_b", "tee_2", "port_out2"),
|
||||
physical_edge("edge-8", "tee_2", "port_in", "tank_1", "port_a"),
|
||||
],
|
||||
simulation={
|
||||
"t_start": 0.0,
|
||||
"t_stop": 0.005,
|
||||
"step": 0.005,
|
||||
"max_step": 0.001,
|
||||
"method": "BDF",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def xml_request(body: bytes) -> Request:
|
||||
delivered = False
|
||||
|
||||
async def receive():
|
||||
nonlocal delivered
|
||||
if delivered:
|
||||
return {"type": "http.disconnect"}
|
||||
delivered = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/system-xml/simulate",
|
||||
"headers": [(b"content-type", b"application/xml")],
|
||||
},
|
||||
receive,
|
||||
)
|
||||
|
||||
|
||||
class GenericSystemXmlSimulationTests(unittest.TestCase):
|
||||
def test_xml_pipe_compiles_to_quasi_steady_resistance(self) -> None:
|
||||
network = compile_reactflow_network(chain_project())
|
||||
|
||||
self.assertIsInstance(network.components["pipe_1"], ResistivePipe)
|
||||
structure = network.pressure_flow_structure_dict()
|
||||
self.assertEqual(structure["unknownCount"], 12)
|
||||
self.assertEqual(structure["equationCount"], 12)
|
||||
self.assertTrue(structure["isSquare"])
|
||||
|
||||
def test_generic_chain_simulation_conserves_mass_and_moves_pressures(self) -> None:
|
||||
network = compile_reactflow_network(chain_project())
|
||||
progress: list[tuple[float, str]] = []
|
||||
result = GenericFluidSystem(network).simulate(
|
||||
SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=0.01,
|
||||
method="BDF",
|
||||
max_step=0.001,
|
||||
),
|
||||
sample_step=0.005,
|
||||
progress_callback=lambda value, phase: progress.append((value, phase)),
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(progress[0], (0.0, "initializing"))
|
||||
self.assertEqual(progress[-1], (1.0, "complete"))
|
||||
self.assertTrue(
|
||||
all(
|
||||
current[0] <= following[0]
|
||||
for current, following in zip(progress, progress[1:])
|
||||
)
|
||||
)
|
||||
self.assertIn("integrating", {phase for _, phase in progress})
|
||||
self.assertIn("postprocessing", {phase for _, phase in progress})
|
||||
self.assertLess(result.series["cylinder_1.p"][-1], 500000.0)
|
||||
self.assertGreater(result.series["tank_1.p"][-1], 100000.0)
|
||||
total_mass = [
|
||||
cylinder + tank
|
||||
for cylinder, tank in zip(
|
||||
result.series["cylinder_1.m"],
|
||||
result.series["tank_1.m"],
|
||||
)
|
||||
]
|
||||
self.assertLess(max(total_mass) - min(total_mass), 1e-12)
|
||||
total_energy = [
|
||||
cylinder + tank
|
||||
for cylinder, tank in zip(
|
||||
result.series["cylinder_1.U"],
|
||||
result.series["tank_1.U"],
|
||||
)
|
||||
]
|
||||
self.assertLess(max(total_energy) - min(total_energy), 1e-6)
|
||||
self.assertLess(
|
||||
result.diagnostics["pressureFlow"]["maxScaledResidual"],
|
||||
1e-7,
|
||||
)
|
||||
|
||||
def test_cancelled_simulation_returns_accepted_partial_samples(self) -> None:
|
||||
cancel_event = threading.Event()
|
||||
|
||||
def request_cancel_after_progress(progress: float, phase: str) -> None:
|
||||
if phase == "integrating" and progress >= 0.2:
|
||||
cancel_event.set()
|
||||
|
||||
result = GenericFluidSystem(
|
||||
compile_reactflow_network(chain_project())
|
||||
).simulate(
|
||||
SolveIVPConfig(t_stop=0.05, method="BDF", max_step=0.001),
|
||||
sample_step=0.005,
|
||||
progress_callback=request_cancel_after_progress,
|
||||
cancel_check=cancel_event.is_set,
|
||||
)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.status, "cancelled")
|
||||
self.assertGreaterEqual(result.diagnostics["sampleCount"], 2)
|
||||
self.assertGreater(result.simulated_until, 0.0)
|
||||
self.assertLess(result.simulated_until, 0.05)
|
||||
self.assertEqual(
|
||||
len(result.series["time"]),
|
||||
len(result.series["cylinder_1.p"]),
|
||||
)
|
||||
|
||||
def test_task_registry_distinguishes_user_stop_and_stalled_stop(self) -> None:
|
||||
for reason, expected_status in (("user", "stopped"), ("stalled", "stalled")):
|
||||
simulation_id = f"test-{uuid4().hex}"
|
||||
task = _register_simulation_task(simulation_id)
|
||||
response = cancel_system_xml_simulation(
|
||||
simulation_id,
|
||||
SimulationCancellationPayload(reason=reason),
|
||||
)
|
||||
self.assertTrue(response["accepted"])
|
||||
|
||||
result = _mark_simulation_task_result(
|
||||
task,
|
||||
{
|
||||
"success": False,
|
||||
"status": "cancelled",
|
||||
"partial": True,
|
||||
"series": {"time": [0.0, 0.1]},
|
||||
},
|
||||
)
|
||||
snapshot = get_system_xml_simulation(simulation_id)
|
||||
|
||||
self.assertEqual(result["status"], expected_status)
|
||||
self.assertEqual(snapshot["status"], expected_status)
|
||||
self.assertEqual(snapshot["cancelReason"], reason)
|
||||
self.assertEqual(snapshot["result"]["series"]["time"], [0.0, 0.1])
|
||||
|
||||
def test_physical_edge_order_does_not_change_simulation(self) -> None:
|
||||
forward = GenericFluidSystem(
|
||||
compile_reactflow_network(chain_project())
|
||||
).simulate(
|
||||
SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001),
|
||||
sample_step=0.005,
|
||||
)
|
||||
reverse = GenericFluidSystem(
|
||||
compile_reactflow_network(chain_project(reverse_edges=True))
|
||||
).simulate(
|
||||
SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001),
|
||||
sample_step=0.005,
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(
|
||||
forward.final["tank_1.p"],
|
||||
reverse.final["tank_1.p"],
|
||||
places=7,
|
||||
)
|
||||
|
||||
def test_branched_topology_is_solved_without_fixed_testmodel_closure(self) -> None:
|
||||
network = compile_reactflow_network(branched_project())
|
||||
result = GenericFluidSystem(network).simulate(
|
||||
SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001),
|
||||
sample_step=0.005,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertAlmostEqual(
|
||||
result.final["pipe_1.port_a.m_flow"],
|
||||
result.final["pipe_2.port_a.m_flow"],
|
||||
places=10,
|
||||
)
|
||||
self.assertGreater(result.final["tank_1.p"], 100000.0)
|
||||
|
||||
def test_directly_coupled_storage_components_are_rejected(self) -> None:
|
||||
project = chain_project()
|
||||
project.nodes = [project.nodes[0], project.nodes[-1]]
|
||||
project.edges = [
|
||||
ReactFlowEdgePayload(
|
||||
**physical_edge(
|
||||
"edge-1",
|
||||
"cylinder_1",
|
||||
"port_b",
|
||||
"tank_1",
|
||||
"port_a",
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
with self.assertRaises(SimulationPreparationError) as caught:
|
||||
GenericFluidSystem(compile_reactflow_network(project))
|
||||
|
||||
self.assertIn(
|
||||
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
|
||||
{issue.code for issue in caught.exception.issues},
|
||||
)
|
||||
|
||||
def test_raw_system_xml_runs_through_generic_simulation_endpoint(self) -> None:
|
||||
xml = build_reactflow_system_xml(chain_project())
|
||||
|
||||
response = asyncio.run(simulate_system_xml(xml_request(xml)))
|
||||
|
||||
self.assertTrue(response["success"])
|
||||
self.assertEqual(response["simulation"]["method"], "BDF")
|
||||
self.assertEqual(response["model"]["pressureFlowSystem"]["unknownCount"], 12)
|
||||
self.assertEqual(response["diagnostics"]["stateCount"], 4)
|
||||
self.assertGreater(response["final"]["tank_1.p"], 100000.0)
|
||||
variables = {variable["key"]: variable for variable in response["variables"]}
|
||||
self.assertEqual(set(variables), set(response["series"]) - {"time"})
|
||||
self.assertEqual(variables["cylinder_1.p"]["componentId"], "cylinder_1")
|
||||
self.assertEqual(variables["cylinder_1.p"]["label"], "压力")
|
||||
self.assertEqual(variables["cylinder_1.p"]["unit"], "Pa")
|
||||
self.assertEqual(variables["pipe_1.port_a.m_flow"]["scope"], "port")
|
||||
self.assertEqual(
|
||||
variables["pipe_1.port_a.m_flow"]["portName"],
|
||||
"port_a",
|
||||
)
|
||||
|
||||
def test_streaming_endpoint_events_have_monotonic_progress_and_result(self) -> None:
|
||||
project = chain_project()
|
||||
xml = build_reactflow_system_xml(project)
|
||||
|
||||
events = [json.loads(line) for line in simulation_event_stream(xml)]
|
||||
|
||||
progress_events = [
|
||||
event for event in events if event["event"] == "progress"
|
||||
]
|
||||
progress = [event["progress"] for event in progress_events]
|
||||
self.assertGreater(len(progress), 3)
|
||||
self.assertTrue(
|
||||
all(current <= following for current, following in zip(progress, progress[1:]))
|
||||
)
|
||||
preparation_events = [
|
||||
event
|
||||
for event in progress_events
|
||||
if event["phase"] in {"validation", "compilation", "initialization"}
|
||||
]
|
||||
self.assertTrue(preparation_events)
|
||||
self.assertTrue(all(event["progress"] == 0 for event in preparation_events))
|
||||
timed_events = [
|
||||
event
|
||||
for event in progress_events
|
||||
if event.get("simulatedTime") is not None
|
||||
]
|
||||
self.assertGreater(len(timed_events), 1)
|
||||
for event in timed_events:
|
||||
self.assertEqual(event["totalTime"], project.simulation.t_stop)
|
||||
expected_progress = round(
|
||||
100
|
||||
* (event["simulatedTime"] - project.simulation.t_start)
|
||||
/ (project.simulation.t_stop - project.simulation.t_start)
|
||||
)
|
||||
self.assertEqual(event["progress"], expected_progress)
|
||||
self.assertEqual(events[-1]["event"], "result")
|
||||
self.assertEqual(events[-1]["progress"], 100)
|
||||
self.assertTrue(events[-1]["result"]["success"])
|
||||
|
||||
def test_streaming_task_stop_returns_partial_result(self) -> None:
|
||||
project = branched_project()
|
||||
project.simulation.t_stop = 2.0
|
||||
project.simulation.step = 0.02
|
||||
project.simulation.max_step = 0.01
|
||||
xml = build_reactflow_system_xml(project)
|
||||
simulation_id = f"test-{uuid4().hex}"
|
||||
task = _register_simulation_task(simulation_id)
|
||||
events: list[dict[str, object]] = []
|
||||
stop_requested = False
|
||||
|
||||
for line in simulation_event_stream(xml, task=task):
|
||||
event = json.loads(line)
|
||||
events.append(event)
|
||||
if (
|
||||
not stop_requested
|
||||
and event["event"] == "progress"
|
||||
and event["phase"] == "integrating"
|
||||
and event["progress"] >= 20
|
||||
):
|
||||
response = cancel_system_xml_simulation(
|
||||
simulation_id,
|
||||
SimulationCancellationPayload(reason="user"),
|
||||
)
|
||||
self.assertTrue(response["accepted"])
|
||||
stop_requested = True
|
||||
|
||||
result_event = next(event for event in events if event["event"] == "result")
|
||||
result = result_event["result"]
|
||||
snapshot = get_system_xml_simulation(simulation_id)
|
||||
|
||||
self.assertTrue(stop_requested)
|
||||
self.assertEqual(result["status"], "stopped")
|
||||
self.assertTrue(result["partial"])
|
||||
self.assertGreaterEqual(result["diagnostics"]["sampleCount"], 2)
|
||||
self.assertGreater(result["simulatedUntil"], 0.0)
|
||||
self.assertLess(result["simulatedUntil"], 2.0)
|
||||
self.assertEqual(snapshot["status"], "stopped")
|
||||
self.assertEqual(snapshot["result"]["status"], "stopped")
|
||||
|
||||
def test_streaming_endpoint_keeps_quiet_solver_connection_alive(self) -> None:
|
||||
def delayed_simulation(_xml_bytes, progress_callback, _cancel_check=None):
|
||||
progress_callback(49, "integrating", "正在进行时间积分与压力流量求解")
|
||||
time.sleep(0.03)
|
||||
return {"success": True}
|
||||
|
||||
with (
|
||||
patch("app.main.SIMULATION_STREAM_HEARTBEAT_SECONDS", 0.005),
|
||||
patch(
|
||||
"app.main.run_system_xml_simulation",
|
||||
side_effect=delayed_simulation,
|
||||
),
|
||||
):
|
||||
events = [
|
||||
json.loads(line)
|
||||
for line in simulation_event_stream(b"<System />")
|
||||
]
|
||||
|
||||
heartbeats = [event for event in events if event.get("heartbeat") is True]
|
||||
self.assertGreaterEqual(len(heartbeats), 1)
|
||||
self.assertTrue(all(event["progress"] == 49 for event in heartbeats))
|
||||
self.assertTrue(all(event["phase"] == "integrating" for event in heartbeats))
|
||||
self.assertEqual(events[-1]["event"], "result")
|
||||
|
||||
def test_streaming_endpoint_returns_structured_validation_error(self) -> None:
|
||||
events = [
|
||||
json.loads(line)
|
||||
for line in simulation_event_stream(b"<System schemaVersion='2'>")
|
||||
]
|
||||
|
||||
self.assertEqual(events[-1]["event"], "error")
|
||||
self.assertEqual(events[-1]["status"], 422)
|
||||
self.assertIn("issues", events[-1]["detail"])
|
||||
|
||||
def test_simulation_endpoint_returns_422_for_ideal_storage_coupling(self) -> None:
|
||||
project = chain_project()
|
||||
project.nodes = [project.nodes[0], project.nodes[-1]]
|
||||
project.edges = [
|
||||
ReactFlowEdgePayload(
|
||||
**physical_edge(
|
||||
"edge-1",
|
||||
"cylinder_1",
|
||||
"port_b",
|
||||
"tank_1",
|
||||
"port_a",
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
asyncio.run(
|
||||
simulate_system_xml(
|
||||
xml_request(build_reactflow_system_xml(project))
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 422)
|
||||
self.assertIn(
|
||||
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
|
||||
{
|
||||
issue["code"]
|
||||
for issue in caught.exception.detail["issues"]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import unittest
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.main import (
|
||||
SimulationResultCsvPayload,
|
||||
SimulationResultVariablePayload,
|
||||
export_simulation_results_csv,
|
||||
)
|
||||
|
||||
|
||||
def result_variable(
|
||||
key: str,
|
||||
component_id: str,
|
||||
name: str,
|
||||
label: str,
|
||||
unit: str,
|
||||
) -> SimulationResultVariablePayload:
|
||||
return SimulationResultVariablePayload(
|
||||
key=key,
|
||||
componentId=component_id,
|
||||
componentType="tank",
|
||||
scope="component",
|
||||
name=name,
|
||||
label=label,
|
||||
quantity="pressure",
|
||||
unit=unit,
|
||||
)
|
||||
|
||||
|
||||
class ResultCsvExportTests(unittest.TestCase):
|
||||
def valid_payload(self) -> SimulationResultCsvPayload:
|
||||
return SimulationResultCsvPayload(
|
||||
projectName="储气系统",
|
||||
variables=[
|
||||
result_variable(
|
||||
"cylinder_1.p",
|
||||
"cylinder_1",
|
||||
"p",
|
||||
"压力",
|
||||
"Pa",
|
||||
),
|
||||
result_variable("tank_1.p", "tank_1", "p", "压力", "Pa"),
|
||||
],
|
||||
series={
|
||||
"time": [0.0, 0.1],
|
||||
"cylinder_1.p": [35000000.0, 34900000.0],
|
||||
"tank_1.p": [100000.0, 101000.0],
|
||||
},
|
||||
)
|
||||
|
||||
def test_csv_export_preserves_result_keys_and_rows(self) -> None:
|
||||
response = export_simulation_results_csv(self.valid_payload())
|
||||
|
||||
text = response.body.decode("utf-8-sig")
|
||||
rows = list(csv.reader(io.StringIO(text)))
|
||||
self.assertEqual(
|
||||
rows[0],
|
||||
["time", "cylinder_1.p", "tank_1.p"],
|
||||
)
|
||||
self.assertEqual(rows[1], ["0.0", "35000000.0", "100000.0"])
|
||||
self.assertEqual(rows[2], ["0.1", "34900000.0", "101000.0"])
|
||||
self.assertIn(
|
||||
"filename*=UTF-8''",
|
||||
response.headers["content-disposition"],
|
||||
)
|
||||
|
||||
def test_csv_export_rejects_inconsistent_column_lengths(self) -> None:
|
||||
payload = self.valid_payload()
|
||||
payload.series["tank_1.p"] = [100000.0]
|
||||
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
export_simulation_results_csv(payload)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 422)
|
||||
self.assertIn("inconsistent length", str(caught.exception.detail))
|
||||
|
||||
def test_csv_export_requires_metadata_for_every_result_column(self) -> None:
|
||||
payload = self.valid_payload()
|
||||
payload.series["orphan.value"] = [1.0, 2.0]
|
||||
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
export_simulation_results_csv(payload)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 422)
|
||||
self.assertIn("unmapped orphan.value", str(caught.exception.detail))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class SimulationPathTests(unittest.TestCase):
|
||||
def test_default_run_paths_are_independent_of_module_depth(self) -> None:
|
||||
prepared = prepare_testmodel_run()
|
||||
|
||||
self.assertEqual(prepared.repo_root, PROJECT_ROOT)
|
||||
self.assertEqual(prepared.output_dir.parent, SIMULATION_RUNS_DIR)
|
||||
|
||||
def test_testmodel_baselines_live_with_the_test_assets(self) -> None:
|
||||
baseline_dir = SIMULATION_BASELINES_DIR / "testmodel"
|
||||
|
||||
self.assertTrue((baseline_dir / "testmodel_primary_series.csv").is_file())
|
||||
self.assertTrue(
|
||||
(
|
||||
baseline_dir / "testmodel_modelica_comparison_summary.txt"
|
||||
).is_file()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.main import (
|
||||
ReactFlowEdgePayload,
|
||||
ReactFlowNodePayload,
|
||||
ReactFlowProjectPayload,
|
||||
build_reactflow_system_xml,
|
||||
compile_system_xml_model,
|
||||
compile_reactflow_network,
|
||||
parse_system_xml,
|
||||
)
|
||||
from app.system_xml import validate_system_xml_document
|
||||
from tests.test_system_xml_protocol import (
|
||||
physical_connection_project,
|
||||
physical_port,
|
||||
)
|
||||
|
||||
|
||||
def valid_xml() -> bytes:
|
||||
return build_reactflow_system_xml(physical_connection_project())
|
||||
|
||||
|
||||
def issue_codes(xml: bytes | str) -> set[str]:
|
||||
return {
|
||||
issue.code
|
||||
for issue in validate_system_xml_document(xml).issues
|
||||
}
|
||||
|
||||
|
||||
def xml_request(body: bytes) -> Request:
|
||||
delivered = False
|
||||
|
||||
async def receive():
|
||||
nonlocal delivered
|
||||
if delivered:
|
||||
return {"type": "http.disconnect"}
|
||||
delivered = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/system-xml/compile-model",
|
||||
"headers": [(b"content-type", b"application/xml")],
|
||||
},
|
||||
receive,
|
||||
)
|
||||
|
||||
|
||||
class SystemXmlParserTests(unittest.TestCase):
|
||||
def test_valid_v2_xml_round_trips_and_compiles(self) -> None:
|
||||
report = validate_system_xml_document(valid_xml())
|
||||
|
||||
self.assertTrue(report.valid)
|
||||
self.assertEqual(report.issues, ())
|
||||
assert report.document is not None
|
||||
project = ReactFlowProjectPayload(**report.document.as_project_data())
|
||||
network = compile_reactflow_network(project)
|
||||
|
||||
self.assertEqual(project.name, "protocol-test")
|
||||
self.assertEqual(project.simulation.method, "BDF")
|
||||
self.assertEqual(set(network.components), {"cylinder_1", "tank_1"})
|
||||
self.assertEqual(len(network.connections), 1)
|
||||
self.assertEqual(network.connections[0].id, "edge-1")
|
||||
|
||||
def test_compile_api_consumes_raw_xml_body(self) -> None:
|
||||
response = asyncio.run(compile_system_xml_model(xml_request(valid_xml())))
|
||||
|
||||
self.assertTrue(response["success"])
|
||||
self.assertEqual(response["name"], "protocol-test")
|
||||
self.assertEqual(len(response["components"]), 2)
|
||||
self.assertEqual(len(response["connections"]), 1)
|
||||
self.assertTrue(response["validation"]["valid"])
|
||||
|
||||
def test_parse_api_returns_structured_422_diagnostics(self) -> None:
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
asyncio.run(parse_system_xml(xml_request(b"<broken>")))
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 422)
|
||||
detail = caught.exception.detail
|
||||
self.assertEqual(detail["message"], "System XML validation failed.")
|
||||
self.assertEqual(detail["issues"][0]["layer"], "xml")
|
||||
self.assertEqual(detail["issues"][0]["code"], "XML_SYNTAX_ERROR")
|
||||
|
||||
def test_malformed_xml_reports_xml_layer_error(self) -> None:
|
||||
report = validate_system_xml_document("<System><broken></System>")
|
||||
|
||||
self.assertFalse(report.valid)
|
||||
self.assertIsNone(report.document)
|
||||
self.assertEqual(report.issues[0].layer, "xml")
|
||||
self.assertEqual(report.issues[0].code, "XML_SYNTAX_ERROR")
|
||||
self.assertIsNotNone(report.issues[0].line)
|
||||
|
||||
def test_dtd_is_rejected_before_schema_validation(self) -> None:
|
||||
xml = valid_xml().replace(
|
||||
b"<System ",
|
||||
b"<!DOCTYPE System [<!ENTITY demo 'unsafe'>]>\n<System ",
|
||||
1,
|
||||
)
|
||||
|
||||
report = validate_system_xml_document(xml)
|
||||
|
||||
self.assertFalse(report.valid)
|
||||
self.assertEqual(report.issues[0].layer, "xml")
|
||||
self.assertEqual(report.issues[0].code, "XML_DTD_NOT_ALLOWED")
|
||||
|
||||
def test_wrong_schema_version_reports_xsd_error(self) -> None:
|
||||
xml = valid_xml().replace(b'schemaVersion="2"', b'schemaVersion="1"')
|
||||
report = validate_system_xml_document(xml)
|
||||
|
||||
self.assertFalse(report.valid)
|
||||
self.assertIsNone(report.document)
|
||||
self.assertTrue(report.issues)
|
||||
self.assertTrue(all(issue.layer == "schema" for issue in report.issues))
|
||||
self.assertIn("XSD_VALIDATION_ERROR", issue_codes(xml))
|
||||
|
||||
def test_parameter_range_is_checked_semantically(self) -> None:
|
||||
xml = valid_xml().replace(
|
||||
b'<Parameter name="volume" value="0.01"',
|
||||
b'<Parameter name="volume" value="-1"',
|
||||
1,
|
||||
)
|
||||
|
||||
report = validate_system_xml_document(xml)
|
||||
|
||||
self.assertFalse(report.valid)
|
||||
self.assertIn("PARAMETER_VALUE_INVALID", issue_codes(xml))
|
||||
issue = next(
|
||||
issue for issue in report.issues if issue.code == "PARAMETER_VALUE_INVALID"
|
||||
)
|
||||
self.assertEqual(issue.layer, "semantic")
|
||||
self.assertIn("cylinder_1.volume", issue.message)
|
||||
|
||||
def test_zero_initial_pressure_is_rejected_before_model_creation(self) -> None:
|
||||
xml = valid_xml().replace(
|
||||
b'<Parameter name="p0" value="35000000"',
|
||||
b'<Parameter name="p0" value="0"',
|
||||
1,
|
||||
)
|
||||
|
||||
report = validate_system_xml_document(xml)
|
||||
|
||||
self.assertFalse(report.valid)
|
||||
self.assertIn("PARAMETER_VALUE_INVALID", issue_codes(xml))
|
||||
|
||||
def test_registered_port_contract_is_checked_semantically(self) -> None:
|
||||
xml = valid_xml().replace(
|
||||
b'nominalRole="outlet"',
|
||||
b'nominalRole="bidirectional"',
|
||||
1,
|
||||
)
|
||||
|
||||
self.assertIn("PORT_NOMINAL_ROLE_MISMATCH", issue_codes(xml))
|
||||
|
||||
def test_unknown_connection_endpoint_is_reported(self) -> None:
|
||||
xml = valid_xml().replace(
|
||||
b'component="tank_1" port="port_a"',
|
||||
b'component="missing_tank" port="port_a"',
|
||||
1,
|
||||
)
|
||||
|
||||
self.assertIn("ENDPOINT_COMPONENT_UNKNOWN", issue_codes(xml))
|
||||
|
||||
def test_physical_port_cannot_be_used_by_two_connections(self) -> None:
|
||||
project = physical_connection_project()
|
||||
project.nodes.append(
|
||||
ReactFlowNodePayload(**{
|
||||
"id": "tank_2",
|
||||
"type": "simulationComponent",
|
||||
"position": {"x": 420, "y": 180},
|
||||
"data": {
|
||||
"label": "receiver-tank-2",
|
||||
"componentType": "tank",
|
||||
"modelType": "tank",
|
||||
"ports": [physical_port("port_a", "inlet", "left")],
|
||||
"parameters": {"volume": 0.1, "p0": 100000, "T0": 300},
|
||||
},
|
||||
})
|
||||
)
|
||||
project.edges.append(
|
||||
ReactFlowEdgePayload(**{
|
||||
"id": "edge-2",
|
||||
"source": "cylinder_1",
|
||||
"sourceHandle": "port_b",
|
||||
"target": "tank_2",
|
||||
"targetHandle": "port_a",
|
||||
})
|
||||
)
|
||||
|
||||
xml = build_reactflow_system_xml(project)
|
||||
|
||||
self.assertIn("PHYSICAL_PORT_ALREADY_CONNECTED", issue_codes(xml))
|
||||
|
||||
def test_unconnected_registered_port_is_a_warning(self) -> None:
|
||||
project = physical_connection_project()
|
||||
project.edges.clear()
|
||||
|
||||
report = validate_system_xml_document(build_reactflow_system_xml(project))
|
||||
|
||||
self.assertTrue(report.valid)
|
||||
self.assertEqual(report.as_dict()["warningCount"], 2)
|
||||
self.assertEqual(
|
||||
{issue.code for issue in report.issues},
|
||||
{"PORT_UNCONNECTED"},
|
||||
)
|
||||
|
||||
def test_missing_required_parameter_is_reported(self) -> None:
|
||||
xml = valid_xml().replace(
|
||||
b' <Parameter name="T0" value="300" />\n',
|
||||
b"",
|
||||
1,
|
||||
)
|
||||
|
||||
self.assertIn("PARAMETER_REQUIRED_MISSING", issue_codes(xml))
|
||||
|
||||
def test_unsupported_solver_method_is_reported(self) -> None:
|
||||
xml = valid_xml().replace(b'method="BDF"', b'method="unknown"')
|
||||
|
||||
self.assertIn("SIMULATION_METHOD_UNSUPPORTED", issue_codes(xml))
|
||||
|
||||
def test_non_positive_simulation_steps_are_rejected_by_schema(self) -> None:
|
||||
xml = valid_xml().replace(b'step="0.2"', b'step="0"')
|
||||
xml = xml.replace(b'maxStep="0.01"', b'maxStep="-1"')
|
||||
|
||||
report = validate_system_xml_document(xml)
|
||||
|
||||
self.assertFalse(report.valid)
|
||||
self.assertEqual(
|
||||
[issue.code for issue in report.issues].count("XSD_VALIDATION_ERROR"),
|
||||
2,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,230 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
|
||||
|
||||
|
||||
def physical_port(
|
||||
name: str,
|
||||
role: str,
|
||||
side: str,
|
||||
*,
|
||||
domain: str = "pneumatic",
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
"name": name,
|
||||
"kind": "physical",
|
||||
"domain": domain,
|
||||
"nominalRole": role,
|
||||
"positiveFlowDirection": "intoComponent",
|
||||
"side": side,
|
||||
}
|
||||
|
||||
|
||||
def physical_connection_project(*, reverse_edge: bool = False) -> ReactFlowProjectPayload:
|
||||
source = {
|
||||
"id": "cylinder_1",
|
||||
"type": "simulationComponent",
|
||||
"position": {"x": 12.5, "y": 24.0},
|
||||
"data": {
|
||||
"label": "source-cylinder",
|
||||
"componentType": "cylinder",
|
||||
"modelType": "cylinder",
|
||||
"ports": [physical_port("port_b", "outlet", "right")],
|
||||
"parameters": {"volume": 0.01, "p0": 35000000, "T0": 300},
|
||||
},
|
||||
}
|
||||
target = {
|
||||
"id": "tank_1",
|
||||
"type": "simulationComponent",
|
||||
"position": {"x": 420.0, "y": 24.0},
|
||||
"data": {
|
||||
"label": "receiver-tank",
|
||||
"componentType": "tank",
|
||||
"modelType": "tank",
|
||||
"ports": [physical_port("port_a", "inlet", "left")],
|
||||
"parameters": {"volume": 0.1, "p0": 100000, "T0": 300},
|
||||
},
|
||||
}
|
||||
edge = (
|
||||
{
|
||||
"id": "edge-1",
|
||||
"source": "tank_1",
|
||||
"sourceHandle": "port_a",
|
||||
"target": "cylinder_1",
|
||||
"targetHandle": "port_b",
|
||||
}
|
||||
if reverse_edge
|
||||
else {
|
||||
"id": "edge-1",
|
||||
"source": "cylinder_1",
|
||||
"sourceHandle": "port_b",
|
||||
"target": "tank_1",
|
||||
"targetHandle": "port_a",
|
||||
}
|
||||
)
|
||||
return ReactFlowProjectPayload(
|
||||
name="protocol-test",
|
||||
nodes=[source, target],
|
||||
edges=[edge],
|
||||
simulation={
|
||||
"t_start": 1.0,
|
||||
"t_stop": 5.0,
|
||||
"step": 0.2,
|
||||
"max_step": 0.01,
|
||||
"method": "BDF",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SystemXmlProtocolTests(unittest.TestCase):
|
||||
def test_v2_xml_contains_port_metadata_and_neutral_physical_endpoints(self) -> None:
|
||||
root = ET.fromstring(build_reactflow_system_xml(physical_connection_project()))
|
||||
|
||||
self.assertEqual(root.tag, "System")
|
||||
self.assertEqual(root.attrib["name"], "protocol-test")
|
||||
self.assertEqual(root.attrib["schemaVersion"], "2")
|
||||
self.assertEqual(root.attrib["unitSystem"], "SI")
|
||||
self.assertEqual(
|
||||
[child.tag for child in root],
|
||||
["Simulation", "Components", "Connections"],
|
||||
)
|
||||
|
||||
simulation = root.find("Simulation")
|
||||
assert simulation is not None
|
||||
self.assertEqual(
|
||||
simulation.attrib,
|
||||
{
|
||||
"tStart": "1.0",
|
||||
"tStop": "5.0",
|
||||
"step": "0.2",
|
||||
"maxStep": "0.01",
|
||||
"method": "BDF",
|
||||
},
|
||||
)
|
||||
|
||||
port = root.find("./Components/Component[@id='cylinder_1']/Port")
|
||||
assert port is not None
|
||||
component = root.find("./Components/Component[@id='cylinder_1']")
|
||||
assert component is not None
|
||||
self.assertEqual(component.attrib["rotation"], "0")
|
||||
self.assertEqual(component.attrib["mirrored"], "false")
|
||||
self.assertEqual(
|
||||
port.attrib,
|
||||
{
|
||||
"name": "port_b",
|
||||
"kind": "physical",
|
||||
"domain": "pneumatic",
|
||||
"nominalRole": "outlet",
|
||||
"side": "right",
|
||||
"positiveFlowDirection": "intoComponent",
|
||||
},
|
||||
)
|
||||
|
||||
connection = root.find("./Connections/Connection")
|
||||
assert connection is not None
|
||||
self.assertEqual(
|
||||
connection.attrib,
|
||||
{"id": "edge-1", "kind": "physical", "domain": "pneumatic"},
|
||||
)
|
||||
self.assertNotIn("source", connection.attrib)
|
||||
self.assertEqual(
|
||||
{
|
||||
(endpoint.attrib["component"], endpoint.attrib["port"])
|
||||
for endpoint in connection.findall("Endpoint")
|
||||
},
|
||||
{("cylinder_1", "port_b"), ("tank_1", "port_a")},
|
||||
)
|
||||
self.assertTrue(
|
||||
all("role" not in endpoint.attrib for endpoint in connection.findall("Endpoint"))
|
||||
)
|
||||
|
||||
def test_physical_connection_meaning_does_not_depend_on_drag_direction(self) -> None:
|
||||
forward = ET.fromstring(
|
||||
build_reactflow_system_xml(physical_connection_project())
|
||||
)
|
||||
reverse = ET.fromstring(
|
||||
build_reactflow_system_xml(physical_connection_project(reverse_edge=True))
|
||||
)
|
||||
|
||||
def endpoint_set(root: ET.Element) -> set[tuple[str, str]]:
|
||||
return {
|
||||
(endpoint.attrib["component"], endpoint.attrib["port"])
|
||||
for endpoint in root.findall("./Connections/Connection/Endpoint")
|
||||
}
|
||||
|
||||
self.assertEqual(endpoint_set(forward), endpoint_set(reverse))
|
||||
|
||||
def test_component_orientation_is_exported_as_layout_metadata(self) -> None:
|
||||
project = physical_connection_project()
|
||||
project.nodes[0].data.rotation = 90
|
||||
project.nodes[0].data.mirrored = True
|
||||
|
||||
root = ET.fromstring(build_reactflow_system_xml(project))
|
||||
component = root.find("./Components/Component[@id='cylinder_1']")
|
||||
assert component is not None
|
||||
self.assertEqual(component.attrib["rotation"], "90")
|
||||
self.assertEqual(component.attrib["mirrored"], "true")
|
||||
|
||||
def test_legacy_string_port_is_migrated_when_exporting_v2(self) -> None:
|
||||
project = ReactFlowProjectPayload(
|
||||
name="legacy-port",
|
||||
nodes=[
|
||||
{
|
||||
"id": "tank_1",
|
||||
"data": {
|
||||
"label": "tank_1",
|
||||
"componentType": "tank",
|
||||
"modelType": "tank",
|
||||
"ports": ["port_a"],
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
root = ET.fromstring(build_reactflow_system_xml(project))
|
||||
port = root.find("./Components/Component/Port")
|
||||
assert port is not None
|
||||
self.assertEqual(port.attrib["kind"], "physical")
|
||||
self.assertEqual(port.attrib["domain"], "pneumatic")
|
||||
self.assertEqual(port.attrib["nominalRole"], "inlet")
|
||||
self.assertEqual(port.attrib["positiveFlowDirection"], "intoComponent")
|
||||
self.assertEqual(port.attrib["side"], "left")
|
||||
|
||||
def test_legacy_tee_port_names_do_not_restore_direction_constraints(self) -> None:
|
||||
project = ReactFlowProjectPayload(
|
||||
name="legacy-tee",
|
||||
nodes=[
|
||||
{
|
||||
"id": "tee_1",
|
||||
"data": {
|
||||
"label": "tee_1",
|
||||
"componentType": "tee",
|
||||
"modelType": "tee",
|
||||
"ports": ["port_in", "port_out1", "port_out2"],
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
root = ET.fromstring(build_reactflow_system_xml(project))
|
||||
ports = root.findall("./Components/Component/Port")
|
||||
self.assertEqual(
|
||||
[port.attrib["nominalRole"] for port in ports],
|
||||
["bidirectional", "bidirectional", "bidirectional"],
|
||||
)
|
||||
|
||||
def test_incompatible_physical_domains_are_rejected(self) -> None:
|
||||
project = physical_connection_project()
|
||||
tank_port = project.nodes[1].data.ports[0]
|
||||
assert not isinstance(tank_port, str)
|
||||
tank_port.domain = "hydraulic"
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "incompatible domains"):
|
||||
build_reactflow_system_xml(project)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.main import ReactFlowProjectPayload, run_reactflow_test_mql
|
||||
from app.simulation.examples.test_mql.run import prepare_test_mql_run, run_test_mql
|
||||
from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR
|
||||
from PythonModels.systems.test_mql import TestMqlRunConfig
|
||||
|
||||
|
||||
class TestMqlExampleRunnerTests(unittest.TestCase):
|
||||
def test_default_run_paths_use_app_simulation_runs_dir(self) -> None:
|
||||
prepared = prepare_test_mql_run()
|
||||
|
||||
self.assertEqual(prepared.repo_root, PROJECT_ROOT)
|
||||
self.assertEqual(prepared.output_dir.parent, SIMULATION_RUNS_DIR)
|
||||
|
||||
def test_zero_duration_run_writes_summary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output_dir = Path(directory) / "test_mql"
|
||||
result = run_test_mql(
|
||||
run_config=TestMqlRunConfig(t_start=0.0, t_stop=0.0),
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
self.assertEqual(result.result.t, [0.0])
|
||||
self.assertEqual(result.summary_path, output_dir / "test_mql_model_summary.txt")
|
||||
self.assertIn(
|
||||
"Model: test_mql",
|
||||
result.summary_path.read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_reactflow_helper_runs_fixed_topology_test_mql(self) -> None:
|
||||
payload = ReactFlowProjectPayload(
|
||||
name="test-mql-fixed",
|
||||
simulation={
|
||||
"t_start": 0.0,
|
||||
"t_stop": 0.2,
|
||||
"step": 0.1,
|
||||
"max_step": 0.001,
|
||||
"method": "BDF",
|
||||
},
|
||||
)
|
||||
|
||||
result = run_reactflow_test_mql(payload)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["model"]["name"], "test_mql")
|
||||
self.assertEqual(result["model"]["componentCount"], 117)
|
||||
self.assertEqual(result["series"]["time"], [0.0, 0.1, 0.2])
|
||||
self.assertIn("summary", result["artifacts"])
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user