From 25d8dff01ce7af91eb3d7b989fbc3f18fb02b451 Mon Sep 17 00:00:00 2001 From: Cameron Flannery Date: Wed, 26 Nov 2025 10:30:14 -0800 Subject: [PATCH 1/5] add combustion cycles and trade study --- .../data/summary.json | 36 +++++++++++++++ .../metadata.json | 9 ++++ .../data/vehicle_summary.json | 42 ++++++++++++++++++ .../metadata.json | 11 +++++ .../plots/engine_dashboard.png | Bin 0 -> 212559 bytes .../plots/mass_breakdown.png | Bin 0 -> 104097 bytes .../metadata.json | 9 ++++ .../metadata.json | 9 ++++ .../metadata.json | 9 ++++ .../data/chamber_pressure_sweep.csv | 10 +++++ .../data/grid_study.csv | 25 +++++++++++ .../data/summary.json | 31 +++++++++++++ .../metadata.json | 11 +++++ 13 files changed, 202 insertions(+) create mode 100644 outputs/cycle_comparison_20251126_102847/data/summary.json create mode 100644 outputs/cycle_comparison_20251126_102847/metadata.json create mode 100644 outputs/methalox_ssto_20251126_100146/data/vehicle_summary.json create mode 100644 outputs/methalox_ssto_20251126_100146/metadata.json create mode 100644 outputs/methalox_ssto_20251126_100146/plots/engine_dashboard.png create mode 100644 outputs/methalox_ssto_20251126_100146/plots/mass_breakdown.png create mode 100644 outputs/trade_study_results_20251126_101124/metadata.json create mode 100644 outputs/trade_study_results_20251126_101453/metadata.json create mode 100644 outputs/trade_study_results_20251126_101803/metadata.json create mode 100644 outputs/trade_study_results_20251126_101903/data/chamber_pressure_sweep.csv create mode 100644 outputs/trade_study_results_20251126_101903/data/grid_study.csv create mode 100644 outputs/trade_study_results_20251126_101903/data/summary.json create mode 100644 outputs/trade_study_results_20251126_101903/metadata.json diff --git a/outputs/cycle_comparison_20251126_102847/data/summary.json b/outputs/cycle_comparison_20251126_102847/data/summary.json new file mode 100644 index 0000000..f46175f --- /dev/null +++ b/outputs/cycle_comparison_20251126_102847/data/summary.json @@ -0,0 +1,36 @@ +{ + "requirements": { + "thrust_kN": 500.0, + "chamber_pressure_MPa": 15.0, + "propellants": "LOX/CH4", + "mixture_ratio": 3.2 + }, + "ideal_performance": { + "isp_vac_s": 355.618287029336, + "cstar_m_s": 1867.159619502034, + "mdot_kg_s": 153.79256378134085 + }, + "cycles": [ + { + "cycle": "Pressure-Fed", + "net_isp": 331.5232505089583, + "tank_pressure_MPa": 25.070814644840986, + "pump_power_kW": 0, + "efficiency": 1.0 + }, + { + "cycle": "Gas Generator", + "net_isp": 301.43986952883694, + "tank_pressure_MPa": 0.3, + "pump_power_kW": 4793.858544008899, + "efficiency": 0.9092571005685514 + }, + { + "cycle": "Staged Combustion", + "net_isp": 324.8927854987791, + "tank_pressure_MPa": 0.4, + "pump_power_kW": 4361.185940503696, + "efficiency": 0.98 + } + ] +} \ No newline at end of file diff --git a/outputs/cycle_comparison_20251126_102847/metadata.json b/outputs/cycle_comparison_20251126_102847/metadata.json new file mode 100644 index 0000000..9de79cb --- /dev/null +++ b/outputs/cycle_comparison_20251126_102847/metadata.json @@ -0,0 +1,9 @@ +{ + "name": "cycle_comparison", + "created": "2025-11-26T10:28:47.176649", + "files": [ + "data/summary.json" + ], + "completed": "2025-11-26T10:28:47.177093", + "success": true +} \ No newline at end of file diff --git a/outputs/methalox_ssto_20251126_100146/data/vehicle_summary.json b/outputs/methalox_ssto_20251126_100146/data/vehicle_summary.json new file mode 100644 index 0000000..e4954eb --- /dev/null +++ b/outputs/methalox_ssto_20251126_100146/data/vehicle_summary.json @@ -0,0 +1,42 @@ +{ + "mission": { + "delta_v_km_s": 9.5, + "target_orbit": "LEO 400 km", + "payload_kg": 1000 + }, + "engine": { + "name": "Methalox-500", + "propellants": "LOX/CH4", + "thrust_kN": 500.0, + "chamber_pressure_MPa": 10.0, + "isp_sl_s": 320.5256042628083, + "isp_vac_s": 346.6915059643235, + "mdot_kg_s": 159.06938469443352 + }, + "propellant": { + "lox_kg": 52550.773557232475, + "ch4_kg": 16422.116736635147, + "total_kg": 68972.89029386762, + "burn_time_s": 433.6025466268196 + }, + "tanks": { + "lox": { + "volume_m3": 47.438472185757625, + "diameter_m": 2.9020123520766594, + "length_m": 7.8560376383067245, + "mass_kg": 398.25268941572546 + }, + "ch4": { + "volume_m3": 40.02550932024184, + "diameter_m": 2.7422138148873865, + "length_m": 7.423447018281644, + "mass_kg": 280.0165139805067 + } + }, + "vehicle": { + "dry_mass_kg": 2578.269203396232, + "wet_mass_kg": 71551.15949726386, + "twr": 0.7125783985491686, + "structure_fraction": 0.022882457102664816 + } +} \ No newline at end of file diff --git a/outputs/methalox_ssto_20251126_100146/metadata.json b/outputs/methalox_ssto_20251126_100146/metadata.json new file mode 100644 index 0000000..12240e1 --- /dev/null +++ b/outputs/methalox_ssto_20251126_100146/metadata.json @@ -0,0 +1,11 @@ +{ + "name": "methalox_ssto", + "created": "2025-11-26T10:01:46.060424", + "files": [ + "data/vehicle_summary.json", + "plots/engine_dashboard.png", + "plots/mass_breakdown.png" + ], + "completed": "2025-11-26T10:01:46.470217", + "success": true +} \ No newline at end of file diff --git a/outputs/methalox_ssto_20251126_100146/plots/engine_dashboard.png b/outputs/methalox_ssto_20251126_100146/plots/engine_dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..d32b713b2b53b9d1bcc861433dec5fcc5e104569 GIT binary patch literal 212559 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

HDMBHNLNF%$doJZ-3@#0S|&7+v418E) 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 Apa1{> literal 0 HcmV?d00001 diff --git a/outputs/methalox_ssto_20251126_100146/plots/mass_breakdown.png b/outputs/methalox_ssto_20251126_100146/plots/mass_breakdown.png new file mode 100644 index 0000000000000000000000000000000000000000..e3867fce338815c803c9f9a3c9785d43ba767e7e GIT binary patch literal 104097 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%0AdKu!j-<>aBqxJY{WSL3$LWh@)w&58)JI?Layh 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 literal 0 HcmV?d00001 diff --git a/outputs/trade_study_results_20251126_101124/metadata.json b/outputs/trade_study_results_20251126_101124/metadata.json new file mode 100644 index 0000000..d617b5c --- /dev/null +++ b/outputs/trade_study_results_20251126_101124/metadata.json @@ -0,0 +1,9 @@ +{ + "name": "trade_study_results", + "created": "2025-11-26T10:11:24.230830", + "files": [ + "data/mixture_ratio_sweep.csv" + ], + "completed": "2025-11-26T10:11:24.235132", + "success": false +} \ No newline at end of file diff --git a/outputs/trade_study_results_20251126_101453/metadata.json b/outputs/trade_study_results_20251126_101453/metadata.json new file mode 100644 index 0000000..0f92173 --- /dev/null +++ b/outputs/trade_study_results_20251126_101453/metadata.json @@ -0,0 +1,9 @@ +{ + "name": "trade_study_results", + "created": "2025-11-26T10:14:53.688293", + "files": [ + "data/chamber_pressure_sweep.csv" + ], + "completed": "2025-11-26T10:14:53.692364", + "success": false +} \ No newline at end of file diff --git a/outputs/trade_study_results_20251126_101803/metadata.json b/outputs/trade_study_results_20251126_101803/metadata.json new file mode 100644 index 0000000..baa4a78 --- /dev/null +++ b/outputs/trade_study_results_20251126_101803/metadata.json @@ -0,0 +1,9 @@ +{ + "name": "trade_study_results", + "created": "2025-11-26T10:18:03.834991", + "files": [ + "data/chamber_pressure_sweep.csv" + ], + "completed": "2025-11-26T10:18:03.839727", + "success": false +} \ No newline at end of file diff --git a/outputs/trade_study_results_20251126_101903/data/chamber_pressure_sweep.csv b/outputs/trade_study_results_20251126_101903/data/chamber_pressure_sweep.csv new file mode 100644 index 0000000..02f8e6d --- /dev/null +++ b/outputs/trade_study_results_20251126_101903/data/chamber_pressure_sweep.csv @@ -0,0 +1,10 @@ +chamber_pressure,expansion_ratio,chamber_area,thrust_coeff_vac,chamber_volume,isp,contraction_ratio,thrust_coeff,exit_mach,chamber_diameter,isp_vac,mdot,throat_area,exit_area,chamber_length,exit_diameter,exhaust_velocity,mdot_ox,mdot_fuel,throat_diameter,cstar,nozzle_length,feasible +5.0,7.876186624172613,0.10082704335041623,1.746486770751889,0.025206760837604057,301.019889224399,4.0,1.586875848813031,2.957953479816565,0.3582973329128707,331.297029100828,67.7507533210052,0.025206760837604057,0.1985331525478551,0.20521283338589116,0.5027725736004974,2951.996696662452,51.619621577908724,16.13113174309648,0.17914866645643535,1860.2568681541907,0.4831111625577843,true +7.5,10.83051902476274,0.06469217023125673,1.7951545725751283,0.016173042557814182,312.7729915554404,4.0,1.6488342605505837,3.1491065807874272,0.2869993543079408,340.5289903311817,65.2048764125581,0.016173042557814182,0.17516244511070395,0.21412508071150738,0.4722539061431368,3067.2552576371595,49.6799058381395,15.524970574418592,0.1434996771539704,1860.2568681541907,0.49076979251734604,true +10.0,13.613407728206797,0.04734558646254097,1.827641287161869,0.011836396615635243,320.5256042628083,4.0,1.6897034333558136,3.2833310847743125,0.24552448544449687,346.6915059643235,63.62775387777341,0.011836396615635243,0.1611336931614096,0.2193094393194379,0.45294788896994387,3143.282417043869,48.47828866877974,15.149465208993668,0.12276224272224844,1860.2568681541907,0.49290663605889995,true +12.5,16.27729832096691,0.03721348009651728,1.8517506844085763,0.00930337002412932,326.2360342698122,4.0,1.7198069042188184,3.386854581730957,0.2176733204967956,351.26489971399786,62.514014753782604,0.00930337002412932,0.15143372927309406,0.22279083493790056,0.4391029634575724,3199.2826054720535,47.62972552669151,14.884289227091095,0.1088366602483978,1860.2568681541907,0.4930270421153389,true +15.0,18.85114636714012,0.030590870176051197,1.8707790567691638,0.007647717544012799,330.71899388603566,4.0,1.7434395630591322,3.471147402545745,0.1973562910474553,354.8744565324403,61.66662525160669,0.007647717544012799,0.14416824279673066,0.22533046361906808,0.42843985321603517,3243.2454213924916,46.98409542979557,14.682529821811116,0.09867814552372765,1860.2568681541907,0.49227377351745816,true +17.5,21.35315508630143,0.025933043466047308,1.8864161311991197,0.006483260866511827,334.38800589793334,4.0,1.7627813632494345,3.5422571327284817,0.1817112447161905,357.840706485907,60.98999934161398,0.006483260866511827,0.13843807474757605,0.22728609441047617,0.41983905400238336,3279.2261380389677,46.46857092694398,14.521428414669995,0.09085562235809524,1860.2568681541907,0.49111195006112696,true +20.0,23.79574885106925,0.02248348554045343,1.8996387950101252,0.005620871385113357,337.48042941959557,4.0,1.7790835823933957,3.6037640338905774,0.16919474842200788,360.3489586586384,60.4311316500132,0.005620871385113357,0.13375284380431918,0.228850656447249,0.412673490731407,3309.5524531676765,46.042766971438624,14.38836467857457,0.08459737421100394,1860.2568681541907,0.489757494921596,true +22.5,26.187906089521437,0.019828792011868247,1.9110605372156693,0.004957198002967062,340.14449966380903,4.0,1.7931276667925244,3.6579632541218174,0.1588924230850526,362.51558787291134,59.95782462957904,0.004957198002967062,0.12981863576886463,0.23013844711436843,0.4065590002748545,3335.6780576280926,45.68215209872689,14.275672530852152,0.0794462115425263,1860.2568681541907,0.4883194231433763,true +25.0,28.53639395834763,0.017724282734337772,1.9210907254407448,0.004431070683584443,342.4786872911498,4.0,1.8054327207275618,3.7064143957641447,0.15022402497412166,364.41824846899425,59.54917785065216,0.004431070683584443,0.1264467786840504,0.2312219968782348,0.4012443631067086,3358.5686187237543,45.370802171925455,14.178375678726704,0.07511201248706083,1860.2568681541907,0.4868558087874004,true diff --git a/outputs/trade_study_results_20251126_101903/data/grid_study.csv b/outputs/trade_study_results_20251126_101903/data/grid_study.csv new file mode 100644 index 0000000..b3eac07 --- /dev/null +++ b/outputs/trade_study_results_20251126_101903/data/grid_study.csv @@ -0,0 +1,25 @@ +chamber_pressure,contraction_ratio,expansion_ratio,chamber_area,thrust_coeff_vac,chamber_volume,isp,thrust_coeff,exit_mach,chamber_diameter,isp_vac,mdot,throat_area,exit_area,chamber_length,exit_diameter,exhaust_velocity,mdot_ox,mdot_fuel,throat_diameter,cstar,nozzle_length,feasible +5.0,3.0,7.876186624172613,0.07562028251281216,1.746486770751889,0.025206760837604057,301.019889224399,1.586875848813031,2.957953479816565,0.31029459241075624,331.297029100828,67.7507533210052,0.025206760837604057,0.1985331525478551,0.30054685184475316,0.5027725736004974,2951.996696662452,51.619621577908724,16.13113174309648,0.17914866645643535,1860.2568681541907,0.4831111625577843,true +5.0,4.0,7.876186624172613,0.10082704335041623,1.746486770751889,0.025206760837604057,301.019889224399,1.586875848813031,2.957953479816565,0.3582973329128707,331.297029100828,67.7507533210052,0.025206760837604057,0.1985331525478551,0.20521283338589116,0.5027725736004974,2951.996696662452,51.619621577908724,16.13113174309648,0.17914866645643535,1860.2568681541907,0.4831111625577843,true +5.0,5.0,7.876186624172613,0.1260338041880203,1.746486770751889,0.025206760837604057,301.019889224399,1.586875848813031,2.957953479816565,0.4005885962750258,331.297029100828,67.7507533210052,0.025206760837604057,0.1985331525478551,0.14464001754535236,0.5027725736004974,2951.996696662452,51.619621577908724,16.13113174309648,0.17914866645643535,1860.2568681541907,0.4831111625577843,true +5.0,6.0,7.876186624172613,0.15124056502562433,1.746486770751889,0.025206760837604057,301.019889224399,1.586875848813031,2.957953479816565,0.43882282091832314,331.297029100828,67.7507533210052,0.025206760837604057,0.1985331525478551,0.10174812805119472,0.5027725736004974,2951.996696662452,51.619621577908724,16.13113174309648,0.17914866645643535,1860.2568681541907,0.4831111625577843,true +8.0,3.0,11.398612239300189,0.045229374048974154,1.8025849624925732,0.015076458016324719,314.55234490527897,1.6582144143491868,3.179305106714798,0.23997463954087364,341.93848632389995,64.83602678498518,0.015076458016324719,0.17185069887017437,0.3079770291325167,0.4677682178086497,3084.704753165354,49.398877550464896,15.43714923452028,0.13854942273760681,1860.2568681541907,0.4914633045074857,true +8.0,4.0,11.398612239300189,0.060305832065298874,1.8025849624925732,0.015076458016324719,314.55234490527897,1.6582144143491868,3.179305106714798,0.27709884547521363,341.93848632389995,64.83602678498518,0.015076458016324719,0.17185069887017437,0.2153626443155983,0.4677682178086497,3084.704753165354,49.398877550464896,15.43714923452028,0.13854942273760681,1860.2568681541907,0.4914633045074857,true +8.0,5.0,11.398612239300189,0.07538229008162359,1.8025849624925732,0.015076458016324719,314.55234490527897,1.6582144143491868,3.179305106714798,0.3098059274846438,341.93848632389995,64.83602678498518,0.015076458016324719,0.17185069887017437,0.15718587381324076,0.4677682178086497,3084.704753165354,49.398877550464896,15.43714923452028,0.13854942273760681,1860.2568681541907,0.4914633045074857,true +8.0,6.0,11.398612239300189,0.09045874809794831,1.8025849624925732,0.015076458016324719,314.55234490527897,1.6582144143491868,3.179305106714798,0.3393753898642983,341.93848632389995,64.83602678498518,0.015076458016324719,0.17185069887017437,0.1164601748849938,0.4677682178086497,3084.704753165354,49.398877550464896,15.43714923452028,0.13854942273760681,1860.2568681541907,0.4914633045074857,true +11.0,3.0,14.691291302428622,0.03203420751978145,1.8380520932621829,0.010678069173260484,322.9957602363314,1.7027252667877666,3.327603013494704,0.20195846057652117,348.6663672626394,63.14115158860391,0.010678069173260484,0.15687462477185293,0.3119939110839473,0.4469216663186218,3167.5063721216193,48.107544067507746,15.033607521096169,0.11660077157897693,1860.2568681541907,0.49310853726192444,true +11.0,4.0,14.691291302428622,0.04271227669304194,1.8380520932621829,0.010678069173260484,322.9957602363314,1.7027252667877666,3.327603013494704,0.23320154315795386,348.6663672626394,63.14115158860391,0.010678069173260484,0.15687462477185293,0.22084980710525576,0.4469216663186218,3167.5063721216193,48.107544067507746,15.033607521096169,0.11660077157897693,1860.2568681541907,0.49310853726192444,true +11.0,5.0,14.691291302428622,0.053390345866302424,1.8380520932621829,0.010678069173260484,322.9957602363314,1.7027252667877666,3.327603013494704,0.2607272514795179,348.6663672626394,63.14115158860391,0.010678069173260484,0.15687462477185293,0.16396838002486472,0.4469216663186218,3167.5063721216193,48.107544067507746,15.033607521096169,0.11660077157897693,1860.2568681541907,0.49310853726192444,true +11.0,6.0,14.691291302428622,0.0640684150395629,1.8380520932621829,0.010678069173260484,322.9957602363314,1.7027252667877666,3.327603013494704,0.2856123939833083,348.6663672626394,63.14115158860391,0.010678069173260484,0.15687462477185293,0.12441376106558383,0.4469216663186218,3167.5063721216193,48.107544067507746,15.033607521096169,0.11660077157897693,1860.2568681541907,0.49310853726192444,true +14.0,3.0,17.831082237629115,0.02470728845597142,1.8636476130278354,0.008235762818657141,329.04126803497513,1.7345951553329948,3.4392764979945003,0.1773648688588622,353.52166865894003,61.98105295835041,0.008235762818657141,0.14685256410908365,0.31459252981372954,0.43241009686343007,3226.7925511751887,47.223659396838414,14.757393561512002,0.10240165478044679,1860.2568681541907,0.4926421027282828,true +14.0,4.0,17.831082237629115,0.032943051274628564,1.8636476130278354,0.008235762818657141,329.04126803497513,1.7345951553329948,3.4392764979945003,0.20480330956089357,353.52166865894003,61.98105295835041,0.008235762818657141,0.14685256410908365,0.2243995863048883,0.43241009686343007,3226.7925511751887,47.223659396838414,14.757393561512002,0.10240165478044679,1860.2568681541907,0.4926421027282828,true +14.0,5.0,17.831082237629115,0.04117881409328571,1.8636476130278354,0.008235762818657141,329.04126803497513,1.7345951553329948,3.4392764979945003,0.22897706109754531,353.52166865894003,61.98105295835041,0.008235762818657141,0.14685256410908365,0.16835614842072535,0.43241009686343007,3226.7925511751887,47.223659396838414,14.757393561512002,0.10240165478044679,1860.2568681541907,0.4926421027282828,true +14.0,6.0,17.831082237629115,0.04941457691194284,1.8636476130278354,0.008235762818657141,329.04126803497513,1.7345951553329948,3.4392764979945003,0.2508318030287284,353.52166865894003,61.98105295835041,0.008235762818657141,0.14685256410908365,0.12955912960459628,0.43241009686343007,3226.7925511751887,47.223659396838414,14.757393561512002,0.10240165478044679,1860.2568681541907,0.4926421027282828,true +17.0,3.0,20.8578441147071,0.02006273677941217,1.8835064797346852,0.0066875789264707235,333.70629158693737,1.7591875941509971,3.528894622816198,0.15982699973146014,357.28876478097726,61.114593202823755,0.0066875789264707235,0.1394884787531266,0.31644562373015045,0.421428816270828,3272.5408043910393,46.56349958310381,14.551093619719941,0.09227616131872876,1860.2568681541907,0.491364569435542,true +17.0,4.0,20.8578441147071,0.026750315705882894,1.8835064797346852,0.0066875789264707235,333.70629158693737,1.7591875941509971,3.528894622816198,0.18455232263745752,357.28876478097726,61.114593202823755,0.0066875789264707235,0.1394884787531266,0.22693095967031782,0.421428816270828,3272.5408043910393,46.56349958310381,14.551093619719941,0.09227616131872876,1860.2568681541907,0.491364569435542,true +17.0,5.0,20.8578441147071,0.03343789463235362,1.8835064797346852,0.0066875789264707235,333.70629158693737,1.7591875941509971,3.528894622816198,0.20633576941141413,357.28876478097726,61.114593202823755,0.0066875789264707235,0.1394884787531266,0.17148509797682868,0.421428816270828,3272.5408043910393,46.56349958310381,14.551093619719941,0.09227616131872876,1860.2568681541907,0.491364569435542,true +17.0,6.0,20.8578441147071,0.04012547355882434,1.8835064797346852,0.0066875789264707235,333.70629158693737,1.7591875941509971,3.528894622816198,0.22602951065363194,357.28876478097726,61.114593202823755,0.0066875789264707235,0.1394884787531266,0.13322832933294085,0.421428816270828,3272.5408043910393,46.56349958310381,14.551093619719941,0.09227616131872876,1860.2568681541907,0.491364569435542,true +20.0,3.0,23.79574885106925,0.016862614155340072,1.8996387950101252,0.005620871385113357,337.48042941959557,1.7790835823933957,3.6037640338905774,0.1465269503203759,360.3489586586384,60.4311316500132,0.005620871385113357,0.13375284380431918,0.31785093930599034,0.412673490731407,3309.5524531676765,46.042766971438624,14.38836467857457,0.08459737421100394,1860.2568681541907,0.489757494921596,true +20.0,4.0,23.79574885106925,0.02248348554045343,1.8996387950101252,0.005620871385113357,337.48042941959557,1.7790835823933957,3.6037640338905774,0.16919474842200788,360.3489586586384,60.4311316500132,0.005620871385113357,0.13375284380431918,0.228850656447249,0.412673490731407,3309.5524531676765,46.042766971438624,14.38836467857457,0.08459737421100394,1860.2568681541907,0.489757494921596,true +20.0,5.0,23.79574885106925,0.028104356925566787,1.8996387950101252,0.005620871385113357,337.48042941959557,1.7790835823933957,3.6037640338905774,0.18916547945379245,360.3489586586384,60.4311316500132,0.005620871385113357,0.13375284380431918,0.1738579736893029,0.412673490731407,3309.5524531676765,46.042766971438624,14.38836467857457,0.08459737421100394,1860.2568681541907,0.489757494921596,true +20.0,6.0,23.79574885106925,0.033725228310680144,1.8996387950101252,0.005620871385113357,337.48042941959557,1.7790835823933957,3.6037640338905774,0.20722040039624431,360.3489586586384,60.4311316500132,0.005620871385113357,0.13375284380431918,0.13601091012035657,0.412673490731407,3309.5524531676765,46.042766971438624,14.38836467857457,0.08459737421100394,1860.2568681541907,0.489757494921596,true diff --git a/outputs/trade_study_results_20251126_101903/data/summary.json b/outputs/trade_study_results_20251126_101903/data/summary.json new file mode 100644 index 0000000..c2fd33c --- /dev/null +++ b/outputs/trade_study_results_20251126_101903/data/summary.json @@ -0,0 +1,31 @@ +{ + "studies": { + "mixture_ratio_sweep": { + "parameter": "mixture_ratio", + "range": [ + 2.4, + 4.0 + ], + "optimal_mr": 3.0, + "optimal_isp_vac": 347.1521682740077, + "note": "Each point recalculates CEA thermochemistry" + }, + "chamber_pressure_sweep": { + "parameter": "chamber_pressure", + "range_MPa": [ + 5, + 25 + ], + "n_points": 9 + }, + "grid_study": { + "parameters": [ + "chamber_pressure", + "contraction_ratio" + ], + "total_designs": 24, + "feasible_designs": 24, + "constraint": "throat_diameter > 8 cm" + } + } +} \ No newline at end of file diff --git a/outputs/trade_study_results_20251126_101903/metadata.json b/outputs/trade_study_results_20251126_101903/metadata.json new file mode 100644 index 0000000..10a480b --- /dev/null +++ b/outputs/trade_study_results_20251126_101903/metadata.json @@ -0,0 +1,11 @@ +{ + "name": "trade_study_results", + "created": "2025-11-26T10:19:03.375007", + "files": [ + "data/chamber_pressure_sweep.csv", + "data/grid_study.csv", + "data/summary.json" + ], + "completed": "2025-11-26T10:19:03.381927", + "success": true +} \ No newline at end of file From d5ca8481dc67e9f301546a0d15f1a39d66a1adc3 Mon Sep 17 00:00:00 2001 From: Cameron Flannery Date: Wed, 26 Nov 2025 10:53:49 -0800 Subject: [PATCH 2/5] rocket migration --- .gitignore | 3 + openrocketengine/__init__.py | 61 + openrocketengine/analysis.py | 1184 +++++++++++++++++ openrocketengine/cycles/__init__.py | 55 + openrocketengine/cycles/base.py | 354 +++++ openrocketengine/cycles/gas_generator.py | 347 +++++ openrocketengine/cycles/pressure_fed.py | 288 ++++ openrocketengine/cycles/staged_combustion.py | 488 +++++++ openrocketengine/examples/basic_engine.py | 130 +- openrocketengine/examples/cycle_comparison.py | 269 ++++ openrocketengine/examples/optimization.py | 252 ++++ .../examples/propellant_design.py | 68 +- openrocketengine/examples/thermal_analysis.py | 275 ++++ openrocketengine/examples/trade_study.py | 254 ++++ .../examples/uncertainty_analysis.py | 244 ++++ openrocketengine/output.py | 271 ++++ openrocketengine/plotting.py | 376 +++++- openrocketengine/results.py | 659 +++++++++ openrocketengine/system.py | 245 ++++ openrocketengine/tanks.py | 76 ++ openrocketengine/thermal/__init__.py | 57 + openrocketengine/thermal/heat_flux.py | 306 +++++ openrocketengine/thermal/regenerative.py | 452 +++++++ openrocketengine/units.py | 29 + pyproject.toml | 9 +- rocket/__init__.py | 159 +++ rocket/analysis.py | 1184 +++++++++++++++++ rocket/cycles/__init__.py | 55 + rocket/cycles/base.py | 354 +++++ rocket/cycles/gas_generator.py | 347 +++++ rocket/cycles/pressure_fed.py | 288 ++++ rocket/cycles/staged_combustion.py | 488 +++++++ rocket/engine.py | 632 +++++++++ rocket/examples/__init__.py | 2 + rocket/examples/basic_engine.py | 249 ++++ rocket/examples/cycle_comparison.py | 312 +++++ rocket/examples/optimization.py | 252 ++++ rocket/examples/propellant_design.py | 201 +++ rocket/examples/thermal_analysis.py | 275 ++++ rocket/examples/trade_study.py | 254 ++++ rocket/examples/uncertainty_analysis.py | 244 ++++ rocket/isentropic.py | 633 +++++++++ rocket/nozzle.py | 427 ++++++ rocket/output.py | 271 ++++ rocket/plotting.py | 1023 ++++++++++++++ rocket/propellants.py | 475 +++++++ rocket/results.py | 659 +++++++++ rocket/system.py | 245 ++++ rocket/tanks.py | 76 ++ rocket/thermal/__init__.py | 57 + rocket/thermal/heat_flux.py | 306 +++++ rocket/thermal/regenerative.py | 452 +++++++ rocket/units.py | 651 +++++++++ uv.lock | 106 +- 54 files changed, 17316 insertions(+), 113 deletions(-) create mode 100644 openrocketengine/analysis.py create mode 100644 openrocketengine/cycles/__init__.py create mode 100644 openrocketengine/cycles/base.py create mode 100644 openrocketengine/cycles/gas_generator.py create mode 100644 openrocketengine/cycles/pressure_fed.py create mode 100644 openrocketengine/cycles/staged_combustion.py create mode 100644 openrocketengine/examples/cycle_comparison.py create mode 100644 openrocketengine/examples/optimization.py create mode 100644 openrocketengine/examples/thermal_analysis.py create mode 100644 openrocketengine/examples/trade_study.py create mode 100644 openrocketengine/examples/uncertainty_analysis.py create mode 100644 openrocketengine/output.py create mode 100644 openrocketengine/results.py create mode 100644 openrocketengine/system.py create mode 100644 openrocketengine/tanks.py create mode 100644 openrocketengine/thermal/__init__.py create mode 100644 openrocketengine/thermal/heat_flux.py create mode 100644 openrocketengine/thermal/regenerative.py create mode 100644 rocket/__init__.py create mode 100644 rocket/analysis.py create mode 100644 rocket/cycles/__init__.py create mode 100644 rocket/cycles/base.py create mode 100644 rocket/cycles/gas_generator.py create mode 100644 rocket/cycles/pressure_fed.py create mode 100644 rocket/cycles/staged_combustion.py create mode 100644 rocket/engine.py create mode 100644 rocket/examples/__init__.py create mode 100644 rocket/examples/basic_engine.py create mode 100644 rocket/examples/cycle_comparison.py create mode 100644 rocket/examples/optimization.py create mode 100644 rocket/examples/propellant_design.py create mode 100644 rocket/examples/thermal_analysis.py create mode 100644 rocket/examples/trade_study.py create mode 100644 rocket/examples/uncertainty_analysis.py create mode 100644 rocket/isentropic.py create mode 100644 rocket/nozzle.py create mode 100644 rocket/output.py create mode 100644 rocket/plotting.py create mode 100644 rocket/propellants.py create mode 100644 rocket/results.py create mode 100644 rocket/system.py create mode 100644 rocket/tanks.py create mode 100644 rocket/thermal/__init__.py create mode 100644 rocket/thermal/heat_flux.py create mode 100644 rocket/thermal/regenerative.py create mode 100644 rocket/units.py diff --git a/.gitignore b/.gitignore index c351f7a..4b5390a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# ignore outputs +outputs/ + # ignore prototyping files *.ipynb diff --git a/openrocketengine/__init__.py b/openrocketengine/__init__.py index fcae48e..d4f9fc5 100644 --- a/openrocketengine/__init__.py +++ b/openrocketengine/__init__.py @@ -48,8 +48,12 @@ # Visualization from openrocketengine.plotting import ( + plot_cycle_comparison_bars, + plot_cycle_radar, + plot_cycle_tradeoff, plot_engine_cross_section, plot_engine_dashboard, + plot_mass_breakdown, plot_nozzle_contour, plot_performance_vs_altitude, ) @@ -63,6 +67,37 @@ list_database_propellants, ) +# Output management +from openrocketengine.output import ( + OutputContext, + clean_outputs, + get_default_output_dir, + list_outputs, +) + +# Analysis framework +from openrocketengine.analysis import ( + Distribution, + LogNormal, + MultiObjectiveOptimizer, + Normal, + ParametricStudy, + ParetoResults, + Range, + StudyResults, + Triangular, + UncertaintyAnalysis, + UncertaintyResults, + Uniform, +) + +# System-level design +from openrocketengine.system import ( + EngineSystemResult, + design_engine_system, + format_system_summary, +) + __all__ = [ # Version "__version__", @@ -89,10 +124,36 @@ "plot_nozzle_contour", "plot_performance_vs_altitude", "plot_engine_dashboard", + "plot_mass_breakdown", + "plot_cycle_comparison_bars", + "plot_cycle_radar", + "plot_cycle_tradeoff", # Propellants "CombustionProperties", "get_combustion_properties", "get_optimal_mixture_ratio", "is_cea_available", "list_database_propellants", + # Output management + "OutputContext", + "get_default_output_dir", + "list_outputs", + "clean_outputs", + # Analysis framework + "ParametricStudy", + "UncertaintyAnalysis", + "MultiObjectiveOptimizer", + "StudyResults", + "UncertaintyResults", + "ParetoResults", + "Range", + "Distribution", + "Normal", + "Uniform", + "Triangular", + "LogNormal", + # System-level design + "EngineSystemResult", + "design_engine_system", + "format_system_summary", ] diff --git a/openrocketengine/analysis.py b/openrocketengine/analysis.py new file mode 100644 index 0000000..214ddb1 --- /dev/null +++ b/openrocketengine/analysis.py @@ -0,0 +1,1184 @@ +"""Parametric analysis and uncertainty quantification for Rocket. + +This module provides general-purpose tools for trade studies, sensitivity +analysis, and uncertainty quantification. The design is introspection-based +to avoid brittleness when dataclass fields change. + +Key Design Principles: +- Works with ANY frozen dataclass + computation function +- Uses dataclass introspection to validate parameters (no hardcoding) +- Automatically discovers output metrics from return types +- Unit-aware parameter ranges + +Example: + >>> from openrocketengine import EngineInputs, design_engine + >>> from openrocketengine.analysis import ParametricStudy, Range + >>> + >>> study = ParametricStudy( + ... compute=design_engine, + ... base=inputs, + ... vary={"chamber_pressure": Range(5, 15, n=11, unit="MPa")}, + ... ) + >>> results = study.run() + >>> results.plot("chamber_pressure", "isp_vac") +""" + +import dataclasses +import itertools +from collections.abc import Callable, Sequence +from dataclasses import dataclass, fields, is_dataclass, replace +from pathlib import Path +from typing import Any, Generic, TypeVar + +import numpy as np +import polars as pl +from beartype import beartype +from numpy.typing import NDArray +from tqdm import tqdm + +from openrocketengine.units import CONVERSIONS, Quantity + +# Type variables for generic analysis +T_Input = TypeVar("T_Input") # Input dataclass type +T_Output = TypeVar("T_Output") # Output type (can be dataclass or tuple) + + +# ============================================================================= +# Parameter Range Specifications +# ============================================================================= + + +@beartype +@dataclass(frozen=True, slots=True) +class Range: + """Specification for a parameter range in a parametric study. + + Supports both dimensionless parameters and Quantity fields with units. + + Examples: + >>> Range(5, 15, n=11, unit="MPa") # 5-15 MPa in 11 steps + >>> Range(2.0, 3.5, n=5) # Dimensionless parameter + >>> Range(values=[2.5, 2.7, 3.0, 3.2]) # Explicit values + """ + + start: float | int | None = None + stop: float | int | None = None + n: int = 10 + unit: str | None = None + values: Sequence[float | int] | None = None + + def __post_init__(self) -> None: + """Validate range specification.""" + if self.values is not None: + if self.start is not None or self.stop is not None: + raise ValueError("Cannot specify both values and start/stop") + else: + if self.start is None or self.stop is None: + raise ValueError("Must specify either values or start/stop") + + def generate(self) -> NDArray[np.float64]: + """Generate array of parameter values.""" + if self.values is not None: + return np.array(self.values, dtype=np.float64) + return np.linspace(self.start, self.stop, self.n) + + def to_quantities(self, dimension: str) -> list[Quantity]: + """Convert range values to Quantity objects. + + Args: + dimension: The dimension of the quantity (e.g., "pressure") + + Returns: + List of Quantity objects + """ + values = self.generate() + if self.unit is None: + raise ValueError(f"Unit required to convert to Quantity for dimension {dimension}") + + return [Quantity(float(v), self.unit, dimension) for v in values] + + +@beartype +@dataclass(frozen=True, slots=True) +class Distribution: + """Base class for probability distributions in uncertainty analysis.""" + + pass + + +@beartype +@dataclass(frozen=True, slots=True) +class Normal(Distribution): + """Normal (Gaussian) distribution. + + Args: + mean: Distribution mean + std: Standard deviation + unit: Optional unit for Quantity fields + """ + + mean: float | int + std: float | int + unit: str | None = None + + def sample(self, n: int, rng: np.random.Generator) -> NDArray[np.float64]: + """Generate n samples from the distribution.""" + return rng.normal(self.mean, self.std, n) + + +@beartype +@dataclass(frozen=True, slots=True) +class Uniform(Distribution): + """Uniform distribution. + + Args: + low: Lower bound + high: Upper bound + unit: Optional unit for Quantity fields + """ + + low: float | int + high: float | int + unit: str | None = None + + def sample(self, n: int, rng: np.random.Generator) -> NDArray[np.float64]: + """Generate n samples from the distribution.""" + return rng.uniform(self.low, self.high, n) + + +@beartype +@dataclass(frozen=True, slots=True) +class Triangular(Distribution): + """Triangular distribution. + + Args: + low: Lower bound + mode: Most likely value + high: Upper bound + unit: Optional unit for Quantity fields + """ + + low: float | int + mode: float | int + high: float | int + unit: str | None = None + + def sample(self, n: int, rng: np.random.Generator) -> NDArray[np.float64]: + """Generate n samples from the distribution.""" + return rng.triangular(self.low, self.mode, self.high, n) + + +@beartype +@dataclass(frozen=True, slots=True) +class LogNormal(Distribution): + """Log-normal distribution. + + Args: + mean: Mean of the underlying normal distribution + sigma: Standard deviation of the underlying normal distribution + unit: Optional unit for Quantity fields + """ + + mean: float | int + sigma: float | int + unit: str | None = None + + def sample(self, n: int, rng: np.random.Generator) -> NDArray[np.float64]: + """Generate n samples from the distribution.""" + return rng.lognormal(self.mean, self.sigma, n) + + +# ============================================================================= +# Introspection Utilities +# ============================================================================= + + +def _get_dataclass_fields(obj: Any) -> dict[str, dataclasses.Field]: + """Get all fields from a dataclass, including nested ones.""" + if not is_dataclass(obj): + raise TypeError(f"Expected dataclass, got {type(obj).__name__}") + return {f.name: f for f in fields(obj)} + + +def _get_field_info(base: Any, field_name: str) -> tuple[Any, str | None]: + """Get the current value and dimension (if Quantity) of a field. + + Args: + base: The base dataclass instance + field_name: Name of the field to inspect + + Returns: + Tuple of (current_value, dimension_or_none) + """ + if not hasattr(base, field_name): + raise ValueError(f"Field '{field_name}' not found in {type(base).__name__}") + + value = getattr(base, field_name) + + if isinstance(value, Quantity): + return value, value.dimension + return value, None + + +def _create_modified_input( + base: T_Input, + field_name: str, + value: float | Quantity, + original_dimension: str | None, +) -> T_Input: + """Create a modified copy of the input with one field changed. + + Handles both Quantity and plain numeric fields. + """ + current_value = getattr(base, field_name) + + if isinstance(current_value, Quantity): + # Field is a Quantity - ensure we create a proper Quantity + if isinstance(value, Quantity): + new_value = value + else: + # Value is numeric, need unit from original + new_value = Quantity(float(value), current_value.unit, current_value.dimension) + else: + # Field is a plain numeric type + new_value = value + + return replace(base, **{field_name: new_value}) + + +def _extract_metrics(result: Any, prefix: str = "") -> dict[str, float]: + """Recursively extract all numeric values from a result. + + Handles dataclasses, tuples, and nested structures. + Returns flat dict with keys for nested values. + + For tuples of dataclasses (common pattern like (Performance, Geometry)), + fields are extracted without prefixes to keep names clean. + """ + metrics: dict[str, float] = {} + + if isinstance(result, tuple): + # Handle tuple of results (e.g., (performance, geometry)) + # Extract fields directly without prefixes for cleaner column names + for item in result: + metrics.update(_extract_metrics(item, prefix)) + + elif is_dataclass(result) and not isinstance(result, type): + # Handle dataclass + for field in fields(result): + field_value = getattr(result, field.name) + field_key = f"{prefix}{field.name}" if prefix else field.name + + if isinstance(field_value, Quantity): + metrics[field_key] = float(field_value.value) + elif isinstance(field_value, (int, float)): + metrics[field_key] = float(field_value) + elif is_dataclass(field_value): + metrics.update(_extract_metrics(field_value, f"{field_key}.")) + + return metrics + + +# ============================================================================= +# Study Results +# ============================================================================= + + +@beartype +@dataclass +class StudyResults: + """Results from a parametric study or uncertainty analysis. + + Contains all input combinations, computed outputs, and extracted metrics. + Provides methods for plotting, filtering, and export. + + Attributes: + inputs: List of input parameter combinations + outputs: List of computed results + metrics: Dict mapping metric names to arrays of values + parameters: Dict mapping parameter names to arrays of values + constraints_passed: Boolean array indicating which runs passed constraints + """ + + inputs: list[Any] + outputs: list[Any] + metrics: dict[str, NDArray[np.float64]] + parameters: dict[str, NDArray[np.float64]] + constraints_passed: NDArray[np.bool_] | None = None + + @property + def n_runs(self) -> int: + """Number of runs in the study.""" + return len(self.inputs) + + @property + def n_feasible(self) -> int: + """Number of runs that passed all constraints.""" + if self.constraints_passed is None: + return self.n_runs + return int(np.sum(self.constraints_passed)) + + def get_metric(self, name: str, feasible_only: bool = False) -> NDArray[np.float64]: + """Get values for a specific metric. + + Args: + name: Metric name (e.g., "isp", "throat_diameter") + feasible_only: If True, only return values where constraints passed + + Returns: + Array of metric values + """ + if name not in self.metrics: + available = list(self.metrics.keys()) + raise ValueError(f"Unknown metric '{name}'. Available: {available}") + + values = self.metrics[name] + if feasible_only and self.constraints_passed is not None: + return values[self.constraints_passed] + return values + + def get_parameter(self, name: str, feasible_only: bool = False) -> NDArray[np.float64]: + """Get values for a specific input parameter. + + Args: + name: Parameter name (e.g., "chamber_pressure") + feasible_only: If True, only return values where constraints passed + + Returns: + Array of parameter values + """ + if name not in self.parameters: + available = list(self.parameters.keys()) + raise ValueError(f"Unknown parameter '{name}'. Available: {available}") + + values = self.parameters[name] + if feasible_only and self.constraints_passed is not None: + return values[self.constraints_passed] + return values + + def get_best( + self, + metric: str, + maximize: bool = True, + feasible_only: bool = True, + ) -> tuple[Any, Any, float]: + """Get the best run according to a metric. + + Args: + metric: Metric to optimize + maximize: If True, find maximum; if False, find minimum + feasible_only: Only consider runs that passed constraints + + Returns: + Tuple of (best_input, best_output, best_metric_value) + """ + values = self.get_metric(metric, feasible_only=False) + mask = self.constraints_passed if feasible_only and self.constraints_passed is not None else np.ones(len(values), dtype=bool) + + if not np.any(mask): + raise ValueError("No feasible solutions found") + + masked_values = np.where(mask, values, -np.inf if maximize else np.inf) + best_idx = int(np.argmax(masked_values) if maximize else np.argmin(masked_values)) + + return self.inputs[best_idx], self.outputs[best_idx], float(values[best_idx]) + + def to_dataframe(self) -> pl.DataFrame: + """Export results to a Polars DataFrame. + + Returns: + Polars DataFrame with parameters and metrics + """ + data = {**self.parameters, **self.metrics} + if self.constraints_passed is not None: + data["feasible"] = self.constraints_passed + return pl.DataFrame(data) + + def to_csv(self, path: str | Path) -> None: + """Export results to CSV file. + + Args: + path: Output file path + """ + df = self.to_dataframe() + df.write_csv(path) + + def list_metrics(self) -> list[str]: + """List all available metric names.""" + return list(self.metrics.keys()) + + def list_parameters(self) -> list[str]: + """List all varied parameter names.""" + return list(self.parameters.keys()) + + +# ============================================================================= +# Parametric Study +# ============================================================================= + + +@beartype +class ParametricStudy(Generic[T_Input, T_Output]): + """General-purpose parametric study framework. + + Runs a computation over a grid of parameter variations, automatically + discovering valid parameters through dataclass introspection. + + This design is non-brittle: + - Adding new fields to input dataclasses automatically makes them available + - No hardcoded parameter names + - Works with any frozen dataclass + computation function + + Example: + >>> study = ParametricStudy( + ... compute=design_engine, + ... base=inputs, + ... vary={ + ... "chamber_pressure": Range(5, 15, n=11, unit="MPa"), + ... "mixture_ratio": Range(2.5, 3.5, n=5), + ... }, + ... ) + >>> results = study.run() + """ + + def __init__( + self, + compute: Callable[[T_Input], T_Output], + base: T_Input, + vary: dict[str, Range | Sequence[Any]], + constraints: list[Callable[[T_Output], bool]] | None = None, + ) -> None: + """Initialize parametric study. + + Args: + compute: Function that takes input and returns output + base: Base input dataclass with default values + vary: Dict mapping field names to Range specifications or plain sequences. + Use Range for unit-aware sweeps, or a plain list for discrete values. + constraints: Optional list of constraint functions. + Each takes output and returns True if feasible. + """ + self.compute = compute + self.base = base + self.vary = vary + self.constraints = constraints or [] + + # Validate that all varied parameters exist in the base dataclass + self._validate_parameters() + + def _validate_parameters(self) -> None: + """Validate that all varied parameters exist and have compatible types.""" + valid_fields = _get_dataclass_fields(self.base) + + for param_name, param_spec in self.vary.items(): + if param_name not in valid_fields: + raise ValueError( + f"Parameter '{param_name}' not found in {type(self.base).__name__}. " + f"Valid fields: {list(valid_fields.keys())}" + ) + + # Check unit compatibility for Quantity fields (only if Range is used) + current_value = getattr(self.base, param_name) + if isinstance(current_value, Quantity) and isinstance(param_spec, Range): + if param_spec.unit is None: + raise ValueError( + f"Parameter '{param_name}' is a Quantity, but no unit specified in Range. " + f"Current unit: {current_value.unit}" + ) + # Verify unit is valid and has compatible dimension + if param_spec.unit not in CONVERSIONS: + raise ValueError(f"Unknown unit '{param_spec.unit}' for parameter '{param_name}'") + + range_dim = CONVERSIONS[param_spec.unit][1] + if range_dim != current_value.dimension: + raise ValueError( + f"Unit '{param_spec.unit}' has dimension '{range_dim}', " + f"but field '{param_name}' has dimension '{current_value.dimension}'" + ) + + def _generate_grid(self) -> list[dict[str, float | Quantity]]: + """Generate all parameter combinations.""" + # Generate values for each parameter + param_values: dict[str, list[Any]] = {} + + for param_name, param_spec in self.vary.items(): + current_value = getattr(self.base, param_name) + + if isinstance(param_spec, Range): + # Range specification + if isinstance(current_value, Quantity): + # Generate Quantity values + param_values[param_name] = param_spec.to_quantities(current_value.dimension) + else: + # Generate plain numeric values + param_values[param_name] = list(param_spec.generate()) + else: + # Plain sequence - use values as-is + param_values[param_name] = list(param_spec) + + # Generate all combinations + keys = list(param_values.keys()) + value_lists = [param_values[k] for k in keys] + combinations = list(itertools.product(*value_lists)) + + return [dict(zip(keys, combo, strict=True)) for combo in combinations] + + def run(self, progress: bool = False) -> StudyResults: + """Run the parametric study. + + Args: + progress: If True, print progress (requires tqdm for fancy progress bar) + + Returns: + StudyResults containing all inputs, outputs, and extracted metrics + """ + grid = self._generate_grid() + n_total = len(grid) + + inputs_list: list[T_Input] = [] + outputs_list: list[T_Output] = [] + all_metrics: list[dict[str, float]] = [] + all_params: list[dict[str, float]] = [] + constraints_passed: list[bool] = [] + + # Create iterator with optional progress bar + iterator: Any = grid + if progress: + iterator = tqdm(grid, desc="Running study", total=n_total) + + for i, param_combo in enumerate(iterator): + # Create modified input + modified_input = self.base + for param_name, param_value in param_combo.items(): + modified_input = _create_modified_input( + modified_input, + param_name, + param_value, + current_value.dimension if isinstance((current_value := getattr(self.base, param_name)), Quantity) else None, + ) + + # Run computation + try: + output = self.compute(modified_input) + success = True + except Exception as e: + # Store None for failed runs + output = None # type: ignore + success = False + if progress and not hasattr(iterator, 'set_postfix'): + print(f" Run {i+1}/{n_total} failed: {e}") + + inputs_list.append(modified_input) + outputs_list.append(output) + + # Extract metrics + if success and output is not None: + metrics = _extract_metrics(output) + all_metrics.append(metrics) + + # Check constraints + passed = all(constraint(output) for constraint in self.constraints) + else: + all_metrics.append({}) + passed = False + + constraints_passed.append(passed) + + # Extract parameter values (numeric form for plotting) + param_dict: dict[str, float] = {} + for param_name, param_value in param_combo.items(): + if isinstance(param_value, Quantity): + param_dict[param_name] = float(param_value.value) + else: + param_dict[param_name] = float(param_value) + all_params.append(param_dict) + + # Consolidate metrics into arrays + if all_metrics and all_metrics[0]: + metric_names = set() + for m in all_metrics: + metric_names.update(m.keys()) + + metrics_arrays = { + name: np.array([m.get(name, np.nan) for m in all_metrics]) + for name in metric_names + } + else: + metrics_arrays = {} + + # Consolidate parameters into arrays + if all_params: + param_names = list(all_params[0].keys()) + params_arrays = { + name: np.array([p[name] for p in all_params]) + for name in param_names + } + else: + params_arrays = {} + + return StudyResults( + inputs=inputs_list, + outputs=outputs_list, + metrics=metrics_arrays, + parameters=params_arrays, + constraints_passed=np.array(constraints_passed), + ) + + +# ============================================================================= +# Uncertainty Analysis +# ============================================================================= + + +@beartype +class UncertaintyAnalysis(Generic[T_Input, T_Output]): + """Monte Carlo uncertainty quantification. + + Samples input parameters from specified distributions and propagates + uncertainty through the computation. + + Example: + >>> analysis = UncertaintyAnalysis( + ... compute=design_engine, + ... base=inputs, + ... distributions={ + ... "gamma": Normal(1.22, 0.02), + ... "chamber_temp": Normal(3200, 50, unit="K"), + ... }, + ... ) + >>> results = analysis.run(n_samples=1000) + >>> print(f"Isp = {results.mean('isp'):.1f} ± {results.std('isp'):.1f} s") + """ + + def __init__( + self, + compute: Callable[[T_Input], T_Output], + base: T_Input, + distributions: dict[str, Distribution], + constraints: list[Callable[[T_Output], bool]] | None = None, + seed: int | None = None, + ) -> None: + """Initialize uncertainty analysis. + + Args: + compute: Function that takes input and returns output + base: Base input dataclass with nominal values + distributions: Dict mapping field names to Distribution specifications + constraints: Optional constraint functions + seed: Random seed for reproducibility + """ + self.compute = compute + self.base = base + self.distributions = distributions + self.constraints = constraints or [] + self.rng = np.random.default_rng(seed) + + self._validate_parameters() + + def _validate_parameters(self) -> None: + """Validate that all uncertain parameters exist.""" + valid_fields = _get_dataclass_fields(self.base) + + for param_name, dist in self.distributions.items(): + if param_name not in valid_fields: + raise ValueError( + f"Parameter '{param_name}' not found in {type(self.base).__name__}. " + f"Valid fields: {list(valid_fields.keys())}" + ) + + # Check unit compatibility for Quantity fields + current_value = getattr(self.base, param_name) + if isinstance(current_value, Quantity) and (not hasattr(dist, 'unit') or dist.unit is None): # type: ignore + raise ValueError( + f"Parameter '{param_name}' is a Quantity, but no unit specified in Distribution" + ) + + def run(self, n_samples: int = 1000, progress: bool = False) -> "UncertaintyResults": + """Run Monte Carlo uncertainty analysis. + + Args: + n_samples: Number of Monte Carlo samples + progress: If True, show progress indicator + + Returns: + UncertaintyResults with statistics and samples + """ + # Generate all samples upfront + samples: dict[str, NDArray[np.float64]] = {} + for param_name, dist in self.distributions.items(): + samples[param_name] = dist.sample(n_samples, self.rng) + + inputs_list: list[T_Input] = [] + outputs_list: list[T_Output] = [] + all_metrics: list[dict[str, float]] = [] + constraints_passed: list[bool] = [] + + iterator: Any = range(n_samples) + if progress: + iterator = tqdm(range(n_samples), desc="Sampling") + + for i in iterator: + # Create modified input with sampled values + modified_input = self.base + + for param_name, dist in self.distributions.items(): + sampled_value = samples[param_name][i] + current_value = getattr(self.base, param_name) + + if isinstance(current_value, Quantity): + # Create Quantity with sampled value + unit = dist.unit # type: ignore + new_value = Quantity(float(sampled_value), unit, current_value.dimension) + else: + new_value = float(sampled_value) + + modified_input = replace(modified_input, **{param_name: new_value}) + + # Run computation + try: + output = self.compute(modified_input) + success = True + except Exception: + output = None # type: ignore + success = False + + inputs_list.append(modified_input) + outputs_list.append(output) + + if success and output is not None: + metrics = _extract_metrics(output) + all_metrics.append(metrics) + passed = all(constraint(output) for constraint in self.constraints) + else: + all_metrics.append({}) + passed = False + + constraints_passed.append(passed) + + # Consolidate metrics + if all_metrics and all_metrics[0]: + metric_names = set() + for m in all_metrics: + metric_names.update(m.keys()) + + metrics_arrays = { + name: np.array([m.get(name, np.nan) for m in all_metrics]) + for name in metric_names + } + else: + metrics_arrays = {} + + return UncertaintyResults( + inputs=inputs_list, + outputs=outputs_list, + metrics=metrics_arrays, + samples=samples, + constraints_passed=np.array(constraints_passed), + n_samples=n_samples, + ) + + +@beartype +@dataclass +class UncertaintyResults: + """Results from uncertainty analysis. + + Provides statistical summaries and access to all samples. + """ + + inputs: list[Any] + outputs: list[Any] + metrics: dict[str, NDArray[np.float64]] + samples: dict[str, NDArray[np.float64]] + constraints_passed: NDArray[np.bool_] + n_samples: int + + def mean(self, metric: str, feasible_only: bool = False) -> float: + """Get mean value of a metric.""" + values = self._get_values(metric, feasible_only) + return float(np.nanmean(values)) + + def std(self, metric: str, feasible_only: bool = False) -> float: + """Get standard deviation of a metric.""" + values = self._get_values(metric, feasible_only) + return float(np.nanstd(values)) + + def percentile( + self, metric: str, p: float | Sequence[float], feasible_only: bool = False + ) -> float | NDArray[np.float64]: + """Get percentile(s) of a metric. + + Args: + metric: Metric name + p: Percentile(s) to compute (0-100) + feasible_only: Only use feasible samples + + Returns: + Percentile value(s) + """ + values = self._get_values(metric, feasible_only) + result = np.nanpercentile(values, p) + if isinstance(p, (int, float)): + return float(result) + return result + + def confidence_interval( + self, metric: str, confidence: float = 0.95, feasible_only: bool = False + ) -> tuple[float, float]: + """Get confidence interval for a metric. + + Args: + metric: Metric name + confidence: Confidence level (0-1), default 0.95 for 95% CI + feasible_only: Only use feasible samples + + Returns: + Tuple of (lower_bound, upper_bound) + """ + alpha = 1 - confidence + lower_p = alpha / 2 * 100 + upper_p = (1 - alpha / 2) * 100 + + values = self._get_values(metric, feasible_only) + return ( + float(np.nanpercentile(values, lower_p)), + float(np.nanpercentile(values, upper_p)), + ) + + def probability_of_success(self) -> float: + """Get fraction of samples that passed all constraints.""" + return float(np.mean(self.constraints_passed)) + + def _get_values(self, metric: str, feasible_only: bool) -> NDArray[np.float64]: + """Get metric values, optionally filtered to feasible only.""" + if metric not in self.metrics: + available = list(self.metrics.keys()) + raise ValueError(f"Unknown metric '{metric}'. Available: {available}") + + values = self.metrics[metric] + if feasible_only: + values = values[self.constraints_passed] + return values + + def summary(self, metrics: list[str] | None = None) -> str: + """Generate a text summary of uncertainty results. + + Args: + metrics: List of metrics to summarize. If None, summarizes all. + + Returns: + Formatted string summary + """ + if metrics is None: + metrics = [m for m in self.metrics if not m.endswith("_si")] + + lines = [ + "Uncertainty Analysis Results", + "=" * 50, + f"Samples: {self.n_samples}", + f"Feasible: {np.sum(self.constraints_passed)} ({self.probability_of_success()*100:.1f}%)", + "", + f"{'Metric':<25} {'Mean':>12} {'Std':>12} {'95% CI':>20}", + "-" * 70, + ] + + for metric in metrics: + if metric in self.metrics: + mean = self.mean(metric) + std = self.std(metric) + ci = self.confidence_interval(metric) + lines.append( + f"{metric:<25} {mean:>12.4g} {std:>12.4g} [{ci[0]:.4g}, {ci[1]:.4g}]" + ) + + return "\n".join(lines) + + def to_dataframe(self) -> pl.DataFrame: + """Export results to Polars DataFrame.""" + data = {**self.samples, **self.metrics, "feasible": self.constraints_passed} + return pl.DataFrame(data) + + def to_csv(self, path: str | Path) -> None: + """Export results to CSV file. + + Args: + path: Output file path + """ + df = self.to_dataframe() + df.write_csv(path) + + +# ============================================================================= +# Multi-Objective Optimization +# ============================================================================= + + +@beartype +def compute_pareto_front( + objectives: NDArray[np.float64], + maximize: Sequence[bool], +) -> NDArray[np.bool_]: + """Identify Pareto-optimal points in a set of objectives. + + A point is Pareto-optimal if no other point dominates it (i.e., no + other point is better in all objectives simultaneously). + + Args: + objectives: Array of shape (n_points, n_objectives) + maximize: List of booleans indicating whether to maximize each objective + + Returns: + Boolean array indicating which points are Pareto-optimal + """ + n_points = objectives.shape[0] + is_pareto = np.ones(n_points, dtype=bool) + + # Flip signs for maximization (we'll minimize internally) + obj = objectives.copy() + for i, is_max in enumerate(maximize): + if is_max: + obj[:, i] = -obj[:, i] + + for i in range(n_points): + if not is_pareto[i]: + continue + + for j in range(n_points): + if i == j or not is_pareto[j]: + continue + + # j dominates i if j is <= in all objectives and < in at least one + if np.all(obj[j] <= obj[i]) and np.any(obj[j] < obj[i]): + is_pareto[i] = False + break + + return is_pareto + + +@beartype +class MultiObjectiveOptimizer(Generic[T_Input, T_Output]): + """Multi-objective optimizer for finding Pareto-optimal designs. + + Uses a combination of grid search and local refinement to find + designs on the Pareto frontier. + + Example: + >>> optimizer = MultiObjectiveOptimizer( + ... compute=design_engine, + ... base=inputs, + ... objectives=["isp_vac", "thrust_to_weight"], + ... maximize=[True, True], + ... vary={"chamber_pressure": Range(5, 20, n=10, unit="MPa")}, + ... ) + >>> pareto_results = optimizer.run() + """ + + def __init__( + self, + compute: Callable[[T_Input], T_Output], + base: T_Input, + objectives: list[str], + maximize: list[bool], + vary: dict[str, Range | Sequence[Any]], + constraints: list[Callable[[T_Output], bool]] | None = None, + ) -> None: + """Initialize multi-objective optimizer. + + Args: + compute: Function that takes input and returns output + base: Base input dataclass + objectives: List of metric names to optimize + maximize: List of booleans for each objective (True = maximize) + vary: Dict mapping field names to Range specifications or plain sequences + constraints: Optional constraint functions + """ + self.compute = compute + self.base = base + self.objectives = objectives + self.maximize = maximize + self.vary = vary + self.constraints = constraints or [] + + if len(objectives) != len(maximize): + raise ValueError("objectives and maximize must have same length") + + def run(self, progress: bool = False) -> "ParetoResults": + """Run the multi-objective optimization. + + First performs a parametric sweep, then identifies Pareto-optimal points. + + Args: + progress: If True, show progress indicator + + Returns: + ParetoResults with Pareto-optimal designs + """ + # Run parametric study + study = ParametricStudy( + compute=self.compute, + base=self.base, + vary=self.vary, + constraints=self.constraints, + ) + results = study.run(progress=progress) + + # Extract objectives + obj_arrays = [] + for obj_name in self.objectives: + if obj_name not in results.metrics: + raise ValueError(f"Objective '{obj_name}' not found in results") + obj_arrays.append(results.get_metric(obj_name)) + + objectives_matrix = np.column_stack(obj_arrays) + + # Filter to feasible points + if results.constraints_passed is not None: + feasible_mask = results.constraints_passed + else: + feasible_mask = np.ones(len(objectives_matrix), dtype=bool) + + # Remove NaN values + valid_mask = feasible_mask & ~np.any(np.isnan(objectives_matrix), axis=1) + valid_indices = np.where(valid_mask)[0] + + if len(valid_indices) == 0: + return ParetoResults( + all_results=results, + pareto_indices=[], + pareto_inputs=[], + pareto_outputs=[], + pareto_objectives=np.array([]).reshape(0, len(self.objectives)), + objective_names=self.objectives, + maximize=self.maximize, + ) + + # Compute Pareto front on valid points only + valid_objectives = objectives_matrix[valid_mask] + is_pareto = compute_pareto_front(valid_objectives, self.maximize) + + # Map back to original indices + pareto_indices = valid_indices[is_pareto].tolist() + pareto_inputs = [results.inputs[i] for i in pareto_indices] + pareto_outputs = [results.outputs[i] for i in pareto_indices] + pareto_objectives = valid_objectives[is_pareto] + + return ParetoResults( + all_results=results, + pareto_indices=pareto_indices, + pareto_inputs=pareto_inputs, + pareto_outputs=pareto_outputs, + pareto_objectives=pareto_objectives, + objective_names=self.objectives, + maximize=self.maximize, + ) + + +@beartype +@dataclass +class ParetoResults: + """Results from multi-objective optimization. + + Contains the Pareto-optimal designs and full study results. + """ + + all_results: StudyResults + pareto_indices: list[int] + pareto_inputs: list[Any] + pareto_outputs: list[Any] + pareto_objectives: NDArray[np.float64] + objective_names: list[str] + maximize: list[bool] + + @property + def n_pareto(self) -> int: + """Number of Pareto-optimal points.""" + return len(self.pareto_indices) + + def get_best(self, objective: str) -> tuple[Any, Any, float]: + """Get the best design for a specific objective. + + Args: + objective: Name of objective to optimize + + Returns: + Tuple of (input, output, objective_value) + """ + if objective not in self.objective_names: + raise ValueError(f"Unknown objective: {objective}") + + obj_idx = self.objective_names.index(objective) + values = self.pareto_objectives[:, obj_idx] + + if self.maximize[obj_idx]: + best_idx = int(np.argmax(values)) + else: + best_idx = int(np.argmin(values)) + + return ( + self.pareto_inputs[best_idx], + self.pareto_outputs[best_idx], + float(values[best_idx]), + ) + + def get_compromise(self, weights: list[float] | None = None) -> tuple[Any, Any]: + """Get a compromise solution from the Pareto front. + + Uses weighted sum of normalized objectives. + + Args: + weights: Weights for each objective (default: equal weights) + + Returns: + Tuple of (input, output) for the compromise solution + """ + if weights is None: + weights = [1.0 / len(self.objectives) for _ in self.objective_names] + + if len(weights) != len(self.objective_names): + raise ValueError("weights must have same length as objectives") + + # Normalize objectives to [0, 1] + obj_norm = self.pareto_objectives.copy() + for i in range(obj_norm.shape[1]): + col = obj_norm[:, i] + col_min, col_max = np.min(col), np.max(col) + if col_max > col_min: + obj_norm[:, i] = (col - col_min) / (col_max - col_min) + else: + obj_norm[:, i] = 0.5 + + # Flip if minimizing + if not self.maximize[i]: + obj_norm[:, i] = 1 - obj_norm[:, i] + + # Weighted sum + scores = np.sum(obj_norm * np.array(weights), axis=1) + best_idx = int(np.argmax(scores)) + + return self.pareto_inputs[best_idx], self.pareto_outputs[best_idx] + + def summary(self) -> str: + """Generate text summary of Pareto results.""" + lines = [ + "Multi-Objective Optimization Results", + "=" * 50, + f"Total designs evaluated: {self.all_results.n_runs}", + f"Feasible designs: {self.all_results.n_feasible}", + f"Pareto-optimal designs: {self.n_pareto}", + "", + "Objectives:", + ] + + for i, (name, is_max) in enumerate(zip(self.objective_names, self.maximize, strict=True)): + direction = "maximize" if is_max else "minimize" + if self.n_pareto > 0: + values = self.pareto_objectives[:, i] + lines.append( + f" {name} ({direction}): " + f"range [{np.min(values):.4g}, {np.max(values):.4g}]" + ) + else: + lines.append(f" {name} ({direction}): no feasible points") + + return "\n".join(lines) + diff --git a/openrocketengine/cycles/__init__.py b/openrocketengine/cycles/__init__.py new file mode 100644 index 0000000..d211daa --- /dev/null +++ b/openrocketengine/cycles/__init__.py @@ -0,0 +1,55 @@ +"""Engine cycle analysis module for Rocket. + +This module provides analysis tools for different rocket engine cycles: +- Pressure-fed (simplest) +- Gas generator (turbopump-fed with separate combustion) +- Expander (turbine driven by heated propellant) +- Staged combustion (preburner exhaust into main chamber) + +Each cycle type has different performance characteristics, complexity, +and feasibility constraints. + +Example: + >>> from openrocketengine import EngineInputs, design_engine + >>> from openrocketengine.cycles import GasGeneratorCycle, analyze_cycle + >>> + >>> inputs = EngineInputs.from_propellants("LOX", "CH4", ...) + >>> performance, geometry = design_engine(inputs) + >>> + >>> cycle = GasGeneratorCycle( + ... turbine_inlet_temp=kelvin(900), + ... pump_efficiency=0.70, + ... ) + >>> result = analyze_cycle(inputs, performance, geometry, cycle) + >>> print(f"Net Isp: {result.net_isp.value:.1f} s") +""" + +from openrocketengine.cycles.base import ( + CycleConfiguration, + CyclePerformance, + CycleType, + analyze_cycle, + format_cycle_summary, +) +from openrocketengine.cycles.gas_generator import GasGeneratorCycle +from openrocketengine.cycles.pressure_fed import PressureFedCycle +from openrocketengine.cycles.staged_combustion import ( + FullFlowStagedCombustion, + StagedCombustionCycle, +) + +__all__ = [ + # Base types + "CycleConfiguration", + "CyclePerformance", + "CycleType", + # Cycle configurations + "PressureFedCycle", + "GasGeneratorCycle", + "StagedCombustionCycle", + "FullFlowStagedCombustion", + # Analysis function + "analyze_cycle", + "format_cycle_summary", +] + diff --git a/openrocketengine/cycles/base.py b/openrocketengine/cycles/base.py new file mode 100644 index 0000000..ce8f11a --- /dev/null +++ b/openrocketengine/cycles/base.py @@ -0,0 +1,354 @@ +"""Base types for engine cycle analysis. + +This module defines the common interfaces and data structures used by +all engine cycle types. +""" + +import math +from dataclasses import dataclass +from enum import Enum, auto +from typing import Protocol, runtime_checkable + +from beartype import beartype + +from openrocketengine.engine import EngineGeometry, EngineInputs, EnginePerformance +from openrocketengine.units import Quantity, pascals, seconds, kg_per_second + + +class CycleType(Enum): + """Engine cycle type enumeration.""" + + PRESSURE_FED = auto() + GAS_GENERATOR = auto() + EXPANDER = auto() + STAGED_COMBUSTION = auto() + FULL_FLOW_STAGED = auto() + ELECTRIC_PUMP = auto() + + +@beartype +@dataclass(frozen=True, slots=True) +class CyclePerformance: + """Performance results from engine cycle analysis. + + Captures the system-level performance including losses from + turbine drive systems, pump power requirements, and pressure margins. + + Attributes: + net_isp: Effective Isp after accounting for cycle losses [s] + net_thrust: Delivered thrust after losses [N] + cycle_efficiency: Ratio of net Isp to ideal Isp [-] + pump_power_ox: Oxidizer pump power requirement [W] + pump_power_fuel: Fuel pump power requirement [W] + turbine_power: Total turbine power available [W] + turbine_mass_flow: Mass flow through turbine [kg/s] + tank_pressure_ox: Required oxidizer tank pressure [Pa] + tank_pressure_fuel: Required fuel tank pressure [Pa] + npsh_margin_ox: Net Positive Suction Head margin for ox pump [Pa] + npsh_margin_fuel: NPSH margin for fuel pump [Pa] + cycle_type: Type of cycle analyzed + feasible: Whether the cycle closes (power balance satisfied) + warnings: List of any warnings or marginal conditions + """ + + net_isp: Quantity + net_thrust: Quantity + cycle_efficiency: float + pump_power_ox: Quantity + pump_power_fuel: Quantity + turbine_power: Quantity + turbine_mass_flow: Quantity + tank_pressure_ox: Quantity + tank_pressure_fuel: Quantity + npsh_margin_ox: Quantity + npsh_margin_fuel: Quantity + cycle_type: CycleType + feasible: bool + warnings: list[str] + + +@runtime_checkable +class CycleConfiguration(Protocol): + """Protocol that all cycle configurations must implement. + + This protocol ensures that any cycle configuration can be used + with the generic analyze_cycle() function. + """ + + @property + def cycle_type(self) -> CycleType: + """Return the cycle type.""" + ... + + def analyze( + self, + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + ) -> CyclePerformance: + """Analyze the cycle and return performance results. + + Args: + inputs: Engine input parameters + performance: Computed engine performance + geometry: Computed engine geometry + + Returns: + CyclePerformance with system-level results + """ + ... + + +@beartype +def analyze_cycle( + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + cycle: CycleConfiguration, +) -> CyclePerformance: + """Analyze an engine cycle configuration. + + This is the main entry point for cycle analysis. It delegates to + the specific cycle implementation's analyze() method. + + Args: + inputs: Engine input parameters + performance: Computed engine performance (from design_engine) + geometry: Computed engine geometry (from design_engine) + cycle: Cycle configuration (PressureFedCycle, GasGeneratorCycle, etc.) + + Returns: + CyclePerformance with net performance and feasibility assessment + + Example: + >>> inputs = EngineInputs.from_propellants("LOX", "CH4", ...) + >>> performance, geometry = design_engine(inputs) + >>> cycle = GasGeneratorCycle(turbine_inlet_temp=kelvin(900), ...) + >>> result = analyze_cycle(inputs, performance, geometry, cycle) + """ + return cycle.analyze(inputs, performance, geometry) + + +# ============================================================================= +# Utility Functions +# ============================================================================= + + +@beartype +def pump_power( + mass_flow: Quantity, + pressure_rise: Quantity, + density: float, + efficiency: float, +) -> Quantity: + """Calculate pump power requirement. + + Uses the basic hydraulic power equation: + P = (mdot * delta_P) / (rho * eta) + + Args: + mass_flow: Mass flow rate through pump [kg/s] + pressure_rise: Pressure rise across pump [Pa] + density: Fluid density [kg/m³] + efficiency: Pump efficiency (0-1) + + Returns: + Pump power in Watts + """ + mdot = mass_flow.to("kg/s").value + dp = pressure_rise.to("Pa").value + + # Volumetric flow rate + Q = mdot / density # m³/s + + # Hydraulic power + P_hydraulic = Q * dp # W + + # Shaft power (accounting for efficiency) + P_shaft = P_hydraulic / efficiency + + return Quantity(P_shaft, "W", "power") + + +@beartype +def turbine_power( + mass_flow: Quantity, + inlet_temp: Quantity, + pressure_ratio: float, + gamma: float, + efficiency: float, + R: float = 287.0, # J/(kg·K), approximate for combustion products +) -> Quantity: + """Calculate turbine power output. + + Uses isentropic turbine equations with efficiency factor. + + Args: + mass_flow: Mass flow through turbine [kg/s] + inlet_temp: Turbine inlet temperature [K] + pressure_ratio: Inlet pressure / outlet pressure [-] + gamma: Ratio of specific heats for turbine gas [-] + efficiency: Turbine isentropic efficiency (0-1) + R: Specific gas constant [J/(kg·K)] + + Returns: + Turbine power output in Watts + """ + mdot = mass_flow.to("kg/s").value + T_in = inlet_temp.to("K").value + + # Isentropic temperature ratio + T_ratio_ideal = pressure_ratio ** ((gamma - 1) / gamma) + + # Actual temperature drop + delta_T_ideal = T_in * (1 - 1 / T_ratio_ideal) + delta_T_actual = delta_T_ideal * efficiency + + # Specific heat at constant pressure + cp = gamma * R / (gamma - 1) + + # Turbine power + P = mdot * cp * delta_T_actual + + return Quantity(P, "W", "power") + + +@beartype +def npsh_available( + tank_pressure: Quantity, + fluid_density: float, + vapor_pressure: Quantity, + inlet_height: float = 0.0, + line_losses: Quantity | None = None, +) -> Quantity: + """Calculate Net Positive Suction Head available at pump inlet. + + NPSH_a = (P_tank - P_vapor) / (rho * g) + h - losses + + Args: + tank_pressure: Tank ullage pressure [Pa] + fluid_density: Propellant density [kg/m³] + vapor_pressure: Propellant vapor pressure [Pa] + inlet_height: Height of fluid above pump inlet [m] + line_losses: Pressure losses in feed lines [Pa] + + Returns: + NPSH available in Pascals (pressure equivalent) + """ + g = 9.80665 # m/s² + + P_tank = tank_pressure.to("Pa").value + P_vapor = vapor_pressure.to("Pa").value + losses = line_losses.to("Pa").value if line_losses else 0.0 + + # NPSH in meters of head + npsh_m = (P_tank - P_vapor) / (fluid_density * g) + inlet_height - losses / (fluid_density * g) + + # Convert to pressure equivalent + npsh_pa = npsh_m * fluid_density * g + + return pascals(npsh_pa) + + +@beartype +def estimate_line_losses( + mass_flow: Quantity, + density: float, + pipe_diameter: float, + pipe_length: float, + num_elbows: int = 2, + num_valves: int = 2, +) -> Quantity: + """Estimate pressure losses in feed lines. + + Uses Darcy-Weisbach equation with loss coefficients for fittings. + + Args: + mass_flow: Mass flow rate [kg/s] + density: Fluid density [kg/m³] + pipe_diameter: Pipe inner diameter [m] + pipe_length: Total pipe length [m] + num_elbows: Number of 90° elbows + num_valves: Number of valves + + Returns: + Total pressure loss [Pa] + """ + mdot = mass_flow.to("kg/s").value + D = pipe_diameter + L = pipe_length + + # Calculate velocity + A = math.pi * (D / 2) ** 2 + V = mdot / (density * A) + + # Dynamic pressure + q = 0.5 * density * V ** 2 + + # Friction factor (assuming turbulent flow, smooth pipe) + # Using Blasius correlation as approximation + Re = density * V * D / 1e-3 # Approximate viscosity + if Re > 2300: + f = 0.316 / Re ** 0.25 + else: + f = 64 / Re + + # Pipe friction losses + dp_pipe = f * (L / D) * q + + # Fitting losses (K-factors) + K_elbow = 0.3 # 90° elbow + K_valve = 0.2 # Gate valve (open) + + dp_fittings = (num_elbows * K_elbow + num_valves * K_valve) * q + + return pascals(dp_pipe + dp_fittings) + + +@beartype +def format_cycle_summary(result: CyclePerformance) -> str: + """Format cycle analysis results as readable string. + + Args: + result: CyclePerformance from analyze_cycle() + + Returns: + Formatted multi-line string + """ + status = "✓ FEASIBLE" if result.feasible else "✗ INFEASIBLE" + + lines = [ + f"{'=' * 60}", + f"CYCLE ANALYSIS: {result.cycle_type.name}", + f"Status: {status}", + f"{'=' * 60}", + "", + "PERFORMANCE:", + f" Net Isp: {result.net_isp.value:.1f} s", + f" Net Thrust: {result.net_thrust.to('kN').value:.2f} kN", + f" Cycle Efficiency: {result.cycle_efficiency * 100:.1f}%", + "", + "POWER BALANCE:", + f" Turbine Power: {result.turbine_power.value / 1000:.1f} kW", + f" Pump Power (Ox): {result.pump_power_ox.value / 1000:.1f} kW", + f" Pump Power (Fuel): {result.pump_power_fuel.value / 1000:.1f} kW", + f" Turbine Flow: {result.turbine_mass_flow.value:.3f} kg/s", + "", + "TANK REQUIREMENTS:", + f" Ox Tank Pressure: {result.tank_pressure_ox.to('bar').value:.1f} bar", + f" Fuel Tank Pressure:{result.tank_pressure_fuel.to('bar').value:.1f} bar", + "", + "NPSH MARGINS:", + f" Ox NPSH Margin: {result.npsh_margin_ox.to('bar').value:.2f} bar", + f" Fuel NPSH Margin: {result.npsh_margin_fuel.to('bar').value:.2f} bar", + ] + + if result.warnings: + lines.extend(["", "WARNINGS:"]) + for warning in result.warnings: + lines.append(f" ⚠ {warning}") + + lines.append(f"{'=' * 60}") + + return "\n".join(lines) + diff --git a/openrocketengine/cycles/gas_generator.py b/openrocketengine/cycles/gas_generator.py new file mode 100644 index 0000000..c3320b0 --- /dev/null +++ b/openrocketengine/cycles/gas_generator.py @@ -0,0 +1,347 @@ +"""Gas generator engine cycle analysis. + +The gas generator (GG) cycle is the most common turbopump-fed cycle. +A small portion of propellants is burned in a separate gas generator +to drive the turbine, then exhausted overboard (or through a secondary nozzle). + +Advantages: +- Proven, reliable technology +- Simpler than staged combustion +- Lower turbine temperatures +- Decoupled turbine from main chamber + +Disadvantages: +- GG exhaust is "wasted" (reduces effective Isp by 1-3%) +- Limited chamber pressure compared to staged combustion +- Requires separate GG and associated plumbing + +Examples: +- SpaceX Merlin (LOX/RP-1) +- Rocketdyne F-1 (LOX/RP-1) +- RS-68 (LOX/LH2) +- Vulcain (LOX/LH2) +""" + +import math +from dataclasses import dataclass + +from beartype import beartype + +from openrocketengine.cycles.base import ( + CyclePerformance, + CycleType, + estimate_line_losses, + npsh_available, + pump_power, + turbine_power, +) +from openrocketengine.engine import EngineGeometry, EngineInputs, EnginePerformance +from openrocketengine.tanks import get_propellant_density +from openrocketengine.units import Quantity, kelvin, kg_per_second, pascals, seconds + + +# Typical vapor pressures for common propellants [Pa] +VAPOR_PRESSURES: dict[str, float] = { + "LOX": 101325, + "LH2": 101325, + "CH4": 101325, + "RP1": 1000, + "Ethanol": 5900, +} + + +def _get_vapor_pressure(propellant: str) -> float: + """Get vapor pressure for a propellant.""" + name = propellant.upper().replace("-", "").replace(" ", "") + for key, value in VAPOR_PRESSURES.items(): + if key.upper() == name: + return value + return 1000.0 + + +@beartype +@dataclass(frozen=True, slots=True) +class GasGeneratorCycle: + """Configuration for a gas generator engine cycle. + + The gas generator produces hot gas to drive the turbines that power + the propellant pumps. The GG exhaust is typically dumped overboard. + + Attributes: + turbine_inlet_temp: Gas generator combustion temperature [K] + Typically 700-1000K to protect turbine blades + pump_efficiency_ox: Oxidizer pump efficiency (0.6-0.75 typical) + pump_efficiency_fuel: Fuel pump efficiency (0.6-0.75 typical) + turbine_efficiency: Turbine isentropic efficiency (0.5-0.7 typical) + turbine_pressure_ratio: Turbine inlet/outlet pressure ratio (2-6 typical) + gg_mixture_ratio: GG O/F ratio (fuel-rich, typically 0.3-0.5) + mechanical_efficiency: Mechanical losses in turbopump (0.95-0.98) + tank_pressure_ox: Oxidizer tank pressure [Pa] + tank_pressure_fuel: Fuel tank pressure [Pa] + """ + + turbine_inlet_temp: Quantity = None # type: ignore # Will validate in __post_init__ + pump_efficiency_ox: float = 0.70 + pump_efficiency_fuel: float = 0.70 + turbine_efficiency: float = 0.60 + turbine_pressure_ratio: float = 4.0 + gg_mixture_ratio: float = 0.4 # Fuel-rich + mechanical_efficiency: float = 0.97 + tank_pressure_ox: Quantity | None = None + tank_pressure_fuel: Quantity | None = None + + def __post_init__(self) -> None: + """Validate inputs.""" + if self.turbine_inlet_temp is None: + object.__setattr__(self, 'turbine_inlet_temp', kelvin(900)) + + @property + def cycle_type(self) -> CycleType: + return CycleType.GAS_GENERATOR + + def analyze( + self, + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + ) -> CyclePerformance: + """Analyze gas generator cycle and compute net performance. + + The key equations are: + 1. Pump power = mdot * delta_P / (rho * eta) + 2. Turbine power = mdot_gg * cp * delta_T * eta + 3. Power balance: P_turbine = P_pump_ox + P_pump_fuel + 4. Net Isp = (F_main - mdot_gg * ue_gg) / (mdot_total * g0) + + Args: + inputs: Engine input parameters + performance: Computed engine performance + geometry: Computed engine geometry + + Returns: + CyclePerformance with net performance and power balance + """ + warnings: list[str] = [] + + # Extract values + pc = inputs.chamber_pressure.to("Pa").value + mdot_total = performance.mdot.to("kg/s").value + mdot_ox = performance.mdot_ox.to("kg/s").value + mdot_fuel = performance.mdot_fuel.to("kg/s").value + isp = performance.isp.value + thrust = inputs.thrust.to("N").value + + # Get propellant properties + # Try to determine from engine name + ox_name = "LOX" + fuel_name = "RP1" + if inputs.name: + name_upper = inputs.name.upper() + if "CH4" in name_upper or "METHANE" in name_upper or "METHALOX" in name_upper: + fuel_name = "CH4" + elif "LH2" in name_upper or "HYDROGEN" in name_upper or "HYDROLOX" in name_upper: + fuel_name = "LH2" + + try: + rho_ox = get_propellant_density(ox_name) + except ValueError: + rho_ox = 1141.0 + warnings.append(f"Unknown oxidizer, assuming LOX density: {rho_ox} kg/m³") + + try: + rho_fuel = get_propellant_density(fuel_name) + except ValueError: + rho_fuel = 810.0 + warnings.append(f"Unknown fuel, assuming RP-1 density: {rho_fuel} kg/m³") + + # Determine tank pressures + if self.tank_pressure_ox is not None: + p_tank_ox = self.tank_pressure_ox.to("Pa").value + else: + # Typical tank pressure for turbopump-fed: 2-5 bar + p_tank_ox = 300000 # 3 bar default + + if self.tank_pressure_fuel is not None: + p_tank_fuel = self.tank_pressure_fuel.to("Pa").value + else: + p_tank_fuel = 250000 # 2.5 bar default + + # Calculate pump pressure rise + # Pump must raise from tank pressure to chamber pressure + injector drop + margins + injector_dp = pc * 0.20 # 20% pressure drop across injector + p_pump_outlet = pc + injector_dp + + dp_ox = p_pump_outlet - p_tank_ox + dp_fuel = p_pump_outlet - p_tank_fuel + + # Pump power requirements + P_pump_ox = pump_power( + mass_flow=kg_per_second(mdot_ox), + pressure_rise=pascals(dp_ox), + density=rho_ox, + efficiency=self.pump_efficiency_ox, + ).value + + P_pump_fuel = pump_power( + mass_flow=kg_per_second(mdot_fuel), + pressure_rise=pascals(dp_fuel), + density=rho_fuel, + efficiency=self.pump_efficiency_fuel, + ).value + + # Total pump power (accounting for mechanical losses) + P_pump_total = (P_pump_ox + P_pump_fuel) / self.mechanical_efficiency + + # Gas generator analysis + # Turbine power must equal pump power + P_turbine_required = P_pump_total + + # GG exhaust properties (fuel-rich combustion products) + # Approximate gamma and R for fuel-rich GG + gamma_gg = 1.25 # Lower gamma for fuel-rich + R_gg = 350.0 # J/(kg·K), approximate for fuel-rich products + T_gg = self.turbine_inlet_temp.to("K").value + + # Turbine specific work + # w = cp * T_in * eta * (1 - 1/PR^((gamma-1)/gamma)) + cp_gg = gamma_gg * R_gg / (gamma_gg - 1) + T_ratio = self.turbine_pressure_ratio ** ((gamma_gg - 1) / gamma_gg) + w_turbine = cp_gg * T_gg * self.turbine_efficiency * (1 - 1/T_ratio) + + # GG mass flow required + mdot_gg = P_turbine_required / w_turbine + + # Check GG flow is reasonable (typically 1-5% of total) + gg_fraction = mdot_gg / mdot_total + if gg_fraction > 0.10: + warnings.append( + f"GG flow is {gg_fraction*100:.1f}% of total - unusually high" + ) + + # GG propellant split + mdot_gg_ox = mdot_gg * self.gg_mixture_ratio / (1 + self.gg_mixture_ratio) + mdot_gg_fuel = mdot_gg / (1 + self.gg_mixture_ratio) + + # Net performance calculation + # The GG exhaust has much lower velocity than main chamber + # Approximate GG exhaust velocity + # For low MR (fuel-rich): Isp_gg ~ 200-250s + isp_gg = 220.0 # s, approximate for fuel-rich GG exhaust + g0 = 9.80665 + ue_gg = isp_gg * g0 + + # GG exhaust thrust (negative contribution to net thrust) + F_gg = mdot_gg * ue_gg + + # Net thrust and Isp + # Main chamber produces full thrust + # But we've "spent" mdot_gg propellant for low-Isp exhaust + F_main = thrust + net_thrust = F_main # GG exhaust typically dumps to atmosphere + + # Effective total mass flow (main + GG) + mdot_effective = mdot_total # GG flow comes from same tanks + + # Net Isp considering the GG "loss" + # Two ways to think about it: + # 1. All propellant flows through main chamber at full Isp + # 2. GG flow produces low-Isp exhaust + # Net: weighted average of Isp + net_isp = (F_main + F_gg * 0.3) / (mdot_effective * g0) # GG contributes ~30% of its thrust + + # Alternative: simple debit approach + # net_isp = isp * (1 - gg_fraction) + isp_gg * gg_fraction + net_isp_alt = isp * (1 - gg_fraction * 0.7) # ~70% loss on GG flow + + # Use the more conservative estimate + net_isp = min(net_isp, net_isp_alt) + + cycle_efficiency = net_isp / isp + + # NPSH analysis + p_vapor_ox = _get_vapor_pressure(ox_name) + p_vapor_fuel = _get_vapor_pressure(fuel_name) + + npsh_ox = npsh_available( + tank_pressure=pascals(p_tank_ox), + fluid_density=rho_ox, + vapor_pressure=pascals(p_vapor_ox), + ) + + npsh_fuel = npsh_available( + tank_pressure=pascals(p_tank_fuel), + fluid_density=rho_fuel, + vapor_pressure=pascals(p_vapor_fuel), + ) + + # Feasibility checks + feasible = True + + if npsh_ox.to("Pa").value < 50000: # < 0.5 bar + warnings.append("Low NPSH margin for oxidizer pump - risk of cavitation") + + if npsh_fuel.to("Pa").value < 50000: + warnings.append("Low NPSH margin for fuel pump - risk of cavitation") + + if T_gg > 1100: + warnings.append( + f"Turbine inlet temp {T_gg:.0f}K exceeds typical limit (~1000K)" + ) + + if gg_fraction > 0.05: + warnings.append( + f"GG fraction {gg_fraction*100:.1f}% is high - consider staged combustion" + ) + + return CyclePerformance( + net_isp=seconds(net_isp), + net_thrust=Quantity(net_thrust, "N", "force"), + cycle_efficiency=cycle_efficiency, + pump_power_ox=Quantity(P_pump_ox, "W", "power"), + pump_power_fuel=Quantity(P_pump_fuel, "W", "power"), + turbine_power=Quantity(P_turbine_required, "W", "power"), + turbine_mass_flow=kg_per_second(mdot_gg), + tank_pressure_ox=pascals(p_tank_ox), + tank_pressure_fuel=pascals(p_tank_fuel), + npsh_margin_ox=npsh_ox, + npsh_margin_fuel=npsh_fuel, + cycle_type=self.cycle_type, + feasible=feasible, + warnings=warnings, + ) + + +@beartype +def estimate_turbopump_mass( + pump_power: Quantity, + turbine_power: Quantity, + propellant_type: str = "LOX/RP1", +) -> Quantity: + """Estimate turbopump mass from power requirements. + + Uses historical correlations from existing engines. + + Args: + pump_power: Total pump power [W] + turbine_power: Turbine power [W] + propellant_type: Propellant combination for correlation selection + + Returns: + Estimated turbopump mass [kg] + """ + P = max(pump_power.value, turbine_power.value) + + # Historical correlation: mass ~ k * P^0.6 + # k varies by propellant type and technology level + if "LH2" in propellant_type.upper(): + k = 0.015 # LH2 pumps are larger due to low density + else: + k = 0.008 # LOX/RP-1, LOX/CH4 + + mass = k * P ** 0.6 + + # Minimum mass for small turbopumps + mass = max(mass, 5.0) + + return Quantity(mass, "kg", "mass") + diff --git a/openrocketengine/cycles/pressure_fed.py b/openrocketengine/cycles/pressure_fed.py new file mode 100644 index 0000000..01267da --- /dev/null +++ b/openrocketengine/cycles/pressure_fed.py @@ -0,0 +1,288 @@ +"""Pressure-fed engine cycle analysis. + +Pressure-fed engines use high-pressure gas (typically helium) to push +propellants from tanks into the combustion chamber. They are the simplest +cycle type but require heavy tanks to contain the high pressures. + +Advantages: +- Simplicity and reliability (no turbopumps) +- Fewer failure modes +- Lower development cost + +Disadvantages: +- Heavy tanks (must withstand full chamber pressure + margins) +- Limited chamber pressure (~3 MPa practical limit) +- Lower performance (limited Isp due to pressure constraints) + +Typical applications: +- Upper stages +- Spacecraft thrusters +- Student/amateur rockets +""" + +from dataclasses import dataclass + +from beartype import beartype + +from openrocketengine.cycles.base import ( + CyclePerformance, + CycleType, + estimate_line_losses, + npsh_available, +) +from openrocketengine.engine import EngineGeometry, EngineInputs, EnginePerformance +from openrocketengine.tanks import get_propellant_density +from openrocketengine.units import Quantity, kg_per_second, pascals, seconds + + +# Typical vapor pressures for common propellants [Pa] +# At nominal storage temperatures +VAPOR_PRESSURES: dict[str, float] = { + "LOX": 101325, # ~1 atm at -183°C + "LH2": 101325, # ~1 atm at -253°C + "CH4": 101325, # ~1 atm at -161°C + "RP1": 1000, # Very low at 20°C + "Ethanol": 5900, # ~0.06 atm at 20°C + "N2O4": 96000, # ~0.95 atm at 20°C + "MMH": 4800, # Low at 20°C + "N2O": 5200000, # ~51 atm at 20°C (self-pressurizing) +} + + +def _get_vapor_pressure(propellant: str) -> float: + """Get vapor pressure for a propellant.""" + # Normalize name + name = propellant.upper().replace("-", "").replace(" ", "") + + for key, value in VAPOR_PRESSURES.items(): + if key.upper() == name: + return value + + # Default to low vapor pressure if unknown + return 1000.0 + + +@beartype +@dataclass(frozen=True, slots=True) +class PressureFedCycle: + """Configuration for a pressure-fed engine cycle. + + In a pressure-fed system, the tank pressure must exceed the chamber + pressure plus all line losses and injector pressure drop. + + Attributes: + injector_dp_fraction: Injector pressure drop as fraction of Pc (typically 0.15-0.25) + line_loss_fraction: Feed line losses as fraction of Pc (typically 0.05-0.10) + tank_pressure_margin: Safety margin on tank pressure (typically 1.1-1.2) + pressurant: Pressurant gas type (typically "helium" or "nitrogen") + ox_line_diameter: Oxidizer feed line diameter [m] + fuel_line_diameter: Fuel feed line diameter [m] + ox_line_length: Oxidizer feed line length [m] + fuel_line_length: Fuel feed line length [m] + """ + + injector_dp_fraction: float = 0.20 + line_loss_fraction: float = 0.05 + tank_pressure_margin: float = 1.15 + pressurant: str = "helium" + ox_line_diameter: float = 0.05 # m + fuel_line_diameter: float = 0.04 # m + ox_line_length: float = 2.0 # m + fuel_line_length: float = 2.0 # m + + @property + def cycle_type(self) -> CycleType: + return CycleType.PRESSURE_FED + + def analyze( + self, + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + ) -> CyclePerformance: + """Analyze pressure-fed cycle and determine tank pressures. + + Args: + inputs: Engine input parameters + performance: Computed engine performance + geometry: Computed engine geometry + + Returns: + CyclePerformance with pressure requirements and feasibility + """ + warnings: list[str] = [] + + # Extract values + pc = inputs.chamber_pressure.to("Pa").value + mdot_ox = performance.mdot_ox.to("kg/s").value + mdot_fuel = performance.mdot_fuel.to("kg/s").value + + # Get propellant densities + # Extract propellant names from engine name or use defaults + ox_name = "LOX" # Default + fuel_name = "RP1" # Default + if inputs.name: + name_upper = inputs.name.upper() + if "LOX" in name_upper or "LO2" in name_upper: + ox_name = "LOX" + if "CH4" in name_upper or "METHANE" in name_upper: + fuel_name = "CH4" + elif "RP1" in name_upper or "KEROSENE" in name_upper: + fuel_name = "RP1" + elif "ETHANOL" in name_upper: + fuel_name = "Ethanol" + elif "LH2" in name_upper or "HYDROGEN" in name_upper: + fuel_name = "LH2" + + try: + rho_ox = get_propellant_density(ox_name) + except ValueError: + rho_ox = 1141.0 # Default LOX + warnings.append(f"Unknown oxidizer, assuming LOX density: {rho_ox} kg/m³") + + try: + rho_fuel = get_propellant_density(fuel_name) + except ValueError: + rho_fuel = 810.0 # Default RP-1 + warnings.append(f"Unknown fuel, assuming RP-1 density: {rho_fuel} kg/m³") + + # Calculate pressure budget + # Tank pressure must overcome: chamber + injector drop + line losses + dp_injector = pc * self.injector_dp_fraction + dp_lines_ox = pc * self.line_loss_fraction + dp_lines_fuel = pc * self.line_loss_fraction + + # More detailed line loss estimate + dp_lines_ox_calc = estimate_line_losses( + mass_flow=kg_per_second(mdot_ox), + density=rho_ox, + pipe_diameter=self.ox_line_diameter, + pipe_length=self.ox_line_length, + ).to("Pa").value + + dp_lines_fuel_calc = estimate_line_losses( + mass_flow=kg_per_second(mdot_fuel), + density=rho_fuel, + pipe_diameter=self.fuel_line_diameter, + pipe_length=self.fuel_line_length, + ).to("Pa").value + + # Use maximum of estimated and calculated + dp_lines_ox = max(dp_lines_ox, dp_lines_ox_calc) + dp_lines_fuel = max(dp_lines_fuel, dp_lines_fuel_calc) + + # Required tank pressures + p_tank_ox = (pc + dp_injector + dp_lines_ox) * self.tank_pressure_margin + p_tank_fuel = (pc + dp_injector + dp_lines_fuel) * self.tank_pressure_margin + + # NPSH analysis + p_vapor_ox = _get_vapor_pressure(ox_name) + p_vapor_fuel = _get_vapor_pressure(fuel_name) + + npsh_ox = npsh_available( + tank_pressure=pascals(p_tank_ox), + fluid_density=rho_ox, + vapor_pressure=pascals(p_vapor_ox), + line_losses=pascals(dp_lines_ox), + ) + + npsh_fuel = npsh_available( + tank_pressure=pascals(p_tank_fuel), + fluid_density=rho_fuel, + vapor_pressure=pascals(p_vapor_fuel), + line_losses=pascals(dp_lines_fuel), + ) + + # Check feasibility + feasible = True + + # Pressure-fed practical limit is ~3-4 MPa + if pc > 4e6: + warnings.append( + f"Chamber pressure {pc/1e6:.1f} MPa exceeds typical pressure-fed limit (~3-4 MPa)" + ) + + # Tank pressure feasibility + if p_tank_ox > 6e6: + warnings.append( + f"Ox tank pressure {p_tank_ox/1e6:.1f} MPa is very high for pressure-fed" + ) + if p_tank_fuel > 6e6: + warnings.append( + f"Fuel tank pressure {p_tank_fuel/1e6:.1f} MPa is very high for pressure-fed" + ) + + # For pressure-fed, there are no turbopumps, so no pump power + # All "pumping" is done by the pressurized tanks + pump_power_ox = Quantity(0.0, "W", "power") + pump_power_fuel = Quantity(0.0, "W", "power") + turbine_power = Quantity(0.0, "W", "power") + turbine_flow = kg_per_second(0.0) + + # Net performance equals ideal performance (no turbine drive losses) + net_isp = performance.isp + net_thrust = inputs.thrust + cycle_efficiency = 1.0 # No cycle losses for pressure-fed + + return CyclePerformance( + net_isp=net_isp, + net_thrust=net_thrust, + cycle_efficiency=cycle_efficiency, + pump_power_ox=pump_power_ox, + pump_power_fuel=pump_power_fuel, + turbine_power=turbine_power, + turbine_mass_flow=turbine_flow, + tank_pressure_ox=pascals(p_tank_ox), + tank_pressure_fuel=pascals(p_tank_fuel), + npsh_margin_ox=npsh_ox, + npsh_margin_fuel=npsh_fuel, + cycle_type=self.cycle_type, + feasible=feasible, + warnings=warnings, + ) + + +@beartype +def estimate_pressurant_mass( + propellant_volume: Quantity, + tank_pressure: Quantity, + pressurant: str = "helium", + initial_temp: float = 300.0, # K + blowdown_ratio: float = 2.0, +) -> Quantity: + """Estimate pressurant gas mass required. + + Uses ideal gas law with blowdown consideration. + In blowdown mode, tank pressure drops as propellant is expelled. + + Args: + propellant_volume: Volume of propellant to expel [m³] + tank_pressure: Initial tank pressure [Pa] + pressurant: Gas type ("helium" or "nitrogen") + initial_temp: Pressurant initial temperature [K] + blowdown_ratio: Initial/final pressure ratio for blowdown + + Returns: + Required pressurant mass [kg] + """ + # Gas constants + R_helium = 2077.0 # J/(kg·K) + R_nitrogen = 296.8 # J/(kg·K) + + R = R_helium if pressurant.lower() == "helium" else R_nitrogen + + V = propellant_volume.to("m^3").value + P = tank_pressure.to("Pa").value + + # For pressure-regulated system: m = P * V / (R * T) + # For blowdown: need to account for pressure decay + # Simplified: assume average pressure + P_avg = P / (1 + 1/blowdown_ratio) * 2 + + mass = P_avg * V / (R * initial_temp) + + # Add margin for residuals and cooling + mass *= 1.2 + + return Quantity(mass, "kg", "mass") + diff --git a/openrocketengine/cycles/staged_combustion.py b/openrocketengine/cycles/staged_combustion.py new file mode 100644 index 0000000..3a2bfc1 --- /dev/null +++ b/openrocketengine/cycles/staged_combustion.py @@ -0,0 +1,488 @@ +"""Staged combustion engine cycle analysis. + +Staged combustion is the highest-performance liquid engine cycle. Unlike +gas generators where turbine exhaust is dumped overboard, staged combustion +routes all turbine exhaust into the main combustion chamber. + +Variants: +- Oxidizer-rich staged combustion (ORSC): Preburner runs oxidizer-rich + Example: RD-180, RD-191, NK-33 +- Fuel-rich staged combustion (FRSC): Preburner runs fuel-rich + Example: RS-25 (SSME), BE-4 +- Full-flow staged combustion (FFSC): Both ox-rich AND fuel-rich preburners + Example: SpaceX Raptor + +Advantages: +- Highest Isp (all propellant goes through main chamber) +- High chamber pressure capability +- High thrust-to-weight ratio + +Disadvantages: +- Most complex cycle +- Expensive development +- Challenging turbine environments (especially ORSC) + +References: + - Sutton & Biblarz, Chapter 6 + - Humble, Henry & Larson, "Space Propulsion Analysis and Design" +""" + +import math +from dataclasses import dataclass + +from beartype import beartype + +from openrocketengine.cycles.base import ( + CyclePerformance, + CycleType, + npsh_available, + pump_power, +) +from openrocketengine.engine import EngineGeometry, EngineInputs, EnginePerformance +from openrocketengine.tanks import get_propellant_density +from openrocketengine.units import Quantity, kelvin, kg_per_second, pascals, seconds + + +# Typical vapor pressures for common propellants [Pa] +VAPOR_PRESSURES: dict[str, float] = { + "LOX": 101325, + "LH2": 101325, + "CH4": 101325, + "RP1": 1000, +} + + +def _get_vapor_pressure(propellant: str) -> float: + """Get vapor pressure for a propellant.""" + name = propellant.upper().replace("-", "").replace(" ", "") + for key, value in VAPOR_PRESSURES.items(): + if key.upper() == name: + return value + return 1000.0 + + +@beartype +@dataclass(frozen=True, slots=True) +class StagedCombustionCycle: + """Configuration for a staged combustion engine cycle. + + In staged combustion, the preburner exhaust (which drove the turbine) + is routed into the main combustion chamber, so no propellant is wasted. + + Attributes: + preburner_temp: Preburner combustion temperature [K] + Typically 700-900K for fuel-rich, 500-700K for ox-rich + pump_efficiency_ox: Oxidizer pump efficiency (0.7-0.8 typical) + pump_efficiency_fuel: Fuel pump efficiency (0.7-0.8 typical) + turbine_efficiency: Turbine isentropic efficiency (0.6-0.75 typical) + turbine_pressure_ratio: Turbine pressure ratio (1.5-3.0 typical) + preburner_mixture_ratio: Preburner O/F ratio + Fuel-rich: 0.3-0.6 (for SSME-type) + Ox-rich: 50-100 (for RD-180-type) + oxidizer_rich: If True, uses ox-rich preburner (ORSC) + mechanical_efficiency: Mechanical losses (0.95-0.98) + tank_pressure_ox: Oxidizer tank pressure [Pa] + tank_pressure_fuel: Fuel tank pressure [Pa] + """ + + preburner_temp: Quantity | None = None + pump_efficiency_ox: float = 0.75 + pump_efficiency_fuel: float = 0.75 + turbine_efficiency: float = 0.70 + turbine_pressure_ratio: float = 2.0 + preburner_mixture_ratio: float = 0.5 # Fuel-rich default + oxidizer_rich: bool = False + mechanical_efficiency: float = 0.97 + tank_pressure_ox: Quantity | None = None + tank_pressure_fuel: Quantity | None = None + + @property + def cycle_type(self) -> CycleType: + return CycleType.STAGED_COMBUSTION + + def analyze( + self, + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + ) -> CyclePerformance: + """Analyze staged combustion cycle. + + The key difference from gas generator is that turbine exhaust + goes to the main chamber, so there's no Isp penalty from + dumping low-energy gases. + + The power balance is more complex because the preburner + operates at a pressure higher than the main chamber. + """ + warnings: list[str] = [] + + # Extract values + pc = inputs.chamber_pressure.to("Pa").value + mdot_total = performance.mdot.to("kg/s").value + mdot_ox = performance.mdot_ox.to("kg/s").value + mdot_fuel = performance.mdot_fuel.to("kg/s").value + isp = performance.isp.value + + # Set default preburner temperature + if self.preburner_temp is not None: + T_pb = self.preburner_temp.to("K").value + else: + # Default based on cycle type + T_pb = 600.0 if self.oxidizer_rich else 800.0 + + # Get propellant properties + ox_name = "LOX" + fuel_name = "RP1" + if inputs.name: + name_upper = inputs.name.upper() + if "CH4" in name_upper or "METHANE" in name_upper: + fuel_name = "CH4" + elif "LH2" in name_upper or "HYDROGEN" in name_upper: + fuel_name = "LH2" + + try: + rho_ox = get_propellant_density(ox_name) + except ValueError: + rho_ox = 1141.0 + warnings.append(f"Unknown oxidizer, assuming LOX density: {rho_ox} kg/m³") + + try: + rho_fuel = get_propellant_density(fuel_name) + except ValueError: + rho_fuel = 810.0 + warnings.append(f"Unknown fuel, assuming RP-1 density: {rho_fuel} kg/m³") + + # Tank pressures + if self.tank_pressure_ox is not None: + p_tank_ox = self.tank_pressure_ox.to("Pa").value + else: + p_tank_ox = 400000 # 4 bar typical for staged combustion + + if self.tank_pressure_fuel is not None: + p_tank_fuel = self.tank_pressure_fuel.to("Pa").value + else: + p_tank_fuel = 350000 # 3.5 bar + + # Preburner pressure must be higher than chamber pressure + # Turbine pressure drop + injector losses + p_preburner = pc * 1.3 # 30% higher than chamber + + # Pump pressure rises + # Pumps must deliver to preburner pressure (higher than PC) + injector_dp = pc * 0.20 + p_pump_outlet = p_preburner + injector_dp + + dp_ox = p_pump_outlet - p_tank_ox + dp_fuel = p_pump_outlet - p_tank_fuel + + # Pump power requirements + P_pump_ox = pump_power( + mass_flow=kg_per_second(mdot_ox), + pressure_rise=pascals(dp_ox), + density=rho_ox, + efficiency=self.pump_efficiency_ox, + ).value + + P_pump_fuel = pump_power( + mass_flow=kg_per_second(mdot_fuel), + pressure_rise=pascals(dp_fuel), + density=rho_fuel, + efficiency=self.pump_efficiency_fuel, + ).value + + # Total pump power + P_pump_total = (P_pump_ox + P_pump_fuel) / self.mechanical_efficiency + + # Preburner/turbine analysis + # In staged combustion, ALL propellant eventually goes through main chamber + # Preburner flow drives turbine, then goes to main chamber + + if self.oxidizer_rich: + # Ox-rich: most of oxidizer through preburner with small fuel + # mdot_pb = mdot_ox + mdot_pb_fuel + # Preburner MR is very high (50-100), so mdot_pb_fuel is small + mdot_pb_fuel = mdot_ox / self.preburner_mixture_ratio + mdot_pb = mdot_ox + mdot_pb_fuel + # Remaining fuel goes directly to main chamber + mdot_direct_fuel = mdot_fuel - mdot_pb_fuel + + if mdot_direct_fuel < 0: + warnings.append("Preburner consumes more fuel than available - infeasible") + mdot_direct_fuel = 0 + + # Preburner exhaust is mostly oxygen with some combustion products + gamma_pb = 1.30 # Higher gamma for ox-rich + R_pb = 280.0 # J/(kg·K) + + else: + # Fuel-rich: most of fuel through preburner with small oxidizer + mdot_pb_ox = mdot_fuel * self.preburner_mixture_ratio + mdot_pb = mdot_fuel + mdot_pb_ox + # Remaining oxidizer goes directly to main chamber + mdot_direct_ox = mdot_ox - mdot_pb_ox + + if mdot_direct_ox < 0: + warnings.append("Preburner consumes more oxidizer than available - infeasible") + mdot_direct_ox = 0 + + # Preburner exhaust is fuel-rich combustion products + gamma_pb = 1.20 # Lower gamma for fuel-rich + R_pb = 400.0 # J/(kg·K), higher for lighter products + + # Turbine power available + cp_pb = gamma_pb * R_pb / (gamma_pb - 1) + T_ratio = self.turbine_pressure_ratio ** ((gamma_pb - 1) / gamma_pb) + w_turbine = cp_pb * T_pb * self.turbine_efficiency * (1 - 1/T_ratio) + + P_turbine_available = mdot_pb * w_turbine + + # Power balance check + power_margin = P_turbine_available / P_pump_total if P_pump_total > 0 else float('inf') + + if power_margin < 1.0: + warnings.append( + f"Power balance not achieved: turbine provides {P_turbine_available/1e6:.1f} MW, " + f"pumps need {P_pump_total/1e6:.1f} MW" + ) + + # Net performance + # In staged combustion, ALL propellant goes through main chamber + # at (nearly) full Isp, so cycle efficiency is very high + # Small losses from: + # 1. Preburner inefficiency + # 2. Slightly different combustion from preburned products + + # Estimate efficiency loss (typically 1-3%) + efficiency_loss = 0.02 # 2% loss typical for staged combustion + net_isp = isp * (1 - efficiency_loss) + cycle_efficiency = 1 - efficiency_loss + + # NPSH analysis + p_vapor_ox = _get_vapor_pressure(ox_name) + p_vapor_fuel = _get_vapor_pressure(fuel_name) + + npsh_ox = npsh_available( + tank_pressure=pascals(p_tank_ox), + fluid_density=rho_ox, + vapor_pressure=pascals(p_vapor_ox), + ) + + npsh_fuel = npsh_available( + tank_pressure=pascals(p_tank_fuel), + fluid_density=rho_fuel, + vapor_pressure=pascals(p_vapor_fuel), + ) + + # Feasibility assessment + feasible = power_margin >= 0.95 # Allow small margin + + if power_margin < 1.0: + feasible = False + + if self.oxidizer_rich and T_pb > 700: + warnings.append( + f"Ox-rich preburner at {T_pb:.0f}K - requires specialized turbine materials" + ) + + if not self.oxidizer_rich and T_pb > 1000: + warnings.append( + f"Fuel-rich preburner temp {T_pb:.0f}K is high" + ) + + if pc > 25e6: + warnings.append( + f"Chamber pressure {pc/1e6:.0f} MPa is very high - " + "typical staged combustion limit ~30 MPa" + ) + + return CyclePerformance( + net_isp=seconds(net_isp), + net_thrust=inputs.thrust, # Staged combustion delivers full thrust + cycle_efficiency=cycle_efficiency, + pump_power_ox=Quantity(P_pump_ox, "W", "power"), + pump_power_fuel=Quantity(P_pump_fuel, "W", "power"), + turbine_power=Quantity(P_turbine_available, "W", "power"), + turbine_mass_flow=kg_per_second(mdot_pb), + tank_pressure_ox=pascals(p_tank_ox), + tank_pressure_fuel=pascals(p_tank_fuel), + npsh_margin_ox=npsh_ox, + npsh_margin_fuel=npsh_fuel, + cycle_type=self.cycle_type, + feasible=feasible, + warnings=warnings, + ) + + +@beartype +@dataclass(frozen=True, slots=True) +class FullFlowStagedCombustion: + """Configuration for full-flow staged combustion (FFSC). + + FFSC uses TWO preburners: + - Fuel-rich preburner: drives fuel turbopump + - Ox-rich preburner: drives oxidizer turbopump + + This provides the highest possible performance and allows + independent control of each turbopump. + + Example: SpaceX Raptor + + Attributes: + fuel_preburner_temp: Fuel-rich preburner temperature [K] + ox_preburner_temp: Ox-rich preburner temperature [K] + pump_efficiency: Pump efficiency for both pumps + turbine_efficiency: Turbine efficiency for both turbines + fuel_turbine_pr: Fuel turbine pressure ratio + ox_turbine_pr: Ox turbine pressure ratio + """ + + fuel_preburner_temp: Quantity | None = None + ox_preburner_temp: Quantity | None = None + pump_efficiency: float = 0.77 + turbine_efficiency: float = 0.72 + fuel_turbine_pr: float = 1.8 + ox_turbine_pr: float = 1.5 + tank_pressure_ox: Quantity | None = None + tank_pressure_fuel: Quantity | None = None + + @property + def cycle_type(self) -> CycleType: + return CycleType.FULL_FLOW_STAGED + + def analyze( + self, + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + ) -> CyclePerformance: + """Analyze full-flow staged combustion cycle.""" + warnings: list[str] = [] + + # Extract values + pc = inputs.chamber_pressure.to("Pa").value + mdot_ox = performance.mdot_ox.to("kg/s").value + mdot_fuel = performance.mdot_fuel.to("kg/s").value + isp = performance.isp.value + + # Default preburner temperatures + T_fuel_pb = self.fuel_preburner_temp.to("K").value if self.fuel_preburner_temp else 800.0 + T_ox_pb = self.ox_preburner_temp.to("K").value if self.ox_preburner_temp else 600.0 + + # Get propellant properties + try: + rho_ox = get_propellant_density("LOX") + except ValueError: + rho_ox = 1141.0 + + try: + rho_fuel = get_propellant_density("CH4") # FFSC typically uses methane + except ValueError: + rho_fuel = 422.0 + + # Tank pressures + p_tank_ox = self.tank_pressure_ox.to("Pa").value if self.tank_pressure_ox else 500000 + p_tank_fuel = self.tank_pressure_fuel.to("Pa").value if self.tank_pressure_fuel else 450000 + + # Preburner pressures (higher than chamber) + p_preburner = pc * 1.25 + + # Pump requirements + dp_ox = p_preburner - p_tank_ox + pc * 0.2 + dp_fuel = p_preburner - p_tank_fuel + pc * 0.2 + + P_pump_ox = pump_power( + mass_flow=kg_per_second(mdot_ox), + pressure_rise=pascals(dp_ox), + density=rho_ox, + efficiency=self.pump_efficiency, + ).value + + P_pump_fuel = pump_power( + mass_flow=kg_per_second(mdot_fuel), + pressure_rise=pascals(dp_fuel), + density=rho_fuel, + efficiency=self.pump_efficiency, + ).value + + # In FFSC, all oxidizer goes through ox-rich preburner + # All fuel goes through fuel-rich preburner + # Each preburner drives its respective turbopump + + # Fuel-rich preburner (drives fuel pump) + # Small amount of ox mixed with all fuel + gamma_fuel_pb = 1.18 + R_fuel_pb = 450.0 + cp_fuel_pb = gamma_fuel_pb * R_fuel_pb / (gamma_fuel_pb - 1) + T_ratio_fuel = self.fuel_turbine_pr ** ((gamma_fuel_pb - 1) / gamma_fuel_pb) + w_fuel_turbine = cp_fuel_pb * T_fuel_pb * self.turbine_efficiency * (1 - 1/T_ratio_fuel) + + # Ox-rich preburner (drives ox pump) + gamma_ox_pb = 1.30 + R_ox_pb = 280.0 + cp_ox_pb = gamma_ox_pb * R_ox_pb / (gamma_ox_pb - 1) + T_ratio_ox = self.ox_turbine_pr ** ((gamma_ox_pb - 1) / gamma_ox_pb) + w_ox_turbine = cp_ox_pb * T_ox_pb * self.turbine_efficiency * (1 - 1/T_ratio_ox) + + # Power available from each turbine + # In FFSC, all propellant flows through preburners + P_fuel_turbine = mdot_fuel * w_fuel_turbine + P_ox_turbine = mdot_ox * w_ox_turbine + + # Check power balance + fuel_margin = P_fuel_turbine / P_pump_fuel if P_pump_fuel > 0 else float('inf') + ox_margin = P_ox_turbine / P_pump_ox if P_pump_ox > 0 else float('inf') + + feasible = True + if fuel_margin < 0.95: + warnings.append(f"Fuel turbopump power margin low: {fuel_margin:.2f}") + feasible = False + if ox_margin < 0.95: + warnings.append(f"Ox turbopump power margin low: {ox_margin:.2f}") + feasible = False + + # FFSC has minimal Isp loss (all propellant to main chamber) + efficiency_loss = 0.01 # ~1% loss + net_isp = isp * (1 - efficiency_loss) + cycle_efficiency = 1 - efficiency_loss + + # NPSH + p_vapor_ox = _get_vapor_pressure("LOX") + p_vapor_fuel = _get_vapor_pressure("CH4") + + npsh_ox = npsh_available( + tank_pressure=pascals(p_tank_ox), + fluid_density=rho_ox, + vapor_pressure=pascals(p_vapor_ox), + ) + + npsh_fuel = npsh_available( + tank_pressure=pascals(p_tank_fuel), + fluid_density=rho_fuel, + vapor_pressure=pascals(p_vapor_fuel), + ) + + # Warnings + if pc > 30e6: + warnings.append(f"Chamber pressure {pc/1e6:.0f} MPa is at FFSC limit") + + if T_ox_pb > 700: + warnings.append("Ox-rich preburner temp requires advanced turbine materials") + + return CyclePerformance( + net_isp=seconds(net_isp), + net_thrust=inputs.thrust, + cycle_efficiency=cycle_efficiency, + pump_power_ox=Quantity(P_pump_ox, "W", "power"), + pump_power_fuel=Quantity(P_pump_fuel, "W", "power"), + turbine_power=Quantity(P_fuel_turbine + P_ox_turbine, "W", "power"), + turbine_mass_flow=kg_per_second(mdot_ox + mdot_fuel), # All flow through preburners + tank_pressure_ox=pascals(p_tank_ox), + tank_pressure_fuel=pascals(p_tank_fuel), + npsh_margin_ox=npsh_ox, + npsh_margin_fuel=npsh_fuel, + cycle_type=self.cycle_type, + feasible=feasible, + warnings=warnings, + ) + diff --git a/openrocketengine/examples/basic_engine.py b/openrocketengine/examples/basic_engine.py index deb7437..709c121 100644 --- a/openrocketengine/examples/basic_engine.py +++ b/openrocketengine/examples/basic_engine.py @@ -11,10 +11,10 @@ 5. Visualize the design 6. Export contour for CAD -The example engine is similar to a small pressure-fed engine suitable -for a student rocket project. +All outputs are organized into a timestamped directory structure. """ +from openrocketengine import OutputContext from openrocketengine.engine import ( EngineInputs, compute_geometry, @@ -161,58 +161,83 @@ def main() -> None: print() # ========================================================================= - # Step 5: Export Contour to CSV + # Step 5-7: Save All Outputs to Organized Directory # ========================================================================= - print("Step 5: Exporting contour to CSV...") - print() - - nozzle_contour.to_csv("nozzle_contour.csv") - full_contour.to_csv("full_chamber_contour.csv") - - print(" Saved: nozzle_contour.csv") - print(" Saved: full_chamber_contour.csv") - print() - - # ========================================================================= - # Step 6: Print Summaries - # ========================================================================= - - print("Step 6: Full summaries...") - print() - print(format_performance_summary(inputs, performance)) - print() - print(format_geometry_summary(inputs, geometry)) - print() - - # ========================================================================= - # Step 7: Create Visualizations - # ========================================================================= - - print("Step 7: Creating visualizations...") - print() - - # Engine cross-section - fig1 = plot_engine_cross_section( - geometry, full_contour, inputs, show_dimensions=True, title=f"{inputs.name} Cross-Section" - ) - fig1.savefig("engine_cross_section.png", dpi=150, bbox_inches="tight") - print(" Saved: engine_cross_section.png") - - # Nozzle contour detail - fig2 = plot_nozzle_contour(nozzle_contour, title=f"{inputs.name} Nozzle Contour") - fig2.savefig("nozzle_contour.png", dpi=150, bbox_inches="tight") - print(" Saved: nozzle_contour.png") - - # Performance vs altitude - fig3 = plot_performance_vs_altitude(inputs, performance, geometry, max_altitude_km=80) - fig3.savefig("altitude_performance.png", dpi=150, bbox_inches="tight") - print(" Saved: altitude_performance.png") - - # Complete dashboard - fig4 = plot_engine_dashboard(inputs, performance, geometry, full_contour) - fig4.savefig("engine_dashboard.png", dpi=150, bbox_inches="tight") - print(" Saved: engine_dashboard.png") + print("Step 5-7: Saving outputs...") + print() + + # Use OutputContext to organize all outputs + with OutputContext("student_engine_mk1", include_timestamp=True) as ctx: + # Add metadata about this run + ctx.add_metadata("engine_name", inputs.name) + ctx.add_metadata("thrust_N", inputs.thrust.to("N").value) + ctx.add_metadata("chamber_pressure_MPa", inputs.chamber_pressure.to("MPa").value) + + # Export contours to CSV (automatically goes to data/) + ctx.log("Exporting nozzle contours...") + nozzle_contour.to_csv(ctx.path("nozzle_contour.csv")) + full_contour.to_csv(ctx.path("full_chamber_contour.csv")) + + # Save text summaries (automatically goes to reports/) + ctx.log("Saving performance summaries...") + ctx.save_text(format_performance_summary(inputs, performance), "performance_summary.txt") + ctx.save_text(format_geometry_summary(inputs, geometry), "geometry_summary.txt") + + # Save design summary as JSON + ctx.save_summary({ + "engine_name": inputs.name, + "performance": { + "isp_sl_s": performance.isp.value, + "isp_vac_s": performance.isp_vac.value, + "cstar_m_s": performance.cstar.value, + "thrust_coeff_sl": performance.thrust_coeff, + "thrust_coeff_vac": performance.thrust_coeff_vac, + "exit_mach": performance.exit_mach, + "mdot_kg_s": performance.mdot.value, + "mdot_ox_kg_s": performance.mdot_ox.value, + "mdot_fuel_kg_s": performance.mdot_fuel.value, + }, + "geometry": { + "throat_diameter_mm": Dt_mm, + "exit_diameter_mm": De_mm, + "chamber_diameter_mm": Dc_mm, + "chamber_length_mm": Lc_mm, + "nozzle_length_mm": Ln_mm, + "expansion_ratio": geometry.expansion_ratio, + "contraction_ratio": geometry.contraction_ratio, + }, + "inputs": { + "thrust_N": inputs.thrust.to("N").value, + "chamber_pressure_Pa": inputs.chamber_pressure.to("Pa").value, + "chamber_temp_K": inputs.chamber_temp.to("K").value, + "molecular_weight": inputs.molecular_weight, + "gamma": inputs.gamma, + "mixture_ratio": inputs.mixture_ratio, + }, + }) + + # Create visualizations (automatically goes to plots/) + ctx.log("Generating visualizations...") + + fig1 = plot_engine_cross_section( + geometry, full_contour, inputs, show_dimensions=True, + title=f"{inputs.name} Cross-Section" + ) + fig1.savefig(ctx.path("engine_cross_section.png"), dpi=150, bbox_inches="tight") + + fig2 = plot_nozzle_contour(nozzle_contour, title=f"{inputs.name} Nozzle Contour") + fig2.savefig(ctx.path("nozzle_contour.png"), dpi=150, bbox_inches="tight") + + fig3 = plot_performance_vs_altitude(inputs, performance, geometry, max_altitude_km=80) + fig3.savefig(ctx.path("altitude_performance.png"), dpi=150, bbox_inches="tight") + + fig4 = plot_engine_dashboard(inputs, performance, geometry, full_contour) + fig4.savefig(ctx.path("engine_dashboard.png"), dpi=150, bbox_inches="tight") + + ctx.log("All outputs saved!") + print() + print(f" Output directory: {ctx.output_dir}") print() print("=" * 70) @@ -222,4 +247,3 @@ def main() -> None: if __name__ == "__main__": main() - diff --git a/openrocketengine/examples/cycle_comparison.py b/openrocketengine/examples/cycle_comparison.py new file mode 100644 index 0000000..3894a19 --- /dev/null +++ b/openrocketengine/examples/cycle_comparison.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python +"""Engine cycle comparison example for Rocket. + +This example demonstrates how to compare different engine cycle architectures +for a given set of requirements: + +1. Pressure-fed (simplest, lowest performance) +2. Gas generator (most common, good balance) +3. Staged combustion (highest performance, most complex) + +Understanding cycle tradeoffs is critical for: +- Selecting the right architecture for your mission +- Understanding performance vs. complexity tradeoffs +- Estimating system-level impacts (tank pressure, turbomachinery) +""" + +from openrocketengine import ( + EngineInputs, + OutputContext, + design_engine, +) +from openrocketengine.cycles import ( + GasGeneratorCycle, + PressureFedCycle, + StagedCombustionCycle, +) +from openrocketengine.units import kelvin, kilonewtons, megapascals + + +def print_header(text: str) -> None: + """Print a formatted section header.""" + print() + print("┌" + "─" * 68 + "┐") + print(f"│ {text:<66} │") + print("└" + "─" * 68 + "┘") + + +def main() -> None: + """Run the engine cycle comparison example.""" + print() + print("╔" + "═" * 68 + "╗") + print("║" + " " * 16 + "ENGINE CYCLE COMPARISON STUDY" + " " * 23 + "║") + print("║" + " " * 18 + "LOX/CH4 Methalox Engine" + " " * 27 + "║") + print("╚" + "═" * 68 + "╝") + + # ========================================================================= + # Common Engine Requirements + # ========================================================================= + + print_header("ENGINE REQUIREMENTS") + + # Base engine thermochemistry (same for all cycles) + base_inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(500), + chamber_pressure=megapascals(15), + mixture_ratio=3.2, + name="Methalox-500", + ) + + print(f" Thrust target: {base_inputs.thrust.to('kN').value:.0f} kN") + print(f" Chamber pressure: {base_inputs.chamber_pressure.to('MPa').value:.0f} MPa") + print(f" Propellants: LOX / CH4") + print(f" Mixture ratio: {base_inputs.mixture_ratio}") + print() + + # Get baseline performance and geometry + performance, geometry = design_engine(base_inputs) + + print(f" Ideal Isp (vac): {performance.isp_vac.value:.1f} s") + print(f" Ideal c*: {performance.cstar.to('m/s').value:.0f} m/s") + print(f" Mass flow: {performance.mdot.to('kg/s').value:.1f} kg/s") + + # Store results for comparison + results: list[dict] = [] + + # ========================================================================= + # Cycle 1: Pressure-Fed + # ========================================================================= + + print_header("CYCLE 1: PRESSURE-FED") + + print(" Architecture:") + print(" - Propellants pushed by tank pressure (no pumps)") + print(" - Requires high tank pressure → heavy tanks") + print(" - Simplest system, highest reliability") + print() + + pressure_fed = PressureFedCycle( + tank_pressure_margin=1.3, + line_loss_fraction=0.05, + injector_dp_fraction=0.15, + ) + + pf_result = pressure_fed.analyze(base_inputs, performance, geometry) + + print(" Results:") + print(f" Tank pressure (ox): {pf_result.tank_pressure_ox.to('MPa').value:.1f} MPa") + print(f" Tank pressure (fuel): {pf_result.tank_pressure_fuel.to('MPa').value:.1f} MPa") + print(f" Net Isp: {pf_result.net_isp.to('s').value:.1f} s") + print(f" Net thrust: {pf_result.net_thrust.to('kN').value:.0f} kN") + print(f" Cycle efficiency: {pf_result.cycle_efficiency*100:.1f}%") + if pf_result.warnings: + print(f" Warnings: {len(pf_result.warnings)}") + for w in pf_result.warnings: + print(f" ⚠ {w}") + + results.append({ + "cycle": "Pressure-Fed", + "net_isp": pf_result.net_isp.to("s").value, + "tank_pressure_MPa": pf_result.tank_pressure_ox.to("MPa").value, + "pump_power_kW": 0, + "efficiency": pf_result.cycle_efficiency, + }) + + # ========================================================================= + # Cycle 2: Gas Generator + # ========================================================================= + + print_header("CYCLE 2: GAS GENERATOR") + + print(" Architecture:") + print(" - Small combustor (GG) drives turbine") + print(" - Turbine exhaust dumped overboard (Isp loss)") + print(" - Moderate complexity, proven technology") + print() + + gas_generator = GasGeneratorCycle( + turbine_inlet_temp=kelvin(900), + pump_efficiency_ox=0.70, + pump_efficiency_fuel=0.70, + turbine_efficiency=0.65, + gg_mixture_ratio=0.4, + ) + + gg_result = gas_generator.analyze(base_inputs, performance, geometry) + + total_pump_power_gg = ( + gg_result.pump_power_ox.to("kW").value + + gg_result.pump_power_fuel.to("kW").value + ) + + print(" Results:") + print(f" Turbine mass flow: {gg_result.turbine_mass_flow.to('kg/s').value:.1f} kg/s") + print(f" Net Isp: {gg_result.net_isp.to('s').value:.1f} s") + print(f" Net thrust: {gg_result.net_thrust.to('kN').value:.0f} kN") + print(f" Cycle efficiency: {gg_result.cycle_efficiency*100:.1f}%") + print(f" Isp loss vs ideal: {performance.isp_vac.value - gg_result.net_isp.to('s').value:.1f} s") + if gg_result.warnings: + print(f" Warnings: {len(gg_result.warnings)}") + for w in gg_result.warnings: + print(f" ⚠ {w}") + + results.append({ + "cycle": "Gas Generator", + "net_isp": gg_result.net_isp.to("s").value, + "tank_pressure_MPa": gg_result.tank_pressure_ox.to("MPa").value if gg_result.tank_pressure_ox else 0.5, + "pump_power_kW": total_pump_power_gg, + "efficiency": gg_result.cycle_efficiency, + }) + + # ========================================================================= + # Cycle 3: Staged Combustion (Oxidizer-Rich) + # ========================================================================= + + print_header("CYCLE 3: STAGED COMBUSTION (OX-RICH)") + + print(" Architecture:") + print(" - Preburner runs oxygen-rich") + print(" - ALL flow goes through main chamber (no dump)") + print(" - Highest performance, most complex") + print() + + staged_combustion = StagedCombustionCycle( + preburner_temp=kelvin(750), + pump_efficiency_ox=0.75, + pump_efficiency_fuel=0.75, + turbine_efficiency=0.70, + preburner_mixture_ratio=50.0, + oxidizer_rich=True, + ) + + sc_result = staged_combustion.analyze(base_inputs, performance, geometry) + + total_pump_power_sc = ( + sc_result.pump_power_ox.to("kW").value + + sc_result.pump_power_fuel.to("kW").value + ) + + print(" Results:") + print(f" Turbine mass flow: {sc_result.turbine_mass_flow.to('kg/s').value:.1f} kg/s") + print(f" Net Isp: {sc_result.net_isp.to('s').value:.1f} s") + print(f" Net thrust: {sc_result.net_thrust.to('kN').value:.0f} kN") + print(f" Cycle efficiency: {sc_result.cycle_efficiency*100:.1f}%") + if sc_result.warnings: + print(f" Warnings: {len(sc_result.warnings)}") + for w in sc_result.warnings: + print(f" ⚠ {w}") + + results.append({ + "cycle": "Staged Combustion", + "net_isp": sc_result.net_isp.to("s").value, + "tank_pressure_MPa": sc_result.tank_pressure_ox.to("MPa").value if sc_result.tank_pressure_ox else 0.5, + "pump_power_kW": total_pump_power_sc, + "efficiency": sc_result.cycle_efficiency, + }) + + # ========================================================================= + # Comparison Summary + # ========================================================================= + + print_header("COMPARISON SUMMARY") + + print() + print(f" {'Cycle':<20} {'Net Isp':<12} {'Efficiency':<12} {'Tank P (MPa)':<12}") + print(" " + "-" * 56) + + for r in results: + isp_str = f"{r['net_isp']:.1f} s" + eff_str = f"{r['efficiency']*100:.1f}%" + tank_str = f"{r['tank_pressure_MPa']:.1f}" + print(f" {r['cycle']:<20} {isp_str:<12} {eff_str:<12} {tank_str:<12}") + + # Compute comparison from actual results + if len(results) >= 3: + isp_gain = results[2]["net_isp"] - results[1]["net_isp"] + isp_gain_pct = 100 * isp_gain / results[1]["net_isp"] if results[1]["net_isp"] > 0 else 0 + + print() + print(f" Staged combustion vs Gas Generator:") + print(f" Isp gain: {isp_gain:.1f} s ({isp_gain_pct:.1f}%)") + + print() + + # ========================================================================= + # Save Results + # ========================================================================= + + print_header("SAVING RESULTS") + + with OutputContext("cycle_comparison", include_timestamp=True) as ctx: + ctx.save_summary({ + "requirements": { + "thrust_kN": base_inputs.thrust.to("kN").value, + "chamber_pressure_MPa": base_inputs.chamber_pressure.to("MPa").value, + "propellants": "LOX/CH4", + "mixture_ratio": base_inputs.mixture_ratio, + }, + "ideal_performance": { + "isp_vac_s": performance.isp_vac.value, + "cstar_m_s": performance.cstar.value, + "mdot_kg_s": performance.mdot.value, + }, + "cycles": results, + }) + + print() + print(f" Results saved to: {ctx.output_dir}") + + print() + print("╔" + "═" * 68 + "╗") + print("║" + " " * 24 + "ANALYSIS COMPLETE" + " " * 27 + "║") + print("╚" + "═" * 68 + "╝") + print() + + +if __name__ == "__main__": + main() diff --git a/openrocketengine/examples/optimization.py b/openrocketengine/examples/optimization.py new file mode 100644 index 0000000..8219d64 --- /dev/null +++ b/openrocketengine/examples/optimization.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python +"""Multi-objective optimization example for Rocket. + +This example demonstrates how to find Pareto-optimal engine designs +that balance competing objectives: + +1. Maximize Isp (specific impulse) +2. Minimize engine mass (via throat size as proxy) +3. Satisfy thermal constraints + +Real engine design involves tradeoffs - you can't maximize everything. +Pareto fronts show the best achievable combinations. +""" + +from openrocketengine import ( + EngineInputs, + MultiObjectiveOptimizer, + OutputContext, + Range, + design_engine, +) +from openrocketengine.units import kilonewtons, megapascals + + +def main() -> None: + """Run the multi-objective optimization example.""" + print("=" * 70) + print("Rocket - Multi-Objective Optimization Example") + print("=" * 70) + print() + + # ========================================================================= + # Define the Optimization Problem + # ========================================================================= + + print("Problem: Design a LOX/CH4 engine balancing Isp vs. compactness") + print("-" * 70) + print() + print("Objectives:") + print(" 1. Maximize vacuum Isp (performance)") + print(" 2. Minimize throat diameter (smaller = lighter, cheaper)") + print() + print("Design variables:") + print(" - Chamber pressure: 5-25 MPa") + print(" - Mixture ratio: 2.5-4.0") + print() + print("Constraints:") + print(" - Expansion ratio < 80 (practical nozzle size)") + print(" - Throat diameter > 3 cm (manufacturability)") + print() + + # Baseline design + baseline = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(100), + chamber_pressure=megapascals(10), + mixture_ratio=3.2, + name="Methalox-Baseline", + ) + + # Define constraints + def expansion_constraint(result: tuple) -> bool: + """Expansion ratio must be < 80.""" + _, geometry = result + return geometry.expansion_ratio < 80 + + def throat_constraint(result: tuple) -> bool: + """Throat diameter must be > 3 cm for manufacturability.""" + _, geometry = result + return geometry.throat_diameter.to("m").value * 100 > 3.0 + + # ========================================================================= + # Run Multi-Objective Optimization + # ========================================================================= + + print("Running optimization...") + print() + + optimizer = MultiObjectiveOptimizer( + compute=design_engine, + base=baseline, + objectives=["isp_vac", "throat_diameter"], + maximize=[True, False], # Max Isp, Min throat (smaller = better) + vary={ + "chamber_pressure": Range(5, 25, n=15, unit="MPa"), + "mixture_ratio": Range(2.5, 4.0, n=10), + }, + constraints=[expansion_constraint, throat_constraint], + ) + + pareto_results = optimizer.run(progress=True) + + print() + print(f"Total designs evaluated: {pareto_results.n_total}") + print(f"Feasible designs: {pareto_results.n_feasible}") + print(f"Pareto-optimal designs: {pareto_results.n_pareto}") + print() + + # ========================================================================= + # Analyze Pareto Front + # ========================================================================= + + print("=" * 70) + print("PARETO-OPTIMAL DESIGNS") + print("=" * 70) + print() + print("These designs represent the best tradeoffs between Isp and size:") + print() + print(f" {'#':<4} {'Pc (MPa)':<10} {'MR':<8} {'Isp (vac)':<12} {'Throat (cm)':<12} {'ε':<8}") + print(" " + "-" * 54) + + pareto_df = pareto_results.pareto_front() + + for i, row in enumerate(pareto_df.iter_rows(named=True)): + pc_mpa = row["chamber_pressure"] / 1e6 + dt_cm = row["throat_diameter"] * 100 + print(f" {i+1:<4} {pc_mpa:<10.0f} {row['mixture_ratio']:<8.1f} {row['isp_vac']:<12.1f} {dt_cm:<12.2f} {row['expansion_ratio']:<8.1f}") + + print() + + # ========================================================================= + # Interpret Results + # ========================================================================= + + print("=" * 70) + print("DESIGN RECOMMENDATIONS") + print("=" * 70) + print() + + # Best Isp design + best_isp_idx = pareto_df["isp_vac"].arg_max() + best_isp_row = pareto_df.row(best_isp_idx, named=True) + + print("For MAXIMUM PERFORMANCE (highest Isp):") + print(f" Chamber pressure: {best_isp_row['chamber_pressure']/1e6:.0f} MPa") + print(f" Mixture ratio: {best_isp_row['mixture_ratio']:.1f}") + print(f" Isp (vac): {best_isp_row['isp_vac']:.1f} s") + print(f" Throat diameter: {best_isp_row['throat_diameter']*100:.2f} cm") + print() + + # Smallest design + best_size_idx = pareto_df["throat_diameter"].arg_min() + best_size_row = pareto_df.row(best_size_idx, named=True) + + print("For MINIMUM SIZE (smallest throat):") + print(f" Chamber pressure: {best_size_row['chamber_pressure']/1e6:.0f} MPa") + print(f" Mixture ratio: {best_size_row['mixture_ratio']:.1f}") + print(f" Isp (vac): {best_size_row['isp_vac']:.1f} s") + print(f" Throat diameter: {best_size_row['throat_diameter']*100:.2f} cm") + print() + + # Balanced design (middle of Pareto front) + mid_idx = len(pareto_df) // 2 + mid_row = pareto_df.row(mid_idx, named=True) + + print("For BALANCED DESIGN (middle of Pareto front):") + print(f" Chamber pressure: {mid_row['chamber_pressure']/1e6:.0f} MPa") + print(f" Mixture ratio: {mid_row['mixture_ratio']:.1f}") + print(f" Isp (vac): {mid_row['isp_vac']:.1f} s") + print(f" Throat diameter: {mid_row['throat_diameter']*100:.2f} cm") + print() + + # ========================================================================= + # Tradeoff Analysis + # ========================================================================= + + print("=" * 70) + print("TRADEOFF ANALYSIS") + print("=" * 70) + print() + + isp_range = pareto_df["isp_vac"].max() - pareto_df["isp_vac"].min() + dt_range = (pareto_df["throat_diameter"].max() - pareto_df["throat_diameter"].min()) * 100 + + print(f" Isp range on Pareto front: {pareto_df['isp_vac'].min():.1f} - {pareto_df['isp_vac'].max():.1f} s (Δ = {isp_range:.1f} s)") + print(f" Throat range on Pareto front: {pareto_df['throat_diameter'].min()*100:.2f} - {pareto_df['throat_diameter'].max()*100:.2f} cm (Δ = {dt_range:.2f} cm)") + print() + print(" Interpretation:") + print(f" - You can gain up to {isp_range:.1f} s of Isp...") + print(f" - ...at the cost of {dt_range:.1f} cm larger throat") + print(f" - Marginal tradeoff: {isp_range/dt_range:.1f} s of Isp per cm of throat") + print() + + # ========================================================================= + # Save Results + # ========================================================================= + + print("=" * 70) + print("Saving Results") + print("=" * 70) + print() + + with OutputContext("optimization_results", include_timestamp=True) as ctx: + # Export all results + ctx.log("Exporting full design space...") + pareto_results.all_results.to_csv(ctx.path("all_designs.csv")) + + ctx.log("Exporting Pareto front...") + pareto_df.write_csv(ctx.path("pareto_front.csv")) + + # Save summary + ctx.save_summary({ + "problem": { + "objectives": ["isp_vac (maximize)", "throat_diameter (minimize)"], + "design_variables": { + "chamber_pressure_MPa": [5, 25], + "mixture_ratio": [2.5, 4.0], + }, + "constraints": [ + "expansion_ratio < 80", + "throat_diameter > 3 cm", + ], + }, + "results": { + "total_designs": pareto_results.n_total, + "feasible_designs": pareto_results.n_feasible, + "pareto_optimal": pareto_results.n_pareto, + }, + "recommendations": { + "max_performance": { + "chamber_pressure_MPa": best_isp_row["chamber_pressure"] / 1e6, + "mixture_ratio": best_isp_row["mixture_ratio"], + "isp_vac_s": best_isp_row["isp_vac"], + }, + "min_size": { + "chamber_pressure_MPa": best_size_row["chamber_pressure"] / 1e6, + "mixture_ratio": best_size_row["mixture_ratio"], + "throat_diameter_cm": best_size_row["throat_diameter"] * 100, + }, + "balanced": { + "chamber_pressure_MPa": mid_row["chamber_pressure"] / 1e6, + "mixture_ratio": mid_row["mixture_ratio"], + "isp_vac_s": mid_row["isp_vac"], + "throat_diameter_cm": mid_row["throat_diameter"] * 100, + }, + }, + }) + + print() + print(f" All results saved to: {ctx.output_dir}") + + print() + print("=" * 70) + print("Optimization Complete!") + print("=" * 70) + print() + + +if __name__ == "__main__": + main() + diff --git a/openrocketengine/examples/propellant_design.py b/openrocketengine/examples/propellant_design.py index d134f6f..d04edcc 100644 --- a/openrocketengine/examples/propellant_design.py +++ b/openrocketengine/examples/propellant_design.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -"""Propellant-based engine design example for OpenRocketEngine. +"""Propellant-based engine design example for Rocket. This example demonstrates the simplified workflow where you specify propellants and the library automatically determines combustion properties. @@ -7,7 +7,7 @@ No need to manually look up Tc, gamma, or molecular weight! """ -from openrocketengine import design_engine, plot_engine_dashboard +from openrocketengine import OutputContext, design_engine, plot_engine_dashboard from openrocketengine.engine import EngineInputs from openrocketengine.nozzle import full_chamber_contour, generate_nozzle_from_geometry from openrocketengine.units import kilonewtons, megapascals @@ -16,12 +16,15 @@ def main() -> None: """Run the propellant-based design example.""" print("=" * 70) - print("OpenRocketEngine - Propellant-Based Design") + print("Rocket - Propellant-Based Design") print("=" * 70) print() print("Using NASA CEA (via RocketCEA) for thermochemistry calculations") print() + # Store all engine designs for comparison + designs: list[tuple[str, EngineInputs, any, any]] = [] + # ========================================================================= # Design a LOX/RP-1 Engine (like Merlin) # ========================================================================= @@ -62,6 +65,8 @@ def main() -> None: print(f" Expansion Ratio: {geom1.expansion_ratio:.1f}") print() + designs.append(("LOX/RP-1", lox_rp1, perf1, geom1)) + # ========================================================================= # Design a LOX/Methane Engine (like Raptor) # ========================================================================= @@ -91,6 +96,8 @@ def main() -> None: print(f" Isp (Vac): {perf2.isp_vac.value:.1f} s") print() + designs.append(("LOX/CH4", lox_ch4, perf2, geom2)) + # ========================================================================= # Design a LOX/LH2 Engine (like RS-25/SSME) # ========================================================================= @@ -120,6 +127,8 @@ def main() -> None: print(f" Isp (Vac): {perf3.isp_vac.value:.1f} s <- Highest!") print() + designs.append(("LOX/LH2", lox_lh2, perf3, geom3)) + # ========================================================================= # Comparison Summary # ========================================================================= @@ -131,11 +140,7 @@ def main() -> None: print(f"{'Engine':<20} {'Isp(SL)':<10} {'Isp(Vac)':<10} {'MW':<8} {'Tc (K)':<10}") print("-" * 70) - for name, inputs, perf in [ - ("LOX/RP-1", lox_rp1, perf1), - ("LOX/CH4", lox_ch4, perf2), - ("LOX/LH2", lox_lh2, perf3), - ]: + for name, inputs, perf, _ in designs: print( f"{name:<20} " f"{perf.isp.value:<10.1f} " @@ -145,22 +150,46 @@ def main() -> None: ) print() - print("Note: Lower molecular weight (MW) = higher Isp") - print(" LH2 engines have best Isp but require large tanks (low density)") - print() # ========================================================================= - # Generate Dashboard for LOX/RP-1 Engine + # Generate Dashboards for All Engines # ========================================================================= - print("Generating visualization for LOX/RP-1 engine...") - - nozzle = generate_nozzle_from_geometry(geom1) - contour = full_chamber_contour(lox_rp1, geom1, nozzle) + print("=" * 70) + print("GENERATING VISUALIZATIONS") + print("=" * 70) + print() - fig = plot_engine_dashboard(lox_rp1, perf1, geom1, contour) - fig.savefig("kerolox_engine_dashboard.png", dpi=150, bbox_inches="tight") - print(" Saved: kerolox_engine_dashboard.png") + with OutputContext("propellant_comparison", include_timestamp=True) as ctx: + # Save comparison summary + ctx.save_summary({ + "designs": [ + { + "name": name, + "propellants": f"{inputs.name}", + "isp_sl": perf.isp.value, + "isp_vac": perf.isp_vac.value, + "molecular_weight": inputs.molecular_weight, + "chamber_temp_K": inputs.chamber_temp.to("K").value, + "gamma": inputs.gamma, + "thrust_kN": inputs.thrust.to("kN").value, + "chamber_pressure_MPa": inputs.chamber_pressure.to("MPa").value, + } + for name, inputs, perf, _ in designs + ] + }, "comparison_summary.json") + + for name, inputs, perf, geom in designs: + ctx.log(f"Generating dashboard for {inputs.name}...") + nozzle = generate_nozzle_from_geometry(geom) + contour = full_chamber_contour(inputs, geom, nozzle) + fig = plot_engine_dashboard(inputs, perf, geom, contour) + + safe_name = inputs.name.lower().replace("-", "_").replace(" ", "_") + fig.savefig(ctx.path(f"{safe_name}_dashboard.png"), dpi=150, bbox_inches="tight") + + print() + print(f" All outputs saved to: {ctx.output_dir}") print() print("=" * 70) @@ -170,4 +199,3 @@ def main() -> None: if __name__ == "__main__": main() - diff --git a/openrocketengine/examples/thermal_analysis.py b/openrocketengine/examples/thermal_analysis.py new file mode 100644 index 0000000..aa89c5a --- /dev/null +++ b/openrocketengine/examples/thermal_analysis.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python +"""Thermal analysis example for Rocket. + +This example demonstrates thermal screening to determine if an engine +design can be regeneratively cooled: + +1. Estimate heat flux using the Bartz correlation +2. Check cooling feasibility for different coolants +3. Understand the relationship between chamber pressure and cooling + +High chamber pressure engines (like Raptor) face severe thermal challenges. +This analysis helps catch infeasible designs early. +""" + +from openrocketengine import ( + EngineInputs, + OutputContext, + design_engine, +) +from openrocketengine.thermal import ( + check_cooling_feasibility, + estimate_heat_flux, + heat_flux_profile, +) +from openrocketengine.units import kelvin, kilonewtons, megapascals + + +def print_header(text: str) -> None: + """Print a formatted section header.""" + print() + print("┌" + "─" * 68 + "┐") + print(f"│ {text:<66} │") + print("└" + "─" * 68 + "┘") + + +def main() -> None: + """Run the thermal analysis example.""" + print() + print("╔" + "═" * 68 + "╗") + print("║" + " " * 18 + "THERMAL FEASIBILITY ANALYSIS" + " " * 22 + "║") + print("║" + " " * 15 + "Can This Engine Be Regeneratively Cooled?" + " " * 12 + "║") + print("╚" + "═" * 68 + "╝") + + # ========================================================================= + # Design a High-Performance Engine + # ========================================================================= + + print_header("ENGINE DESIGN") + + # High chamber pressure like Raptor + inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(500), + chamber_pressure=megapascals(25), # High pressure! + mixture_ratio=3.2, + name="High-Pc-Methalox", + ) + + performance, geometry = design_engine(inputs) + + print(f" Engine: {inputs.name}") + print(f" Thrust: {inputs.thrust.to('kN').value:.0f} kN") + print(f" Chamber pressure: {inputs.chamber_pressure.to('MPa').value:.0f} MPa") + print(f" Chamber temperature: {inputs.chamber_temp.to('K').value:.0f} K") + print() + print(f" Isp (vac): {performance.isp_vac.value:.1f} s") + print(f" Throat diameter: {geometry.throat_diameter.to('m').value*100:.2f} cm") + + # ========================================================================= + # Heat Flux Estimation + # ========================================================================= + + print_header("HEAT FLUX ANALYSIS") + + print(" Using Bartz correlation for convective heat transfer...") + print() + + # Estimate heat flux at key locations + locations = ["chamber", "throat", "exit"] + + print(f" {'Location':<15} {'Heat Flux (MW/m²)':<20}") + print(" " + "-" * 35) + + max_q = 0.0 + max_location = "" + + for location in locations: + q = estimate_heat_flux( + inputs=inputs, + performance=performance, + geometry=geometry, + location=location, + ) + q_mw = q.to("W/m^2").value / 1e6 + + if q_mw > max_q: + max_q = q_mw + max_location = location + + print(f" {location:<15} {q_mw:<20.1f}") + + print() + print(f" Maximum heat flux: {max_q:.1f} MW/m² at {max_location}") + + # ========================================================================= + # Heat Flux Profile Along Nozzle + # ========================================================================= + + print_header("AXIAL HEAT FLUX PROFILE") + + x_positions, heat_fluxes = heat_flux_profile( + inputs=inputs, + performance=performance, + geometry=geometry, + n_points=11, + ) + + print(f" {'x/L':<10} {'Heat Flux (MW/m²)':<20}") + print(" " + "-" * 30) + + for x, q in zip(x_positions, heat_fluxes, strict=True): + q_mw = q / 1e6 # heat_flux_profile returns W/m² + bar = "█" * int(q_mw / 5) # Simple bar chart + print(f" {x:<10.2f} {q_mw:<10.1f} {bar}") + + # ========================================================================= + # Cooling Feasibility Check - Methane + # ========================================================================= + + print_header("COOLING FEASIBILITY: METHANE (CH4)") + + print(" Checking if methane can cool this engine...") + print() + + ch4_cooling = check_cooling_feasibility( + inputs=inputs, + performance=performance, + geometry=geometry, + coolant="CH4", + coolant_inlet_temp=kelvin(110), # Near boiling point + max_wall_temp=kelvin(920), # Material limit + ) + + if ch4_cooling.feasible: + print(" ✓ FEASIBLE with methane cooling!") + else: + print(" ✗ NOT FEASIBLE with methane cooling") + + print() + print(f" Max wall temperature: {ch4_cooling.max_wall_temp.to('K').value:.0f} K (limit: {ch4_cooling.max_allowed_temp.to('K').value:.0f} K)") + print(f" Throat heat flux: {ch4_cooling.throat_heat_flux.to('W/m^2').value/1e6:.1f} MW/m²") + print(f" Coolant outlet temp: {ch4_cooling.coolant_outlet_temp.to('K').value:.0f} K") + print(f" Flow margin: {ch4_cooling.flow_margin:.2f}x") + if ch4_cooling.warnings: + print(f" Warnings:") + for w in ch4_cooling.warnings: + print(f" ⚠ {w}") + + # ========================================================================= + # Cooling Feasibility Check - RP-1 (for comparison) + # ========================================================================= + + print_header("COOLING FEASIBILITY: RP-1 (KEROSENE)") + + # Design a kerosene engine for comparison + rp1_inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="RP1", + thrust=kilonewtons(500), + chamber_pressure=megapascals(25), + mixture_ratio=2.7, + name="High-Pc-Kerolox", + ) + rp1_perf, rp1_geom = design_engine(rp1_inputs) + + print(" Checking RP-1 cooling for LOX/RP-1 engine...") + print() + + rp1_cooling = check_cooling_feasibility( + inputs=rp1_inputs, + performance=rp1_perf, + geometry=rp1_geom, + coolant="RP1", + coolant_inlet_temp=kelvin(300), # Room temperature + max_wall_temp=kelvin(920), + ) + + if rp1_cooling.feasible: + print(" ✓ FEASIBLE with RP-1 cooling!") + else: + print(" ✗ NOT FEASIBLE with RP-1 cooling") + + print() + print(f" Max wall temperature: {rp1_cooling.max_wall_temp.to('K').value:.0f} K") + print(f" Coolant outlet temp: {rp1_cooling.coolant_outlet_temp.to('K').value:.0f} K") + print(f" Flow margin: {rp1_cooling.flow_margin:.2f}x") + + # ========================================================================= + # Chamber Pressure Impact Study + # ========================================================================= + + print_header("CHAMBER PRESSURE vs. COOLING FEASIBILITY") + + print(" How does Pc affect cooling feasibility?") + print() + print(f" {'Pc (MPa)':<12} {'Throat q (MW/m²)':<20} {'Coolable?':<12}") + print(" " + "-" * 44) + + for pc_mpa in [5, 10, 15, 20, 25, 30]: + test_inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(500), + chamber_pressure=megapascals(pc_mpa), + mixture_ratio=3.2, + name=f"Test-{pc_mpa}MPa", + ) + test_perf, test_geom = design_engine(test_inputs) + + q_throat = estimate_heat_flux(test_inputs, test_perf, test_geom, location="throat") + q_mw = q_throat.to("W/m^2").value / 1e6 + + cooling = check_cooling_feasibility( + test_inputs, test_perf, test_geom, + coolant="CH4", + coolant_inlet_temp=kelvin(110), + max_wall_temp=kelvin(920), + ) + + status = "✓ Yes" if cooling.feasible else "✗ No" + print(f" {pc_mpa:<12} {q_mw:<20.1f} {status:<12}") + + # ========================================================================= + # Save Results + # ========================================================================= + + print_header("SAVING RESULTS") + + with OutputContext("thermal_analysis", include_timestamp=True) as ctx: + ctx.save_summary({ + "engine": { + "name": inputs.name, + "thrust_kN": inputs.thrust.to("kN").value, + "chamber_pressure_MPa": inputs.chamber_pressure.to("MPa").value, + "chamber_temp_K": inputs.chamber_temp.to("K").value, + }, + "heat_flux": { + "max_MW_m2": max_q, + "max_location": max_location, + }, + "ch4_cooling": { + "feasible": ch4_cooling.feasible, + "max_wall_temp_K": ch4_cooling.max_wall_temp.value, + "flow_margin": ch4_cooling.flow_margin, + }, + "rp1_cooling": { + "feasible": rp1_cooling.feasible, + "max_wall_temp_K": rp1_cooling.max_wall_temp.value, + "flow_margin": rp1_cooling.flow_margin, + }, + }) + + print() + print(f" Results saved to: {ctx.output_dir}") + + print() + print("╔" + "═" * 68 + "╗") + print("║" + " " * 24 + "ANALYSIS COMPLETE" + " " * 27 + "║") + print("╚" + "═" * 68 + "╝") + print() + + +if __name__ == "__main__": + main() diff --git a/openrocketengine/examples/trade_study.py b/openrocketengine/examples/trade_study.py new file mode 100644 index 0000000..8fc150c --- /dev/null +++ b/openrocketengine/examples/trade_study.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python +"""Parametric trade study example for Rocket. + +This example demonstrates how to run systematic trade studies to explore +the design space and make informed engineering decisions: + +1. Chamber pressure sweeps (most impactful parameter) +2. Multi-parameter grid studies +3. Constraint-based filtering +4. Polars DataFrame export +5. Mixture ratio studies (requires CEA recalculation) + +These tools let you answer questions like: +- "How does Isp change with chamber pressure?" +- "Which designs satisfy my throat size requirement?" +- "What's the tradeoff between performance and size?" +""" + +from openrocketengine import ( + EngineInputs, + OutputContext, + ParametricStudy, + Range, + design_engine, +) +from openrocketengine.units import kilonewtons, megapascals + + +def main() -> None: + """Run the parametric trade study example.""" + print("=" * 70) + print("Rocket - Parametric Trade Study Example") + print("=" * 70) + print() + + # ========================================================================= + # Baseline Engine Design + # ========================================================================= + + print("Baseline Engine: LOX/CH4 Methalox") + print("-" * 70) + + baseline = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(200), + chamber_pressure=megapascals(10), + mixture_ratio=3.2, + name="Methalox-Baseline", + ) + + perf, geom = design_engine(baseline) + print(f" Isp (vac): {perf.isp_vac.value:.1f} s") + print(f" Thrust: {baseline.thrust.to('kN').value:.0f} kN") + print() + + # ========================================================================= + # Study 1: Mixture Ratio Trade (with CEA recalculation) + # ========================================================================= + + print("=" * 70) + print("Study 1: Mixture Ratio Sweep (with CEA thermochemistry)") + print("=" * 70) + print() + print("Question: What mixture ratio maximizes Isp for LOX/CH4?") + print() + print("Note: Each point recalculates combustion properties via NASA CEA") + print() + + mixture_ratios = [2.4, 2.6, 2.8, 3.0, 3.2, 3.4, 3.6, 3.8, 4.0] + mr_results = [] + + print(f" {'MR':<8} {'Tc (K)':<10} {'Isp (vac)':<12} {'Isp (SL)':<12}") + print(" " + "-" * 42) + + for mr in mixture_ratios: + inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(200), + chamber_pressure=megapascals(10), + mixture_ratio=mr, + ) + perf, geom = design_engine(inputs) + mr_results.append({ + "mr": mr, + "Tc": inputs.chamber_temp.to("K").value, + "isp_vac": perf.isp_vac.value, + "isp_sl": perf.isp.value, + }) + print(f" {mr:<8.1f} {inputs.chamber_temp.to('K').value:<10.0f} {perf.isp_vac.value:<12.1f} {perf.isp.value:<12.1f}") + + # Find optimal MR + best = max(mr_results, key=lambda x: x["isp_vac"]) + print() + print(f" → Optimal MR for max Isp: {best['mr']:.1f} (Isp = {best['isp_vac']:.1f} s)") + print(f" At this MR: Tc = {best['Tc']:.0f} K") + print() + + # ========================================================================= + # Study 2: Chamber Pressure Sweep with Range + # ========================================================================= + + print("=" * 70) + print("Study 2: Chamber Pressure Trade") + print("=" * 70) + print() + print("Question: How does performance scale with chamber pressure?") + print() + + # Using Range for continuous sweeps with units + pc_study = ParametricStudy( + compute=design_engine, + base=baseline, + vary={"chamber_pressure": Range(5, 25, n=9, unit="MPa")}, + ) + + pc_results = pc_study.run(progress=True) + + print() + print("Results:") + print(f" {'Pc (MPa)':<10} {'Isp (vac)':<12} {'Throat (cm)':<12} {'c* (m/s)':<10}") + print(" " + "-" * 44) + + df = pc_results.to_dataframe() + for row in df.iter_rows(named=True): + throat_cm = row["throat_diameter"] * 100 + # chamber_pressure is already in MPa (unit specified in Range) + print(f" {row['chamber_pressure']:<10.0f} {row['isp_vac']:<12.1f} {throat_cm:<12.1f} {row['cstar']:<10.0f}") + + # Compute actual insights from data + print() + pc_low = df["chamber_pressure"].min() + pc_high = df["chamber_pressure"].max() + isp_at_low = df.filter(df["chamber_pressure"] == pc_low)["isp_vac"][0] + isp_at_high = df.filter(df["chamber_pressure"] == pc_high)["isp_vac"][0] + dt_at_low = df.filter(df["chamber_pressure"] == pc_low)["throat_diameter"][0] * 100 + dt_at_high = df.filter(df["chamber_pressure"] == pc_high)["throat_diameter"][0] * 100 + + isp_change = isp_at_high - isp_at_low + dt_change = dt_at_high - dt_at_low + + print(f" From {pc_low:.0f} to {pc_high:.0f} MPa:") + print(f" Isp changed by {isp_change:+.1f} s ({100*isp_change/isp_at_low:+.1f}%)") + print(f" Throat diameter changed by {dt_change:+.1f} cm ({100*dt_change/dt_at_low:+.1f}%)") + print() + + # ========================================================================= + # Study 3: Multi-Parameter Grid with Constraints + # ========================================================================= + + print("=" * 70) + print("Study 3: Multi-Parameter Design Space Exploration") + print("=" * 70) + print() + print("Sweeping: Chamber Pressure (5-20 MPa) × Contraction Ratio (3-6)") + print("Constraint: Throat diameter > 8 cm (manufacturability)") + print() + + def throat_constraint(result: tuple) -> bool: + """Filter out designs with throat diameter < 8 cm.""" + _, geometry = result + return geometry.throat_diameter.to("m").value * 100 > 8.0 + + grid_study = ParametricStudy( + compute=design_engine, + base=baseline, + vary={ + "chamber_pressure": Range(5, 20, n=6, unit="MPa"), + "contraction_ratio": [3.0, 4.0, 5.0, 6.0], + }, + constraints=[throat_constraint], + ) + + grid_results = grid_study.run(progress=True) + + print() + n_total = len(grid_results.inputs) + n_feasible = grid_results.constraints_passed.sum() + print(f" Total designs evaluated: {n_total}") + print(f" Feasible designs: {n_feasible} ({100*n_feasible/n_total:.0f}%)") + print() + + # Export to Polars DataFrame for further analysis + df = grid_results.to_dataframe() + + # Filter to feasible designs and show best by Isp + feasible_df = df.filter(df["feasible"]) + best_designs = feasible_df.sort("isp_vac", descending=True).head(5) + + print("Top 5 Feasible Designs by Vacuum Isp:") + print(f" {'Pc (MPa)':<10} {'CR':<8} {'Isp (vac)':<12} {'Dt (cm)':<10}") + print(" " + "-" * 40) + + for row in best_designs.iter_rows(named=True): + # chamber_pressure is already in MPa (unit specified in Range) + dt_cm = row["throat_diameter"] * 100 + print(f" {row['chamber_pressure']:<10.0f} {row['contraction_ratio']:<8.1f} {row['isp_vac']:<12.1f} {dt_cm:<10.1f}") + + print() + + # ========================================================================= + # Save All Results + # ========================================================================= + + print("=" * 70) + print("Saving Results") + print("=" * 70) + print() + + with OutputContext("trade_study_results", include_timestamp=True) as ctx: + # Export DataFrames to CSV + ctx.log("Exporting chamber pressure study...") + pc_results.to_csv(ctx.path("chamber_pressure_sweep.csv")) + + ctx.log("Exporting grid study...") + grid_results.to_csv(ctx.path("grid_study.csv")) + + # Save summary + ctx.save_summary({ + "studies": { + "mixture_ratio_sweep": { + "parameter": "mixture_ratio", + "range": [2.4, 4.0], + "optimal_mr": best["mr"], + "optimal_isp_vac": best["isp_vac"], + "note": "Each point recalculates CEA thermochemistry", + }, + "chamber_pressure_sweep": { + "parameter": "chamber_pressure", + "range_MPa": [5, 25], + "n_points": 9, + }, + "grid_study": { + "parameters": ["chamber_pressure", "contraction_ratio"], + "total_designs": n_total, + "feasible_designs": int(n_feasible), + "constraint": "throat_diameter > 8 cm", + }, + } + }) + + print() + print(f" All results saved to: {ctx.output_dir}") + + print() + print("=" * 70) + print("Trade Study Complete!") + print("=" * 70) + + +if __name__ == "__main__": + main() + diff --git a/openrocketengine/examples/uncertainty_analysis.py b/openrocketengine/examples/uncertainty_analysis.py new file mode 100644 index 0000000..27c0941 --- /dev/null +++ b/openrocketengine/examples/uncertainty_analysis.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python +"""Uncertainty analysis example for Rocket. + +This example demonstrates Monte Carlo uncertainty quantification to understand +how input uncertainties propagate through engine design calculations: + +1. Define probability distributions for uncertain inputs +2. Run Monte Carlo sampling +3. Analyze output statistics and confidence intervals +4. Identify which inputs drive the most uncertainty + +This answers questions like: +- "If my mixture ratio varies ±5%, how much does Isp vary?" +- "What's the 95% confidence interval on my thrust coefficient?" +- "Which input uncertainty should I focus on reducing?" +""" + +from openrocketengine import ( + EngineInputs, + Normal, + OutputContext, + UncertaintyAnalysis, + Uniform, + design_engine, +) +from openrocketengine.units import kilonewtons, megapascals + + +def main() -> None: + """Run the uncertainty analysis example.""" + print("=" * 70) + print("Rocket - Uncertainty Analysis Example") + print("=" * 70) + print() + + # ========================================================================= + # Define Nominal Design Point + # ========================================================================= + + print("Nominal Engine Design: LOX/RP-1 Kerolox") + print("-" * 70) + + nominal = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="RP1", + thrust=kilonewtons(100), + chamber_pressure=megapascals(7), + mixture_ratio=2.7, + name="Kerolox-100", + ) + + perf, geom = design_engine(nominal) + print(f" Nominal Isp (vac): {perf.isp_vac.value:.1f} s") + print(f" Nominal Isp (SL): {perf.isp.value:.1f} s") + print(f" Nominal Thrust Coeff: {perf.thrust_coeff:.3f}") + print(f" Nominal Throat Dia: {geom.throat_diameter.to('m').value*100:.2f} cm") + print() + + # ========================================================================= + # Study 1: Single Source of Uncertainty (Mixture Ratio) + # ========================================================================= + + print("=" * 70) + print("Study 1: Mixture Ratio Uncertainty") + print("=" * 70) + print() + print("The mixture ratio is controlled by the propellant valves.") + print("Assume MR = 2.7 ± 0.1 (Normal distribution, σ = 0.1)") + print() + + mr_uncertainty = UncertaintyAnalysis( + compute=design_engine, + base=nominal, + distributions={ + "mixture_ratio": Normal(mean=2.7, std=0.1), + }, + seed=42, # For reproducibility + ) + + mr_results = mr_uncertainty.run(n_samples=1000, progress=True) + + print() + print("Monte Carlo Results (N=1000):") + print() + + # Get statistics for key metrics + stats = mr_results.statistics() + + print(f" {'Metric':<20} {'Mean':<12} {'Std Dev':<12} {'95% CI':<20}") + print(" " + "-" * 64) + + for metric in ["isp_vac", "isp", "thrust_coeff"]: + mean = stats[metric]["mean"] + std = stats[metric]["std"] + ci_low, ci_high = mr_results.confidence_interval(metric, 0.95) + print(f" {metric:<20} {mean:<12.2f} {std:<12.4f} [{ci_low:.2f}, {ci_high:.2f}]") + + print() + print(" Interpretation:") + print(f" - MR uncertainty of σ=0.1 causes Isp variation of σ≈{stats['isp_vac']['std']:.2f} s") + print(f" - 95% of the time, Isp(vac) will be in [{mr_results.confidence_interval('isp_vac', 0.95)[0]:.1f}, {mr_results.confidence_interval('isp_vac', 0.95)[1]:.1f}] s") + print() + + # ========================================================================= + # Study 2: Multiple Sources of Uncertainty + # ========================================================================= + + print("=" * 70) + print("Study 2: Multiple Uncertainty Sources") + print("=" * 70) + print() + print("Real engines have uncertainty in multiple inputs:") + print(" - Chamber pressure: Pc = 7 MPa ± 0.3 MPa (Normal)") + print(" - Mixture ratio: MR = 2.7 ± 0.1 (Normal)") + print(" - Gamma: γ = 1.24 ± 0.02 (Normal, combustion model uncertainty)") + print() + + multi_uncertainty = UncertaintyAnalysis( + compute=design_engine, + base=nominal, + distributions={ + "chamber_pressure": Normal(mean=7, std=0.3, unit="MPa"), + "mixture_ratio": Normal(mean=2.7, std=0.1), + "gamma": Normal(mean=nominal.gamma, std=0.02), + }, + seed=42, + ) + + multi_results = multi_uncertainty.run(n_samples=2000, progress=True) + + print() + print("Monte Carlo Results (N=2000):") + print() + + stats = multi_results.statistics() + + print(f" {'Metric':<20} {'Mean':<12} {'Std Dev':<12} {'Range (99%)':<20}") + print(" " + "-" * 64) + + for metric in ["isp_vac", "isp", "thrust_coeff", "cstar", "expansion_ratio"]: + mean = stats[metric]["mean"] + std = stats[metric]["std"] + ci_low, ci_high = multi_results.confidence_interval(metric, 0.99) + print(f" {metric:<20} {mean:<12.2f} {std:<12.4f} [{ci_low:.2f}, {ci_high:.2f}]") + + print() + + # ========================================================================= + # Study 3: Uniform Distribution (Manufacturing Tolerances) + # ========================================================================= + + print("=" * 70) + print("Study 3: Manufacturing Tolerance Analysis") + print("=" * 70) + print() + print("Manufacturing tolerances are often uniformly distributed.") + print(" - Contraction ratio: CR = 4.0 ± 0.2 (Uniform)") + print(" - L* (characteristic length): L* = 1.0 ± 0.05 m (Uniform)") + print() + + mfg_uncertainty = UncertaintyAnalysis( + compute=design_engine, + base=nominal, + distributions={ + "contraction_ratio": Uniform(low=3.8, high=4.2), + "lstar": Uniform(low=0.95, high=1.05, unit="m"), + }, + seed=42, + ) + + mfg_results = mfg_uncertainty.run(n_samples=1000, progress=True) + + print() + print("Impact of Manufacturing Tolerances:") + print() + + stats = mfg_results.statistics() + + # These parameters mainly affect geometry, not performance + for metric in ["chamber_diameter", "chamber_length"]: + mean = stats[metric]["mean"] + std = stats[metric]["std"] + cv = 100 * std / mean # Coefficient of variation + print(f" {metric:<20}: mean={mean*100:.2f} cm, CV={cv:.2f}%") + + print() + print(" Note: Geometric tolerances have minimal impact on performance") + print(" but affect fit/interface dimensions.") + print() + + # ========================================================================= + # Export Results + # ========================================================================= + + print("=" * 70) + print("Saving Results") + print("=" * 70) + print() + + with OutputContext("uncertainty_analysis", include_timestamp=True) as ctx: + # Export raw Monte Carlo samples + ctx.log("Exporting MR uncertainty samples...") + mr_results.to_csv(ctx.path("mr_uncertainty_samples.csv")) + + ctx.log("Exporting multi-source uncertainty samples...") + multi_results.to_csv(ctx.path("multi_uncertainty_samples.csv")) + + ctx.log("Exporting manufacturing tolerance samples...") + mfg_results.to_csv(ctx.path("mfg_tolerance_samples.csv")) + + # Save statistical summary + ctx.save_summary({ + "mr_uncertainty": { + "inputs": {"mixture_ratio": {"distribution": "Normal", "mean": 2.7, "std": 0.1}}, + "n_samples": 1000, + "isp_vac": { + "mean": float(mr_results.statistics()["isp_vac"]["mean"]), + "std": float(mr_results.statistics()["isp_vac"]["std"]), + "ci_95": list(mr_results.confidence_interval("isp_vac", 0.95)), + }, + }, + "multi_uncertainty": { + "inputs": { + "chamber_pressure": {"distribution": "Normal", "mean_MPa": 7, "std_MPa": 0.3}, + "mixture_ratio": {"distribution": "Normal", "mean": 2.7, "std": 0.1}, + "gamma": {"distribution": "Normal", "mean": nominal.gamma, "std": 0.02}, + }, + "n_samples": 2000, + }, + }) + + print() + print(f" All results saved to: {ctx.output_dir}") + + print() + print("=" * 70) + print("Uncertainty Analysis Complete!") + print("=" * 70) + print() + + +if __name__ == "__main__": + main() + diff --git a/openrocketengine/output.py b/openrocketengine/output.py new file mode 100644 index 0000000..d0d2202 --- /dev/null +++ b/openrocketengine/output.py @@ -0,0 +1,271 @@ +"""Output management for Rocket. + +This module provides utilities for organizing outputs from rocket design +analyses into structured directories with consistent naming. + +Example: + >>> from openrocketengine.output import OutputContext + >>> with OutputContext("my_engine_study") as ctx: + ... fig.savefig(ctx.path("engine_dashboard.png")) + ... contour.to_csv(ctx.path("nozzle_contour.csv")) + ... ctx.save_summary({"isp": 300, "thrust": 50000}) +""" + +import json +import shutil +from datetime import datetime +from pathlib import Path +from typing import Any + +from beartype import beartype + + +@beartype +class OutputContext: + """Context manager for organizing analysis outputs. + + Creates a structured output directory with: + - Timestamp-based naming for version control + - Subdirectories for different output types + - Automatic metadata logging + - Summary JSON export + + Directory structure: + {base_dir}/{name}_{timestamp}/ + ├── plots/ # Visualization outputs + ├── data/ # CSV, contour exports + ├── reports/ # Text summaries + └── metadata.json # Run information + + Attributes: + name: Study/analysis name + output_dir: Path to the output directory + timestamp: Creation timestamp + """ + + def __init__( + self, + name: str, + base_dir: str | Path | None = None, + include_timestamp: bool = True, + create_subdirs: bool = True, + ) -> None: + """Initialize output context. + + Args: + name: Name for this analysis/study (used in directory name) + base_dir: Base directory for outputs. Defaults to ./outputs/ + include_timestamp: Whether to include timestamp in directory name + create_subdirs: Whether to create plots/, data/, reports/ subdirs + """ + self.name = name + self.timestamp = datetime.now() + self._include_timestamp = include_timestamp + self._create_subdirs = create_subdirs + self._metadata: dict[str, Any] = { + "name": name, + "created": self.timestamp.isoformat(), + "files": [], + } + + # Determine base directory + if base_dir is None: + base_dir = Path.cwd() / "outputs" + self.base_dir = Path(base_dir) + + # Create output directory name + if include_timestamp: + timestamp_str = self.timestamp.strftime("%Y%m%d_%H%M%S") + dir_name = f"{name}_{timestamp_str}" + else: + dir_name = name + + self.output_dir = self.base_dir / dir_name + self._entered = False + + def __enter__(self) -> "OutputContext": + """Enter the context and create directories.""" + self._entered = True + + # Create main output directory + self.output_dir.mkdir(parents=True, exist_ok=True) + + # Create subdirectories + if self._create_subdirs: + (self.output_dir / "plots").mkdir(exist_ok=True) + (self.output_dir / "data").mkdir(exist_ok=True) + (self.output_dir / "reports").mkdir(exist_ok=True) + + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Exit context and write metadata.""" + self._metadata["completed"] = datetime.now().isoformat() + self._metadata["success"] = exc_type is None + + # Write metadata + metadata_path = self.output_dir / "metadata.json" + with open(metadata_path, "w") as f: + json.dump(self._metadata, f, indent=2, default=str) + + self._entered = False + + def path(self, filename: str, subdir: str | None = None) -> Path: + """Get full path for an output file. + + Automatically routes files to appropriate subdirectories based on extension. + + Args: + filename: Name of the output file + subdir: Optional subdirectory override. If None, auto-routes: + - .png, .pdf, .svg → plots/ + - .csv, .json → data/ + - .txt, .md → reports/ + + Returns: + Full path to the output file + """ + if not self._entered: + raise RuntimeError("OutputContext must be used as a context manager") + + # Auto-route based on extension if subdir not specified + if subdir is None and self._create_subdirs: + ext = Path(filename).suffix.lower() + if ext in {".png", ".pdf", ".svg", ".jpg", ".jpeg"}: + subdir = "plots" + elif ext in {".csv", ".json", ".npy", ".npz"}: + subdir = "data" + elif ext in {".txt", ".md", ".rst", ".log"}: + subdir = "reports" + + if subdir: + full_path = self.output_dir / subdir / filename + else: + full_path = self.output_dir / filename + + # Track file for metadata + self._metadata["files"].append(str(full_path.relative_to(self.output_dir))) + + return full_path + + def plots_dir(self) -> Path: + """Get path to plots subdirectory.""" + return self.output_dir / "plots" + + def data_dir(self) -> Path: + """Get path to data subdirectory.""" + return self.output_dir / "data" + + def reports_dir(self) -> Path: + """Get path to reports subdirectory.""" + return self.output_dir / "reports" + + def save_summary(self, summary: dict[str, Any], filename: str = "summary.json") -> Path: + """Save a summary dictionary as JSON. + + Args: + summary: Dictionary of summary data + filename: Output filename + + Returns: + Path to saved file + """ + path = self.path(filename, subdir="data") + with open(path, "w") as f: + json.dump(summary, f, indent=2, default=str) + return path + + def save_text(self, text: str, filename: str = "report.txt") -> Path: + """Save text to a report file. + + Args: + text: Text content to save + filename: Output filename + + Returns: + Path to saved file + """ + path = self.path(filename, subdir="reports") + with open(path, "w") as f: + f.write(text) + return path + + def add_metadata(self, key: str, value: Any) -> None: + """Add custom metadata. + + Args: + key: Metadata key + value: Metadata value (must be JSON-serializable) + """ + self._metadata[key] = value + + def log(self, message: str) -> None: + """Log a message to both console and log file. + + Args: + message: Message to log + """ + timestamp = datetime.now().strftime("%H:%M:%S") + formatted = f"[{timestamp}] {message}" + print(formatted) + + log_path = self.output_dir / "run.log" + with open(log_path, "a") as f: + f.write(formatted + "\n") + + +@beartype +def get_default_output_dir() -> Path: + """Get the default output directory. + + Returns ./outputs/ in the current working directory. + + Returns: + Path to default output directory + """ + return Path.cwd() / "outputs" + + +@beartype +def list_outputs(base_dir: str | Path | None = None) -> list[Path]: + """List all output directories. + + Args: + base_dir: Base directory to search. Defaults to ./outputs/ + + Returns: + List of output directory paths, sorted by modification time (newest first) + """ + if base_dir is None: + base_dir = get_default_output_dir() + base_dir = Path(base_dir) + + if not base_dir.exists(): + return [] + + outputs = [d for d in base_dir.iterdir() if d.is_dir()] + return sorted(outputs, key=lambda p: p.stat().st_mtime, reverse=True) + + +@beartype +def clean_outputs(base_dir: str | Path | None = None, keep_latest: int = 5) -> int: + """Clean old output directories, keeping the N most recent. + + Args: + base_dir: Base directory to clean. Defaults to ./outputs/ + keep_latest: Number of recent outputs to keep + + Returns: + Number of directories removed + """ + outputs = list_outputs(base_dir) + + if len(outputs) <= keep_latest: + return 0 + + to_remove = outputs[keep_latest:] + for output_dir in to_remove: + shutil.rmtree(output_dir) + + return len(to_remove) + diff --git a/openrocketengine/plotting.py b/openrocketengine/plotting.py index 81caf29..6df6ac9 100644 --- a/openrocketengine/plotting.py +++ b/openrocketengine/plotting.py @@ -5,6 +5,7 @@ - Performance curves (Isp vs altitude, thrust vs altitude) - Nozzle contour visualization - Trade study plots +- Cycle comparison charts All plots use matplotlib with a consistent, professional style. """ @@ -24,6 +25,11 @@ isp_at_altitude, thrust_at_altitude, ) +from openrocketengine.isentropic import ( + area_ratio_from_mach, + mach_from_pressure_ratio, + thrust_coefficient, +) from openrocketengine.nozzle import NozzleContour from openrocketengine.units import pascals @@ -446,12 +452,6 @@ def plot_isp_vs_expansion_ratio( Returns: matplotlib Figure """ - from openrocketengine.isentropic import ( - area_ratio_from_mach, - mach_from_pressure_ratio, - thrust_coefficient, - ) - _setup_style() fig, ax = plt.subplots(figsize=figsize) @@ -657,3 +657,367 @@ def plot_engine_dashboard( return fig + +# ============================================================================= +# Mass Breakdown Plot +# ============================================================================= + + +@beartype +def plot_mass_breakdown( + masses: dict[str, float], + title: str = "Mass Breakdown", + figsize: tuple[float, float] = (10, 8), +) -> Figure: + """Create a mass breakdown pie chart with bar chart. + + Args: + masses: Dictionary mapping component names to masses (kg) + title: Plot title + figsize: Figure size + + Returns: + matplotlib Figure + """ + _setup_style() + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize) + + labels = list(masses.keys()) + values = list(masses.values()) + total = sum(values) + + # Color palette + colors = plt.cm.Set2(np.linspace(0, 1, len(labels))) + + # Pie chart + ax1.pie( + values, + labels=labels, + autopct=lambda pct: f"{pct:.1f}%\n({pct*total/100:.0f} kg)", + colors=colors, + startangle=90, + pctdistance=0.75, + ) + ax1.set_title("Distribution") + + # Bar chart + bars = ax2.barh(labels, values, color=colors) + ax2.set_xlabel("Mass (kg)") + ax2.set_title("Component Masses") + + # Add value labels on bars + for bar, val in zip(bars, values, strict=True): + ax2.text( + val + total * 0.01, + bar.get_y() + bar.get_height() / 2, + f"{val:.0f} kg", + va="center", + fontsize=9, + ) + + ax2.set_xlim(0, max(values) * 1.2) + + fig.suptitle(f"{title} (Total: {total:,.0f} kg)", fontsize=14, fontweight="bold") + fig.tight_layout() + + return fig + + +# ============================================================================= +# Cycle Comparison Plots +# ============================================================================= + + +@beartype +def plot_cycle_comparison_bars( + cycle_data: list[dict], + metrics: list[str] | None = None, + figsize: tuple[float, float] = (14, 8), + title: str = "Engine Cycle Comparison", +) -> Figure: + """Create multi-metric bar chart comparing engine cycles. + + Args: + cycle_data: List of dicts with keys 'name' and metric values. + Example: [ + {'name': 'Pressure-Fed', 'net_isp': 320, 'efficiency': 1.0, ...}, + {'name': 'Gas Generator', 'net_isp': 310, 'efficiency': 0.95, ...}, + ] + metrics: List of metrics to plot. Defaults to common cycle metrics. + figsize: Figure size + title: Plot title + + Returns: + matplotlib Figure + """ + _setup_style() + + if metrics is None: + metrics = ["net_isp", "efficiency", "tank_pressure_MPa", "pump_power_kW"] + + # Filter to only metrics that exist in the data + available_metrics = [] + for m in metrics: + if all(m in d for d in cycle_data): + available_metrics.append(m) + + if not available_metrics: + raise ValueError("No valid metrics found in cycle_data") + + n_cycles = len(cycle_data) + n_metrics = len(available_metrics) + + # Metric display names and units + metric_info = { + "net_isp": ("Net Isp", "s"), + "efficiency": ("Cycle Efficiency", "%"), + "tank_pressure_MPa": ("Tank Pressure", "MPa"), + "pump_power_kW": ("Pump Power", "kW"), + "turbine_power_kW": ("Turbine Power", "kW"), + } + + fig, axes = plt.subplots(1, n_metrics, figsize=figsize) + if n_metrics == 1: + axes = [axes] + + cycle_names = [d["name"] for d in cycle_data] + colors = [COLORS["primary"], COLORS["secondary"], COLORS["accent"], "#48A9A6", "#4B3F72"] + + for ax, metric in zip(axes, available_metrics, strict=True): + values = [d.get(metric, 0) for d in cycle_data] + + # Scale efficiency to percentage + if metric == "efficiency": + values = [v * 100 for v in values] + + bars = ax.bar(cycle_names, values, color=colors[:n_cycles], edgecolor="white", linewidth=1.5) + + # Add value labels on bars + for bar, val in zip(bars, values, strict=True): + height = bar.get_height() + ax.annotate( + f"{val:.1f}", + xy=(bar.get_x() + bar.get_width() / 2, height), + xytext=(0, 3), + textcoords="offset points", + ha="center", + va="bottom", + fontsize=10, + fontweight="bold", + ) + + # Axis formatting + display_name, unit = metric_info.get(metric, (metric, "")) + ax.set_ylabel(f"{display_name} ({unit})" if unit else display_name) + ax.set_title(display_name, fontsize=12, fontweight="bold") + ax.tick_params(axis="x", rotation=15) + ax.set_ylim(0, max(values) * 1.2 if max(values) > 0 else 1) + ax.grid(axis="y", alpha=0.3) + + fig.suptitle(title, fontsize=16, fontweight="bold", y=1.02) + fig.tight_layout() + + return fig + + +@beartype +def plot_cycle_radar( + cycle_data: list[dict], + metrics: list[str] | None = None, + figsize: tuple[float, float] = (10, 10), + title: str = "Cycle Comparison Radar", +) -> Figure: + """Create radar/spider chart comparing cycles across normalized dimensions. + + All metrics are normalized to 0-1 scale for comparison. + + Args: + cycle_data: List of dicts with 'name' and metric values + metrics: Metrics to include in radar. Defaults to standard set. + figsize: Figure size + title: Plot title + + Returns: + matplotlib Figure + """ + _setup_style() + + if metrics is None: + metrics = ["net_isp", "efficiency", "simplicity", "tank_mass_factor"] + + # Filter to available metrics + available_metrics = [] + for m in metrics: + if all(m in d for d in cycle_data): + available_metrics.append(m) + + if len(available_metrics) < 3: + raise ValueError("Need at least 3 metrics for radar chart") + + n_metrics = len(available_metrics) + + # Metric display names + metric_names = { + "net_isp": "Performance\n(Isp)", + "efficiency": "Efficiency", + "simplicity": "Simplicity", + "tank_mass_factor": "Low Tank\nPressure", + "reliability": "Reliability", + "trl": "Maturity\n(TRL)", + } + + # Normalize all metrics to 0-1 scale + normalized_data = [] + for d in cycle_data: + norm_d = {"name": d["name"]} + for m in available_metrics: + values = [cd[m] for cd in cycle_data] + min_val, max_val = min(values), max(values) + if max_val > min_val: + norm_d[m] = (d[m] - min_val) / (max_val - min_val) + else: + norm_d[m] = 1.0 + normalized_data.append(norm_d) + + # Setup radar chart + angles = np.linspace(0, 2 * np.pi, n_metrics, endpoint=False).tolist() + angles += angles[:1] # Complete the loop + + fig, ax = plt.subplots(figsize=figsize, subplot_kw=dict(polar=True)) + + colors = [COLORS["primary"], COLORS["secondary"], COLORS["accent"], "#48A9A6", "#4B3F72"] + + for i, d in enumerate(normalized_data): + values = [d[m] for m in available_metrics] + values += values[:1] # Complete the loop + + ax.plot(angles, values, "o-", linewidth=2, color=colors[i % len(colors)], label=d["name"]) + ax.fill(angles, values, alpha=0.25, color=colors[i % len(colors)]) + + # Set labels + labels = [metric_names.get(m, m) for m in available_metrics] + ax.set_xticks(angles[:-1]) + ax.set_xticklabels(labels, fontsize=11) + + # Radial limits + ax.set_ylim(0, 1.1) + ax.set_yticks([0.25, 0.5, 0.75, 1.0]) + ax.set_yticklabels(["25%", "50%", "75%", "100%"], fontsize=8, alpha=0.7) + + ax.legend(loc="upper right", bbox_to_anchor=(1.3, 1.0), fontsize=11) + ax.set_title(title, fontsize=14, fontweight="bold", y=1.08) + + fig.tight_layout() + + return fig + + +@beartype +def plot_cycle_tradeoff( + cycle_data: list[dict], + x_metric: str = "net_isp", + y_metric: str = "efficiency", + size_metric: str | None = None, + figsize: tuple[float, float] = (10, 8), + title: str = "Cycle Trade Space", +) -> Figure: + """Create scatter plot showing cycle trade-offs. + + Plot cycles on 2D trade space with optional bubble size for third dimension. + + Args: + cycle_data: List of dicts with 'name' and metric values + x_metric: Metric for x-axis + y_metric: Metric for y-axis + size_metric: Optional metric for bubble size + figsize: Figure size + title: Plot title + + Returns: + matplotlib Figure + """ + _setup_style() + + # Metric display info + metric_info = { + "net_isp": ("Net Isp", "s"), + "efficiency": ("Cycle Efficiency", ""), + "tank_pressure_MPa": ("Tank Pressure", "MPa"), + "pump_power_kW": ("Pump Power", "kW"), + "simplicity": ("Simplicity Score", ""), + "complexity": ("Complexity Score", ""), + } + + fig, ax = plt.subplots(figsize=figsize) + + x_vals = [d[x_metric] for d in cycle_data] + y_vals = [d[y_metric] for d in cycle_data] + + # Scale efficiency to percentage for display + if y_metric == "efficiency": + y_vals = [v * 100 for v in y_vals] + + colors = [COLORS["primary"], COLORS["secondary"], COLORS["accent"], "#48A9A6", "#4B3F72"] + + if size_metric and all(size_metric in d for d in cycle_data): + sizes = [d[size_metric] for d in cycle_data] + # Normalize sizes for display + max_size = max(sizes) if max(sizes) > 0 else 1 + normalized_sizes = [500 + 1500 * (s / max_size) for s in sizes] + else: + normalized_sizes = [800] * len(cycle_data) + + for i, (x, y, s, d) in enumerate(zip(x_vals, y_vals, normalized_sizes, cycle_data, strict=True)): + ax.scatter( + x, y, s=s, c=colors[i % len(colors)], alpha=0.7, edgecolors="white", linewidth=2, zorder=5 + ) + # Label + ax.annotate( + d["name"], + xy=(x, y), + xytext=(10, 10), + textcoords="offset points", + fontsize=11, + fontweight="bold", + arrowprops=dict(arrowstyle="-", color="gray", alpha=0.5), + ) + + # Axis formatting + x_name, x_unit = metric_info.get(x_metric, (x_metric, "")) + y_name, y_unit = metric_info.get(y_metric, (y_metric, "")) + + ax.set_xlabel(f"{x_name} ({x_unit})" if x_unit else x_name, fontsize=12) + ax.set_ylabel(f"{y_name} ({y_unit})" if y_unit else y_name, fontsize=12) + + # Add quadrant annotations + x_mid = (max(x_vals) + min(x_vals)) / 2 + y_mid = (max(y_vals) + min(y_vals)) / 2 + + ax.axvline(x=x_mid, color="gray", linestyle="--", alpha=0.3) + ax.axhline(y=y_mid, color="gray", linestyle="--", alpha=0.3) + + # Expand limits slightly + x_range = max(x_vals) - min(x_vals) + y_range = max(y_vals) - min(y_vals) + ax.set_xlim(min(x_vals) - 0.1 * x_range, max(x_vals) + 0.15 * x_range) + ax.set_ylim(min(y_vals) - 0.1 * y_range, max(y_vals) + 0.15 * y_range) + + ax.grid(True, alpha=0.3) + ax.set_title(title, fontsize=14, fontweight="bold") + + # Add size legend if applicable + if size_metric and all(size_metric in d for d in cycle_data): + size_name = metric_info.get(size_metric, (size_metric, ""))[0] + ax.text( + 0.02, + 0.02, + f"Bubble size: {size_name}", + transform=ax.transAxes, + fontsize=9, + alpha=0.7, + ) + + fig.tight_layout() + + return fig diff --git a/openrocketengine/results.py b/openrocketengine/results.py new file mode 100644 index 0000000..9cf4974 --- /dev/null +++ b/openrocketengine/results.py @@ -0,0 +1,659 @@ +"""Results visualization and Pareto front analysis for Rocket. + +This module provides plotting utilities for parametric study and uncertainty +analysis results. Integrates with the analysis module for seamless visualization. + +Example: + >>> from openrocketengine.analysis import ParametricStudy, Range + >>> from openrocketengine.results import plot_1d, plot_2d_contour, plot_pareto + >>> + >>> results = study.run() + >>> fig = plot_1d(results, "chamber_pressure", "isp_vac") + >>> fig = plot_pareto(results, "isp_vac", "thrust_to_weight", maximize=[True, True]) +""" + +from typing import Sequence + +import matplotlib.pyplot as plt +import numpy as np +from beartype import beartype +from matplotlib.figure import Figure +from numpy.typing import NDArray + +from openrocketengine.analysis import StudyResults, UncertaintyResults + + +# ============================================================================= +# Plot Style Configuration +# ============================================================================= + +COLORS = { + "primary": "#2E86AB", + "secondary": "#A23B72", + "accent": "#F18F01", + "feasible": "#2E86AB", + "infeasible": "#CCCCCC", + "pareto": "#E63946", + "grid": "#CCCCCC", + "text": "#333333", +} + + +def _setup_style() -> None: + """Configure matplotlib style for consistent appearance.""" + plt.rcParams.update({ + "font.family": "sans-serif", + "font.sans-serif": ["Helvetica", "Arial", "DejaVu Sans"], + "font.size": 11, + "axes.titlesize": 14, + "axes.labelsize": 12, + "axes.linewidth": 1.2, + "xtick.labelsize": 10, + "ytick.labelsize": 10, + "legend.fontsize": 10, + "figure.titlesize": 16, + "grid.alpha": 0.5, + }) + + +# ============================================================================= +# 1D Parameter Sweep Plots +# ============================================================================= + + +@beartype +def plot_1d( + results: StudyResults, + x_param: str, + y_metric: str, + show_infeasible: bool = True, + figsize: tuple[float, float] = (10, 6), + title: str | None = None, + xlabel: str | None = None, + ylabel: str | None = None, +) -> Figure: + """Plot a 1D parameter sweep. + + Args: + results: StudyResults from ParametricStudy + x_param: Parameter name for x-axis + y_metric: Metric name for y-axis + show_infeasible: Whether to show infeasible points (grayed out) + figsize: Figure size (width, height) + title: Optional plot title + xlabel: Optional x-axis label + ylabel: Optional y-axis label + + Returns: + matplotlib Figure + """ + _setup_style() + + fig, ax = plt.subplots(figsize=figsize) + + x = results.get_parameter(x_param) + y = results.get_metric(y_metric) + + if results.constraints_passed is not None and show_infeasible: + # Plot infeasible points first (gray) + infeasible = ~results.constraints_passed + if np.any(infeasible): + ax.scatter( + x[infeasible], y[infeasible], + c=COLORS["infeasible"], s=50, alpha=0.5, + label="Infeasible", zorder=1, + ) + + # Plot feasible points + feasible = results.constraints_passed + if np.any(feasible): + ax.scatter( + x[feasible], y[feasible], + c=COLORS["primary"], s=80, + label="Feasible", zorder=2, + ) + ax.plot( + x[feasible], y[feasible], + c=COLORS["primary"], alpha=0.5, linewidth=1.5, zorder=1, + ) + else: + ax.scatter(x, y, c=COLORS["primary"], s=80) + ax.plot(x, y, c=COLORS["primary"], alpha=0.5, linewidth=1.5) + + # Mark optimum + if results.constraints_passed is not None: + feasible_mask = results.constraints_passed + if np.any(feasible_mask): + best_idx = np.argmax(y * feasible_mask.astype(float) - (~feasible_mask) * 1e10) + ax.scatter( + [x[best_idx]], [y[best_idx]], + c=COLORS["accent"], s=150, marker="*", + label=f"Best: {y[best_idx]:.3g}", zorder=3, + ) + + ax.set_xlabel(xlabel or x_param) + ax.set_ylabel(ylabel or y_metric) + ax.set_title(title or f"{y_metric} vs {x_param}") + ax.grid(True, alpha=0.3) + ax.legend() + + fig.tight_layout() + return fig + + +@beartype +def plot_1d_multi( + results: StudyResults, + x_param: str, + y_metrics: list[str], + figsize: tuple[float, float] = (12, 6), + title: str | None = None, + xlabel: str | None = None, + normalize: bool = False, +) -> Figure: + """Plot multiple metrics vs a single parameter. + + Args: + results: StudyResults from ParametricStudy + x_param: Parameter name for x-axis + y_metrics: List of metric names to plot + figsize: Figure size + title: Optional title + xlabel: Optional x-axis label + normalize: If True, normalize all metrics to [0, 1] + + Returns: + matplotlib Figure + """ + _setup_style() + + fig, ax = plt.subplots(figsize=figsize) + + x = results.get_parameter(x_param) + colors = plt.cm.tab10(np.linspace(0, 1, len(y_metrics))) + + for i, metric in enumerate(y_metrics): + y = results.get_metric(metric) + if normalize: + y = (y - np.nanmin(y)) / (np.nanmax(y) - np.nanmin(y) + 1e-10) + + ax.plot(x, y, color=colors[i], linewidth=2, label=metric, marker="o", markersize=4) + + ax.set_xlabel(xlabel or x_param) + ax.set_ylabel("Normalized Value" if normalize else "Metric Value") + ax.set_title(title or f"Metrics vs {x_param}") + ax.grid(True, alpha=0.3) + ax.legend(loc="best") + + fig.tight_layout() + return fig + + +# ============================================================================= +# 2D Contour Plots +# ============================================================================= + + +@beartype +def plot_2d_contour( + results: StudyResults, + x_param: str, + y_param: str, + z_metric: str, + figsize: tuple[float, float] = (10, 8), + title: str | None = None, + levels: int = 20, + show_points: bool = True, + show_infeasible: bool = True, +) -> Figure: + """Plot a 2D contour for two-parameter sweeps. + + Args: + results: StudyResults from ParametricStudy (must be 2D grid) + x_param: Parameter for x-axis + y_param: Parameter for y-axis + z_metric: Metric for contour values + figsize: Figure size + title: Optional title + levels: Number of contour levels + show_points: Show scatter points at evaluated locations + show_infeasible: Mark infeasible regions + + Returns: + matplotlib Figure + """ + _setup_style() + + x = results.get_parameter(x_param) + y = results.get_parameter(y_param) + z = results.get_metric(z_metric) + + # Get unique values to determine grid shape + x_unique = np.unique(x) + y_unique = np.unique(y) + + if len(x_unique) * len(y_unique) != len(x): + raise ValueError( + f"Data is not a complete 2D grid. Got {len(x)} points, " + f"expected {len(x_unique)} x {len(y_unique)} = {len(x_unique) * len(y_unique)}" + ) + + # Reshape to 2D grid + nx, ny = len(x_unique), len(y_unique) + X = x.reshape(ny, nx) + Y = y.reshape(ny, nx) + Z = z.reshape(ny, nx) + + fig, ax = plt.subplots(figsize=figsize) + + # Contour plot + contour = ax.contourf(X, Y, Z, levels=levels, cmap="viridis") + ax.contour(X, Y, Z, levels=levels, colors="white", alpha=0.3, linewidths=0.5) + + # Colorbar + cbar = fig.colorbar(contour, ax=ax, label=z_metric) + + # Show evaluated points + if show_points: + ax.scatter(x, y, c="white", s=10, alpha=0.5, edgecolors="black", linewidths=0.5) + + # Mark infeasible regions + if show_infeasible and results.constraints_passed is not None: + infeasible = ~results.constraints_passed + if np.any(infeasible): + ax.scatter( + x[infeasible], y[infeasible], + c="red", s=30, marker="x", alpha=0.7, + label="Infeasible", + ) + ax.legend() + + ax.set_xlabel(x_param) + ax.set_ylabel(y_param) + ax.set_title(title or f"{z_metric}") + + fig.tight_layout() + return fig + + +# ============================================================================= +# Pareto Front Analysis +# ============================================================================= + + +def _compute_pareto_front( + objectives: NDArray[np.float64], + maximize: Sequence[bool], +) -> NDArray[np.bool_]: + """Compute Pareto-optimal points. + + Args: + objectives: Array of shape (n_points, n_objectives) + maximize: List of booleans indicating whether to maximize each objective + + Returns: + Boolean array indicating Pareto-optimal points + """ + n_points = objectives.shape[0] + is_pareto = np.ones(n_points, dtype=bool) + + # Flip signs for maximization objectives + obj = objectives.copy() + for i, is_max in enumerate(maximize): + if is_max: + obj[:, i] = -obj[:, i] + + for i in range(n_points): + if not is_pareto[i]: + continue + + # Check if any other point dominates this one + for j in range(n_points): + if i == j or not is_pareto[j]: + continue + + # j dominates i if j is <= in all objectives and < in at least one + dominates = np.all(obj[j] <= obj[i]) and np.any(obj[j] < obj[i]) + if dominates: + is_pareto[i] = False + break + + return is_pareto + + +@beartype +def plot_pareto( + results: StudyResults, + x_metric: str, + y_metric: str, + maximize: tuple[bool, bool] = (True, True), + feasible_only: bool = True, + figsize: tuple[float, float] = (10, 8), + title: str | None = None, + show_dominated: bool = True, +) -> Figure: + """Plot Pareto front for two objectives. + + Args: + results: StudyResults from ParametricStudy + x_metric: First objective (x-axis) + y_metric: Second objective (y-axis) + maximize: Tuple of booleans for each objective (True = maximize) + feasible_only: Only consider feasible points + figsize: Figure size + title: Optional title + show_dominated: Show dominated points in gray + + Returns: + matplotlib Figure + """ + _setup_style() + + # Get metrics + x = results.get_metric(x_metric) + y = results.get_metric(y_metric) + + # Filter to feasible if requested + if feasible_only and results.constraints_passed is not None: + mask = results.constraints_passed + else: + mask = np.ones(len(x), dtype=bool) + + # Remove NaN values + valid = mask & ~np.isnan(x) & ~np.isnan(y) + x_valid = x[valid] + y_valid = y[valid] + + if len(x_valid) == 0: + raise ValueError("No valid data points for Pareto analysis") + + # Compute Pareto front + objectives = np.column_stack([x_valid, y_valid]) + is_pareto = _compute_pareto_front(objectives, maximize) + + fig, ax = plt.subplots(figsize=figsize) + + # Plot dominated points + if show_dominated: + dominated = ~is_pareto + ax.scatter( + x_valid[dominated], y_valid[dominated], + c=COLORS["infeasible"], s=50, alpha=0.5, + label="Dominated", + ) + + # Plot Pareto front points + ax.scatter( + x_valid[is_pareto], y_valid[is_pareto], + c=COLORS["pareto"], s=100, + label=f"Pareto Front ({np.sum(is_pareto)} points)", + zorder=3, + ) + + # Connect Pareto points with line (sorted) + pareto_x = x_valid[is_pareto] + pareto_y = y_valid[is_pareto] + sort_idx = np.argsort(pareto_x) + ax.plot( + pareto_x[sort_idx], pareto_y[sort_idx], + c=COLORS["pareto"], linewidth=2, alpha=0.7, + linestyle="--", + ) + + # Add direction arrows + x_dir = "→" if maximize[0] else "←" + y_dir = "↑" if maximize[1] else "↓" + ax.annotate( + f"Better {x_dir}", + xy=(0.95, 0.02), xycoords="axes fraction", + ha="right", fontsize=10, color=COLORS["text"], + ) + ax.annotate( + f"Better {y_dir}", + xy=(0.02, 0.95), xycoords="axes fraction", + ha="left", fontsize=10, color=COLORS["text"], rotation=90, + ) + + ax.set_xlabel(x_metric) + ax.set_ylabel(y_metric) + ax.set_title(title or f"Pareto Front: {x_metric} vs {y_metric}") + ax.grid(True, alpha=0.3) + ax.legend() + + fig.tight_layout() + return fig + + +@beartype +def get_pareto_points( + results: StudyResults, + metrics: list[str], + maximize: list[bool], + feasible_only: bool = True, +) -> tuple[list[int], NDArray[np.float64]]: + """Get indices and values of Pareto-optimal points. + + Args: + results: StudyResults from ParametricStudy + metrics: List of objective metric names + maximize: List of booleans for each metric + feasible_only: Only consider feasible points + + Returns: + Tuple of (indices in original results, objective values array) + """ + # Get metrics + obj_arrays = [results.get_metric(m) for m in metrics] + objectives = np.column_stack(obj_arrays) + + # Filter to feasible + if feasible_only and results.constraints_passed is not None: + mask = results.constraints_passed + else: + mask = np.ones(objectives.shape[0], dtype=bool) + + # Remove NaN + valid = mask & ~np.any(np.isnan(objectives), axis=1) + valid_indices = np.where(valid)[0] + objectives_valid = objectives[valid] + + # Compute Pareto front + is_pareto = _compute_pareto_front(objectives_valid, maximize) + + pareto_indices = valid_indices[is_pareto].tolist() + pareto_values = objectives_valid[is_pareto] + + return pareto_indices, pareto_values + + +# ============================================================================= +# Uncertainty Visualization +# ============================================================================= + + +@beartype +def plot_histogram( + results: UncertaintyResults, + metric: str, + bins: int = 50, + show_ci: bool = True, + ci_level: float = 0.95, + figsize: tuple[float, float] = (10, 6), + title: str | None = None, + feasible_only: bool = False, +) -> Figure: + """Plot histogram of a metric from uncertainty analysis. + + Args: + results: UncertaintyResults from UncertaintyAnalysis + metric: Metric name to plot + bins: Number of histogram bins + show_ci: Show confidence interval lines + ci_level: Confidence level for CI lines + figsize: Figure size + title: Optional title + feasible_only: Only include feasible samples + + Returns: + matplotlib Figure + """ + _setup_style() + + values = results.metrics[metric] + if feasible_only: + values = values[results.constraints_passed] + + # Remove NaN + values = values[~np.isnan(values)] + + fig, ax = plt.subplots(figsize=figsize) + + # Histogram + ax.hist(values, bins=bins, color=COLORS["primary"], alpha=0.7, edgecolor="white") + + # Statistics + mean = np.mean(values) + std = np.std(values) + + ax.axvline(mean, color=COLORS["accent"], linewidth=2, label=f"Mean: {mean:.4g}") + + if show_ci: + ci = results.confidence_interval(metric, ci_level, feasible_only) + ax.axvline(ci[0], color=COLORS["secondary"], linewidth=1.5, linestyle="--", + label=f"{ci_level*100:.0f}% CI: [{ci[0]:.4g}, {ci[1]:.4g}]") + ax.axvline(ci[1], color=COLORS["secondary"], linewidth=1.5, linestyle="--") + + ax.set_xlabel(metric) + ax.set_ylabel("Frequency") + ax.set_title(title or f"Distribution of {metric}") + ax.legend() + ax.grid(True, alpha=0.3, axis="y") + + # Add text box with statistics + stats_text = f"μ = {mean:.4g}\nσ = {std:.4g}\nn = {len(values)}" + ax.text( + 0.95, 0.95, stats_text, + transform=ax.transAxes, ha="right", va="top", + fontsize=10, fontfamily="monospace", + bbox=dict(boxstyle="round", facecolor="white", alpha=0.8), + ) + + fig.tight_layout() + return fig + + +@beartype +def plot_correlation( + results: UncertaintyResults, + x_param: str, + y_metric: str, + figsize: tuple[float, float] = (10, 6), + title: str | None = None, +) -> Figure: + """Plot correlation between input parameter and output metric. + + Args: + results: UncertaintyResults from UncertaintyAnalysis + x_param: Input parameter name (from samples) + y_metric: Output metric name + figsize: Figure size + title: Optional title + + Returns: + matplotlib Figure + """ + _setup_style() + + if x_param not in results.samples: + raise ValueError(f"Parameter '{x_param}' not in samples. Available: {list(results.samples.keys())}") + + x = results.samples[x_param] + y = results.metrics[y_metric] + + # Remove NaN pairs + valid = ~(np.isnan(x) | np.isnan(y)) + x = x[valid] + y = y[valid] + + fig, ax = plt.subplots(figsize=figsize) + + ax.scatter(x, y, c=COLORS["primary"], alpha=0.3, s=20) + + # Compute correlation + corr = np.corrcoef(x, y)[0, 1] + + # Add trend line + z = np.polyfit(x, y, 1) + p = np.poly1d(z) + x_line = np.linspace(x.min(), x.max(), 100) + ax.plot(x_line, p(x_line), c=COLORS["accent"], linewidth=2, + label=f"Trend (r = {corr:.3f})") + + ax.set_xlabel(x_param) + ax.set_ylabel(y_metric) + ax.set_title(title or f"Correlation: {x_param} vs {y_metric}") + ax.legend() + ax.grid(True, alpha=0.3) + + fig.tight_layout() + return fig + + +@beartype +def plot_tornado( + results: UncertaintyResults, + metric: str, + parameters: list[str] | None = None, + figsize: tuple[float, float] = (10, 6), + title: str | None = None, +) -> Figure: + """Plot tornado chart showing sensitivity of metric to input parameters. + + Shows which parameters have the strongest influence on the metric. + + Args: + results: UncertaintyResults from UncertaintyAnalysis + metric: Metric to analyze + parameters: List of parameters to include (None = all) + figsize: Figure size + title: Optional title + + Returns: + matplotlib Figure + """ + _setup_style() + + if parameters is None: + parameters = list(results.samples.keys()) + + y_values = results.metrics[metric] + correlations: dict[str, float] = {} + + for param in parameters: + x_values = results.samples[param] + valid = ~(np.isnan(x_values) | np.isnan(y_values)) + if np.sum(valid) > 2: + corr = np.corrcoef(x_values[valid], y_values[valid])[0, 1] + correlations[param] = corr + + # Sort by absolute correlation + sorted_params = sorted(correlations.keys(), key=lambda p: abs(correlations[p])) + sorted_corrs = [correlations[p] for p in sorted_params] + + fig, ax = plt.subplots(figsize=figsize) + + y_pos = np.arange(len(sorted_params)) + colors = [COLORS["primary"] if c >= 0 else COLORS["secondary"] for c in sorted_corrs] + + ax.barh(y_pos, sorted_corrs, color=colors, alpha=0.8, edgecolor="white") + ax.set_yticks(y_pos) + ax.set_yticklabels(sorted_params) + ax.set_xlabel("Correlation Coefficient") + ax.set_title(title or f"Sensitivity of {metric}") + ax.axvline(0, color="black", linewidth=0.5) + ax.set_xlim(-1, 1) + ax.grid(True, alpha=0.3, axis="x") + + fig.tight_layout() + return fig + diff --git a/openrocketengine/system.py b/openrocketengine/system.py new file mode 100644 index 0000000..8f33eda --- /dev/null +++ b/openrocketengine/system.py @@ -0,0 +1,245 @@ +"""High-level system design API for Rocket. + +This module provides the main entry point for complete engine system design, +integrating: +- Engine performance and geometry +- Cycle analysis (pressure-fed, gas-generator, etc.) +- Thermal/cooling feasibility + +Example: + >>> from openrocketengine import EngineInputs + >>> from openrocketengine.system import design_engine_system + >>> from openrocketengine.cycles import GasGeneratorCycle + >>> from openrocketengine.units import kilonewtons, megapascals, kelvin + >>> + >>> inputs = EngineInputs.from_propellants( + ... oxidizer="LOX", + ... fuel="CH4", + ... thrust=kilonewtons(2000), + ... chamber_pressure=megapascals(30), + ... ) + >>> + >>> result = design_engine_system( + ... inputs=inputs, + ... cycle=GasGeneratorCycle(turbine_inlet_temp=kelvin(900)), + ... check_cooling=True, + ... ) + >>> + >>> print(f"Net Isp: {result.cycle.net_isp.value:.1f} s") + >>> print(f"Cooling feasible: {result.cooling.feasible}") +""" + +from dataclasses import dataclass +from typing import Any + +from beartype import beartype + +from openrocketengine.engine import ( + EngineGeometry, + EngineInputs, + EnginePerformance, + compute_geometry, + compute_performance, +) +from openrocketengine.cycles.base import CycleConfiguration, CyclePerformance, analyze_cycle +from openrocketengine.thermal.regenerative import CoolingFeasibility, check_cooling_feasibility +from openrocketengine.units import kelvin + + +@beartype +@dataclass(frozen=True) +class EngineSystemResult: + """Complete engine system design result. + + Contains all analysis results from engine performance through + cycle analysis and thermal assessment. + + Attributes: + inputs: Original engine inputs + performance: Ideal engine performance (before cycle losses) + geometry: Engine geometry + cycle: Cycle analysis results (if cycle provided) + cooling: Cooling feasibility results (if check_cooling=True) + feasible: Overall feasibility (cycle closes AND cooling ok) + warnings: Combined warnings from all analyses + """ + + inputs: EngineInputs + performance: EnginePerformance + geometry: EngineGeometry + cycle: CyclePerformance | None + cooling: CoolingFeasibility | None + feasible: bool + warnings: list[str] + + +@beartype +def design_engine_system( + inputs: EngineInputs, + cycle: CycleConfiguration | None = None, + check_cooling: bool = True, + coolant: str | None = None, + max_wall_temp: Any | None = None, +) -> EngineSystemResult: + """Design a complete engine system with cycle and thermal analysis. + + This is the main entry point for rocket engine system design. It: + 1. Computes ideal engine performance and geometry + 2. Analyzes the engine cycle (if specified) + 3. Checks cooling feasibility (if requested) + 4. Returns a comprehensive result with all analyses + + Args: + inputs: Engine input parameters (from EngineInputs.from_propellants or manual) + cycle: Engine cycle configuration (PressureFedCycle, GasGeneratorCycle, etc.) + If None, only basic performance is computed. + check_cooling: Whether to perform cooling feasibility analysis + coolant: Coolant name for cooling analysis. If None, uses fuel. + max_wall_temp: Maximum allowed wall temperature [K]. If None, uses defaults. + + Returns: + EngineSystemResult with all analysis results + + Example: + >>> # Basic design without cycle analysis + >>> result = design_engine_system(inputs) + >>> print(f"Isp: {result.performance.isp.value:.1f} s") + >>> + >>> # Full system design with gas generator cycle + >>> from openrocketengine.cycles import GasGeneratorCycle + >>> result = design_engine_system( + ... inputs=inputs, + ... cycle=GasGeneratorCycle(turbine_inlet_temp=kelvin(900)), + ... check_cooling=True, + ... ) + """ + all_warnings: list[str] = [] + + # Step 1: Compute basic engine performance and geometry + performance = compute_performance(inputs) + geometry = compute_geometry(inputs, performance) + + # Step 2: Cycle analysis (if cycle provided) + cycle_result: CyclePerformance | None = None + if cycle is not None: + cycle_result = analyze_cycle(inputs, performance, geometry, cycle) + all_warnings.extend(cycle_result.warnings) + + # Step 3: Cooling feasibility (if requested) + cooling_result: CoolingFeasibility | None = None + if check_cooling: + # Determine coolant (default to fuel) + if coolant is None: + # Try to infer fuel from engine name + coolant = _infer_coolant(inputs) + + cooling_result = check_cooling_feasibility( + inputs=inputs, + performance=performance, + geometry=geometry, + coolant=coolant, + max_wall_temp=max_wall_temp, + ) + all_warnings.extend(cooling_result.warnings) + + # Determine overall feasibility + feasible = True + if cycle_result is not None and not cycle_result.feasible: + feasible = False + if cooling_result is not None and not cooling_result.feasible: + feasible = False + + return EngineSystemResult( + inputs=inputs, + performance=performance, + geometry=geometry, + cycle=cycle_result, + cooling=cooling_result, + feasible=feasible, + warnings=all_warnings, + ) + + +def _infer_coolant(inputs: EngineInputs) -> str: + """Infer coolant from engine inputs.""" + if inputs.name: + name_upper = inputs.name.upper() + if "CH4" in name_upper or "METHANE" in name_upper or "METHALOX" in name_upper: + return "CH4" + elif "LH2" in name_upper or "HYDROGEN" in name_upper or "HYDROLOX" in name_upper: + return "LH2" + elif "RP1" in name_upper or "KEROSENE" in name_upper or "KEROLOX" in name_upper: + return "RP1" + elif "ETHANOL" in name_upper: + return "Ethanol" + + # Default to RP-1 + return "RP1" + + +@beartype +def format_system_summary(result: EngineSystemResult) -> str: + """Format complete system design results as readable string. + + Args: + result: EngineSystemResult from design_engine_system() + + Returns: + Formatted multi-line string summary + """ + name = result.inputs.name or "Unnamed Engine" + status = "✓ FEASIBLE" if result.feasible else "✗ NOT FEASIBLE" + + lines = [ + "=" * 70, + f"ENGINE SYSTEM DESIGN: {name}", + f"Overall Status: {status}", + "=" * 70, + "", + "PERFORMANCE (Ideal):", + f" Thrust (SL): {result.inputs.thrust.to('kN').value:.1f} kN", + f" Isp (SL): {result.performance.isp.value:.1f} s", + f" Isp (Vac): {result.performance.isp_vac.value:.1f} s", + f" C*: {result.performance.cstar.value:.0f} m/s", + f" Mass Flow: {result.performance.mdot.value:.2f} kg/s", + "", + "GEOMETRY:", + f" Throat Dia: {result.geometry.throat_diameter.to('m').value*100:.1f} cm", + f" Exit Dia: {result.geometry.exit_diameter.to('m').value*100:.1f} cm", + f" Chamber Dia: {result.geometry.chamber_diameter.to('m').value*100:.1f} cm", + f" Expansion Ratio: {result.geometry.expansion_ratio:.1f}", + ] + + if result.cycle is not None: + lines.extend([ + "", + f"CYCLE ({result.cycle.cycle_type.name}):", + f" Net Isp: {result.cycle.net_isp.value:.1f} s", + f" Cycle Efficiency:{result.cycle.cycle_efficiency*100:.1f}%", + f" Turbine Power: {result.cycle.turbine_power.value/1000:.0f} kW", + f" GG/Turbine Flow: {result.cycle.turbine_mass_flow.value:.2f} kg/s", + f" Tank P (Ox): {result.cycle.tank_pressure_ox.to('bar').value:.1f} bar", + f" Tank P (Fuel): {result.cycle.tank_pressure_fuel.to('bar').value:.1f} bar", + ]) + + if result.cooling is not None: + lines.extend([ + "", + "COOLING:", + f" Throat Heat Flux:{result.cooling.throat_heat_flux.value/1e6:.1f} MW/m²", + f" Max Wall Temp: {result.cooling.max_wall_temp.value:.0f} K", + f" Allowed Temp: {result.cooling.max_allowed_temp.value:.0f} K", + f" Coolant ΔT: {result.cooling.coolant_temp_rise.value:.0f} K", + f" Flow Margin: {result.cooling.flow_margin:.2f}x", + f" Pressure Drop: {result.cooling.pressure_drop.to('bar').value:.1f} bar", + ]) + + if result.warnings: + lines.extend(["", "WARNINGS:"]) + for warning in result.warnings: + lines.append(f" ⚠ {warning}") + + lines.append("=" * 70) + + return "\n".join(lines) + diff --git a/openrocketengine/tanks.py b/openrocketengine/tanks.py new file mode 100644 index 0000000..9a241cc --- /dev/null +++ b/openrocketengine/tanks.py @@ -0,0 +1,76 @@ +"""Tank sizing and propellant utilities for OpenRocketEngine. + +This module provides propellant density data and tank sizing utilities. +""" + +from beartype import beartype + +# Propellant densities at ~1 atm and typical storage temperatures [kg/m³] +PROPELLANT_DENSITIES = { + # Oxidizers + "LOX": 1141.0, # Liquid oxygen @ 90K + "N2O4": 1440.0, # Nitrogen tetroxide + "N2O": 1220.0, # Nitrous oxide @ 0°C + "H2O2": 1450.0, # High-test hydrogen peroxide (90%) + "IRFNA": 1550.0, # Inhibited red fuming nitric acid + # Fuels + "LH2": 70.8, # Liquid hydrogen @ 20K + "RP1": 810.0, # Kerosene (RP-1) + "CH4": 422.8, # Liquid methane @ 111K + "LCH4": 422.8, # Alias for liquid methane + "ETHANOL": 789.0, # Ethanol + "MMH": 880.0, # Monomethylhydrazine + "UDMH": 791.0, # Unsymmetrical dimethylhydrazine + "N2H4": 1021.0, # Hydrazine + "METHANOL": 792.0, # Methanol + "ISOPROPANOL": 786.0, # Isopropyl alcohol + "JET-A": 820.0, # Jet fuel +} + + +@beartype +def get_propellant_density(propellant: str) -> float: + """Get the density of a propellant in kg/m³. + + Args: + propellant: Propellant name (e.g., "LOX", "CH4", "RP1") + + Returns: + Density in kg/m³ + + Raises: + ValueError: If propellant is not found in database + """ + propellant_upper = propellant.upper() + + if propellant_upper in PROPELLANT_DENSITIES: + return PROPELLANT_DENSITIES[propellant_upper] + + # Try common aliases + aliases = { + "O2": "LOX", + "OXYGEN": "LOX", + "METHANE": "CH4", + "KEROSENE": "RP1", + "HYDROGEN": "LH2", + "H2": "LH2", + } + + if propellant_upper in aliases: + return PROPELLANT_DENSITIES[aliases[propellant_upper]] + + available = ", ".join(sorted(PROPELLANT_DENSITIES.keys())) + raise ValueError( + f"Unknown propellant: {propellant}. Available: {available}" + ) + + +@beartype +def list_propellants() -> list[str]: + """List all available propellants in the database. + + Returns: + List of propellant names + """ + return sorted(PROPELLANT_DENSITIES.keys()) + diff --git a/openrocketengine/thermal/__init__.py b/openrocketengine/thermal/__init__.py new file mode 100644 index 0000000..a34ac5c --- /dev/null +++ b/openrocketengine/thermal/__init__.py @@ -0,0 +1,57 @@ +"""Thermal analysis module for Rocket. + +This module provides tools for analyzing thermal loads on rocket engine +components and evaluating cooling system feasibility. + +Key capabilities: +- Heat flux estimation using Bartz correlation +- Regenerative cooling feasibility screening +- Wall temperature prediction +- Coolant property database + +Example: + >>> from openrocketengine import EngineInputs, design_engine + >>> from openrocketengine.thermal import estimate_heat_flux, check_cooling_feasibility + >>> + >>> inputs = EngineInputs.from_propellants("LOX", "CH4", ...) + >>> performance, geometry = design_engine(inputs) + >>> + >>> q_throat = estimate_heat_flux(inputs, performance, geometry, location="throat") + >>> print(f"Throat heat flux: {q_throat.value/1e6:.1f} MW/m²") + >>> + >>> cooling = check_cooling_feasibility( + ... inputs, performance, geometry, + ... coolant="CH4", + ... max_wall_temp=kelvin(800), + ... ) + >>> print(f"Cooling feasible: {cooling.feasible}") +""" + +from openrocketengine.thermal.heat_flux import ( + adiabatic_wall_temperature, + bartz_heat_flux, + estimate_heat_flux, + heat_flux_profile, + recovery_factor, +) +from openrocketengine.thermal.regenerative import ( + CoolingFeasibility, + CoolantProperties, + check_cooling_feasibility, + get_coolant_properties, +) + +__all__ = [ + # Heat flux estimation + "adiabatic_wall_temperature", + "bartz_heat_flux", + "estimate_heat_flux", + "heat_flux_profile", + "recovery_factor", + # Regenerative cooling + "CoolingFeasibility", + "CoolantProperties", + "check_cooling_feasibility", + "get_coolant_properties", +] + diff --git a/openrocketengine/thermal/heat_flux.py b/openrocketengine/thermal/heat_flux.py new file mode 100644 index 0000000..c4f09ec --- /dev/null +++ b/openrocketengine/thermal/heat_flux.py @@ -0,0 +1,306 @@ +"""Heat flux estimation for rocket engine combustion chambers and nozzles. + +This module implements the Bartz correlation and related methods for +estimating convective heat transfer in rocket engines. + +The Bartz correlation is the industry-standard method for preliminary +heat flux estimation, derived from turbulent pipe flow correlations +modified for rocket engine conditions. + +References: + - Bartz, D.R., "A Simple Equation for Rapid Estimation of Rocket + Nozzle Convective Heat Transfer Coefficients", Jet Propulsion, 1957 + - Huzel & Huang, "Modern Engineering for Design of Liquid-Propellant + Rocket Engines", Chapter 4 +""" + +import math + +import numpy as np +from beartype import beartype + +from openrocketengine.engine import EngineGeometry, EngineInputs, EnginePerformance +from openrocketengine.units import Quantity, kelvin + + +@beartype +def recovery_factor(prandtl: float, laminar: bool = False) -> float: + """Calculate the recovery factor for adiabatic wall temperature. + + The recovery factor accounts for the difference between the + stagnation temperature and the actual adiabatic wall temperature + due to boundary layer effects. + + Args: + prandtl: Prandtl number of the gas [-] + laminar: If True, use laminar correlation; else turbulent + + Returns: + Recovery factor r [-], typically 0.85-0.92 for turbulent flow + """ + if laminar: + return math.sqrt(prandtl) # r = Pr^0.5 for laminar + else: + return prandtl ** (1/3) # r = Pr^(1/3) for turbulent + + +@beartype +def adiabatic_wall_temperature( + stagnation_temp: Quantity, + mach: float, + gamma: float, + recovery_factor: float, +) -> Quantity: + """Calculate adiabatic wall temperature. + + The adiabatic wall temperature is the temperature the wall would + reach if there were no heat transfer (perfectly insulated wall). + It's used as the driving temperature difference for heat flux. + + T_aw = T_0 * [1 + r * (gamma-1)/2 * M^2] / [1 + (gamma-1)/2 * M^2] + + Args: + stagnation_temp: Chamber/stagnation temperature [K] + mach: Local Mach number [-] + gamma: Ratio of specific heats [-] + recovery_factor: Recovery factor r [-] + + Returns: + Adiabatic wall temperature [K] + """ + T0 = stagnation_temp.to("K").value + gm1 = gamma - 1 + + # Temperature ratio T/T0 from isentropic relations + T_ratio = 1 / (1 + gm1/2 * mach**2) + + # Static temperature + T_static = T0 * T_ratio + + # Adiabatic wall temperature + # T_aw = T_static + r * (T0 - T_static) + T_aw = T_static + recovery_factor * (T0 - T_static) + + return kelvin(T_aw) + + +@beartype +def bartz_heat_flux( + chamber_pressure: Quantity, + chamber_temp: Quantity, + throat_diameter: Quantity, + local_diameter: Quantity, + characteristic_velocity: Quantity, + gamma: float, + molecular_weight: float, + local_mach: float, + wall_temp: Quantity | None = None, +) -> Quantity: + """Calculate convective heat flux using the Bartz correlation. + + The Bartz equation estimates the convective heat transfer coefficient: + + h = (0.026/D_t^0.2) * (mu^0.2 * cp / Pr^0.6) * (p_c / c*)^0.8 * + (D_t/R_c)^0.1 * (A_t/A)^0.9 * sigma + + where sigma is a correction factor for property variations. + + Args: + chamber_pressure: Chamber pressure [Pa] + chamber_temp: Chamber temperature [K] + throat_diameter: Throat diameter [m] + local_diameter: Local diameter at evaluation point [m] + characteristic_velocity: c* [m/s] + gamma: Ratio of specific heats [-] + molecular_weight: Molecular weight [kg/kmol] + local_mach: Local Mach number [-] + wall_temp: Wall temperature [K]. If None, estimates at 600K. + + Returns: + Heat flux [W/m²] + """ + # Extract values in SI + pc = chamber_pressure.to("Pa").value + Tc = chamber_temp.to("K").value + Dt = throat_diameter.to("m").value + D = local_diameter.to("m").value + cstar = characteristic_velocity.to("m/s").value + MW = molecular_weight + + # Wall temperature (estimate if not provided) + Tw = wall_temp.to("K").value if wall_temp else 600.0 + + # Gas properties + R_specific = 8314.46 / MW # J/(kg·K) + cp = gamma * R_specific / (gamma - 1) # J/(kg·K) + + # Estimate viscosity using Sutherland's law + # Reference: air at 273K, mu0 = 1.71e-5 Pa·s + # For combustion products, use higher reference + mu_ref = 4e-5 # Pa·s at Tc_ref + Tc_ref = 3000 # K + S = 200 # Sutherland constant (approximate for combustion products) + + mu = mu_ref * (Tc / Tc_ref) ** 1.5 * (Tc_ref + S) / (Tc + S) + + # Prandtl number + # Pr = mu * cp / k, approximate k from cp and Pr ~ 0.7-0.9 + Pr = 0.8 # Typical for combustion products + + # Area ratio + area_ratio = (D / Dt) ** 2 + + # Sigma correction factor for property variation across boundary layer + # sigma = 1 / [(Tw/Tc * (1 + (gamma-1)/2 * M^2) + 0.5)^0.68 * + # (1 + (gamma-1)/2 * M^2)^0.12] + gm1 = gamma - 1 + temp_factor = Tw / Tc * (1 + gm1/2 * local_mach**2) + 0.5 + sigma = 1 / (temp_factor ** 0.68 * (1 + gm1/2 * local_mach**2) ** 0.12) + + # Bartz correlation for heat transfer coefficient + # h = (0.026 / Dt^0.2) * (mu^0.2 * cp / Pr^0.6) * (pc/cstar)^0.8 * + # (Dt/Dt)^0.1 * (At/A)^0.9 * sigma + h = (0.026 / Dt**0.2) * (mu**0.2 * cp / Pr**0.6) * \ + (pc / cstar)**0.8 * (1/area_ratio)**0.9 * sigma + + # Calculate adiabatic wall temperature + r = recovery_factor(Pr) + T_aw = Tc * (1 + r * gm1/2 * local_mach**2) / (1 + gm1/2 * local_mach**2) + + # Heat flux + q = h * (T_aw - Tw) + + return Quantity(q, "Pa", "pressure") # W/m² = Pa·m/s, using Pa as proxy + + +@beartype +def estimate_heat_flux( + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + location: str = "throat", + wall_temp: Quantity | None = None, +) -> Quantity: + """Estimate heat flux at a specific location in the engine. + + Provides a simplified interface to the Bartz correlation. + + Args: + inputs: Engine input parameters + performance: Computed engine performance + geometry: Computed engine geometry + location: Location to evaluate: "throat", "chamber", or "exit" + wall_temp: Wall temperature [K]. If None, estimates based on location. + + Returns: + Heat flux [W/m²] + """ + # Determine local conditions based on location + if location == "throat": + local_diameter = geometry.throat_diameter + local_mach = 1.0 + default_wall_temp = 700 # K, hot at throat + elif location == "chamber": + local_diameter = geometry.chamber_diameter + local_mach = 0.1 # Low Mach in chamber + default_wall_temp = 600 # K + elif location == "exit": + local_diameter = geometry.exit_diameter + local_mach = performance.exit_mach + default_wall_temp = 400 # K, cooler at exit + else: + raise ValueError(f"Unknown location: {location}. Use 'throat', 'chamber', or 'exit'") + + if wall_temp is None: + wall_temp = kelvin(default_wall_temp) + + return bartz_heat_flux( + chamber_pressure=inputs.chamber_pressure, + chamber_temp=inputs.chamber_temp, + throat_diameter=geometry.throat_diameter, + local_diameter=local_diameter, + characteristic_velocity=performance.cstar, + gamma=inputs.gamma, + molecular_weight=inputs.molecular_weight, + local_mach=local_mach, + wall_temp=wall_temp, + ) + + +@beartype +def heat_flux_profile( + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + n_points: int = 50, + wall_temp: Quantity | None = None, +) -> tuple[list[float], list[float]]: + """Calculate heat flux profile along the engine. + + Returns heat flux from chamber through throat to exit. + + Args: + inputs: Engine input parameters + performance: Computed engine performance + geometry: Computed engine geometry + n_points: Number of points in profile + wall_temp: Wall temperature (constant along length) + + Returns: + Tuple of (x_positions, heat_fluxes) where x is normalized (0=chamber, 1=exit) + """ + # Get key dimensions + Dc = geometry.chamber_diameter.to("m").value + Dt = geometry.throat_diameter.to("m").value + De = geometry.exit_diameter.to("m").value + + # Generate normalized positions + x_norm = np.linspace(0, 1, n_points) + + # Estimate local diameter and Mach number along engine + # Simplified: linear convergent, bell divergent + diameters = [] + machs = [] + + for x in x_norm: + if x < 0.3: + # Chamber region + D = Dc + M = 0.1 + x * 0.3 # Low, slowly increasing + elif x < 0.5: + # Convergent section + frac = (x - 0.3) / 0.2 + D = Dc - frac * (Dc - Dt) + M = 0.1 + frac * 0.9 + elif x < 0.55: + # Throat region + D = Dt + M = 1.0 + else: + # Divergent section + frac = (x - 0.55) / 0.45 + D = Dt + frac * (De - Dt) + # Simple approximation for supersonic Mach + M = 1.0 + frac * (performance.exit_mach - 1.0) + + diameters.append(D) + machs.append(max(0.1, M)) + + # Calculate heat flux at each point + heat_fluxes = [] + for D, M in zip(diameters, machs, strict=True): + q = bartz_heat_flux( + chamber_pressure=inputs.chamber_pressure, + chamber_temp=inputs.chamber_temp, + throat_diameter=geometry.throat_diameter, + local_diameter=Quantity(D, "m", "length"), + characteristic_velocity=performance.cstar, + gamma=inputs.gamma, + molecular_weight=inputs.molecular_weight, + local_mach=M, + wall_temp=wall_temp or kelvin(600), + ) + heat_fluxes.append(q.value) + + return list(x_norm), heat_fluxes + diff --git a/openrocketengine/thermal/regenerative.py b/openrocketengine/thermal/regenerative.py new file mode 100644 index 0000000..820454f --- /dev/null +++ b/openrocketengine/thermal/regenerative.py @@ -0,0 +1,452 @@ +"""Regenerative cooling feasibility analysis. + +Regenerative cooling uses the fuel (or oxidizer) as a coolant, flowing +through channels in the chamber/nozzle wall before injection. This is +the most common cooling method for high-performance rocket engines. + +This module provides screening-level analysis to determine if a given +engine design can be regeneratively cooled within material limits. + +Key considerations: +- Heat flux at the throat (highest) +- Coolant heat capacity and flow rate +- Wall material temperature limits +- Coolant-side pressure drop + +References: + - Huzel & Huang, Chapter 4 + - Sutton & Biblarz, Chapter 8 +""" + +import math +from dataclasses import dataclass + +from beartype import beartype + +from openrocketengine.engine import EngineGeometry, EngineInputs, EnginePerformance +from openrocketengine.thermal.heat_flux import estimate_heat_flux +from openrocketengine.units import Quantity, kelvin, pascals + + +# ============================================================================= +# Coolant Properties Database +# ============================================================================= + + +@beartype +@dataclass(frozen=True, slots=True) +class CoolantProperties: + """Thermophysical properties of a coolant. + + Properties are approximate values at typical operating conditions. + For detailed design, use property tables or CoolProp. + + Attributes: + name: Coolant name + density: Liquid density [kg/m³] + specific_heat: Specific heat capacity [J/(kg·K)] + thermal_conductivity: Thermal conductivity [W/(m·K)] + viscosity: Dynamic viscosity [Pa·s] + boiling_point: Boiling point at 1 atm [K] + max_temp: Maximum recommended temperature before decomposition [K] + """ + + name: str + density: float + specific_heat: float + thermal_conductivity: float + viscosity: float + boiling_point: float + max_temp: float + + +# Coolant property database +# Values are approximate at typical inlet conditions +COOLANT_DATABASE: dict[str, CoolantProperties] = { + "RP1": CoolantProperties( + name="RP-1 (Kerosene)", + density=810.0, + specific_heat=2000.0, + thermal_conductivity=0.12, + viscosity=0.0015, + boiling_point=490.0, + max_temp=600.0, # Coking limit + ), + "CH4": CoolantProperties( + name="Liquid Methane", + density=422.0, + specific_heat=3500.0, + thermal_conductivity=0.19, + viscosity=0.00012, + boiling_point=111.0, + max_temp=500.0, # Before significant decomposition + ), + "LH2": CoolantProperties( + name="Liquid Hydrogen", + density=70.8, + specific_heat=14300.0, + thermal_conductivity=0.10, + viscosity=0.000013, + boiling_point=20.0, + max_temp=300.0, # Stays liquid/supercritical + ), + "Ethanol": CoolantProperties( + name="Ethanol", + density=789.0, + specific_heat=2440.0, + thermal_conductivity=0.17, + viscosity=0.0011, + boiling_point=351.0, + max_temp=500.0, + ), + "N2O4": CoolantProperties( + name="Nitrogen Tetroxide", + density=1450.0, + specific_heat=1560.0, + thermal_conductivity=0.12, + viscosity=0.0004, + boiling_point=294.0, + max_temp=400.0, + ), + "MMH": CoolantProperties( + name="Monomethylhydrazine", + density=878.0, + specific_heat=2920.0, + thermal_conductivity=0.22, + viscosity=0.0008, + boiling_point=360.0, + max_temp=450.0, + ), +} + + +@beartype +def get_coolant_properties(coolant: str) -> CoolantProperties: + """Get properties for a coolant. + + Args: + coolant: Coolant name (e.g., "RP1", "CH4", "LH2") + + Returns: + CoolantProperties for the coolant + + Raises: + ValueError: If coolant not found in database + """ + # Normalize name + name = coolant.upper().replace("-", "").replace(" ", "") + + for key, props in COOLANT_DATABASE.items(): + if key.upper() == name: + return props + + available = list(COOLANT_DATABASE.keys()) + raise ValueError(f"Unknown coolant '{coolant}'. Available: {available}") + + +# ============================================================================= +# Cooling Feasibility Analysis +# ============================================================================= + + +@beartype +@dataclass(frozen=True, slots=True) +class CoolingFeasibility: + """Results of regenerative cooling feasibility analysis. + + Attributes: + feasible: Whether cooling is feasible within constraints + max_wall_temp: Maximum predicted wall temperature [K] + max_allowed_temp: Maximum allowed wall temperature [K] + coolant_temp_rise: Temperature rise of coolant [K] + coolant_outlet_temp: Predicted coolant outlet temperature [K] + throat_heat_flux: Heat flux at throat [W/m²] + total_heat_load: Total heat load to coolant [W] + required_coolant_flow: Minimum required coolant flow [kg/s] + available_coolant_flow: Available coolant flow (fuel or ox) [kg/s] + flow_margin: Ratio of available/required flow [-] + pressure_drop: Estimated coolant-side pressure drop [Pa] + channel_velocity: Estimated coolant velocity in channels [m/s] + warnings: List of warnings or concerns + """ + + feasible: bool + max_wall_temp: Quantity + max_allowed_temp: Quantity + coolant_temp_rise: Quantity + coolant_outlet_temp: Quantity + throat_heat_flux: Quantity + total_heat_load: Quantity + required_coolant_flow: Quantity + available_coolant_flow: Quantity + flow_margin: float + pressure_drop: Quantity + channel_velocity: float + warnings: list[str] + + +@beartype +def check_cooling_feasibility( + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + coolant: str, + coolant_inlet_temp: Quantity | None = None, + max_wall_temp: Quantity | None = None, + wall_material: str = "copper_alloy", + num_channels: int | None = None, + channel_aspect_ratio: float = 3.0, +) -> CoolingFeasibility: + """Check if regenerative cooling is feasible for this engine design. + + This is a screening-level analysis that estimates whether the engine + can be cooled within material limits using the available coolant flow. + + Args: + inputs: Engine input parameters + performance: Computed engine performance + geometry: Computed engine geometry + coolant: Coolant name (typically the fuel: "RP1", "CH4", "LH2") + coolant_inlet_temp: Coolant inlet temperature [K]. Defaults to storage temp. + max_wall_temp: Maximum allowed wall temperature [K]. Defaults based on material. + wall_material: Wall material for thermal limits + num_channels: Number of cooling channels. If None, estimated. + channel_aspect_ratio: Channel height/width ratio + + Returns: + CoolingFeasibility assessment + """ + warnings: list[str] = [] + + # Get coolant properties + try: + coolant_props = get_coolant_properties(coolant) + except ValueError: + # Use generic properties if unknown + coolant_props = CoolantProperties( + name=coolant, + density=800.0, + specific_heat=2000.0, + thermal_conductivity=0.15, + viscosity=0.001, + boiling_point=300.0, + max_temp=500.0, + ) + warnings.append(f"Unknown coolant '{coolant}', using generic properties") + + # Set default inlet temperature (storage temperature) + if coolant_inlet_temp is None: + # Cryogenic propellants near boiling point, storables at ~300K + if coolant.upper() in ["LH2", "CH4", "LOX"]: + T_inlet = coolant_props.boiling_point + 10 # Just above boiling + else: + T_inlet = 300.0 # Room temperature + else: + T_inlet = coolant_inlet_temp.to("K").value + + # Set default max wall temperature based on material + if max_wall_temp is None: + wall_temps = { + "copper_alloy": 800, # GRCop-84, NARloy-Z + "nickel_alloy": 1000, # Inconel 718 + "steel": 700, # Stainless steel + "niobium": 1500, # Refractory metal + } + T_wall_max = wall_temps.get(wall_material, 800) + else: + T_wall_max = max_wall_temp.to("K").value + + # Estimate heat flux at throat (worst case) + q_throat = estimate_heat_flux( + inputs, performance, geometry, + location="throat", + wall_temp=kelvin(T_wall_max * 0.9), # Assume wall near limit + ) + + # Also get chamber and exit heat fluxes for total heat load + q_chamber = estimate_heat_flux(inputs, performance, geometry, location="chamber") + q_exit = estimate_heat_flux(inputs, performance, geometry, location="exit") + + # Estimate total heat load + # Simplified: use average heat flux × total surface area + Dt = geometry.throat_diameter.to("m").value + Dc = geometry.chamber_diameter.to("m").value + De = geometry.exit_diameter.to("m").value + Lc = geometry.chamber_length.to("m").value + Ln = geometry.nozzle_length.to("m").value + + # Surface areas (approximate) + A_chamber = math.pi * Dc * Lc + A_convergent = math.pi * (Dc + Dt) / 2 * (Dc - Dt) / (2 * math.tan(math.radians(45))) * 0.5 + A_throat = math.pi * Dt * Dt * 0.1 # Small throat region + A_divergent = math.pi * (Dt + De) / 2 * Ln * 0.7 # Approximate bell surface + + # Average heat fluxes for each region (throat is highest) + q_avg_chamber = q_chamber.value * 0.3 # Lower than throat + q_avg_convergent = (q_chamber.value + q_throat.value) / 2 + q_avg_throat = q_throat.value + q_avg_divergent = (q_throat.value + q_exit.value) / 2 + + # Total heat load + Q_total = ( + q_avg_chamber * A_chamber + + q_avg_convergent * A_convergent + + q_avg_throat * A_throat + + q_avg_divergent * A_divergent + ) + + # Available coolant flow (assume fuel is coolant) + mdot_coolant_available = performance.mdot_fuel.to("kg/s").value + + # Required coolant flow to absorb heat without exceeding temperature limit + # Q = mdot * cp * delta_T + # delta_T_max = T_coolant_max - T_inlet + T_coolant_max = min(coolant_props.max_temp, T_wall_max - 100) # Stay below wall + delta_T_max = T_coolant_max - T_inlet + + if delta_T_max <= 0: + warnings.append("Coolant inlet temperature exceeds maximum allowable") + delta_T_max = 100 # Use minimum for calculation + + mdot_coolant_required = Q_total / (coolant_props.specific_heat * delta_T_max) + + # Flow margin + flow_margin = mdot_coolant_available / mdot_coolant_required if mdot_coolant_required > 0 else float('inf') + + # Actual temperature rise with available flow + if mdot_coolant_available > 0: + delta_T_actual = Q_total / (mdot_coolant_available * coolant_props.specific_heat) + else: + delta_T_actual = float('inf') + + T_coolant_out = T_inlet + delta_T_actual + + # Estimate wall temperature + # T_wall = T_coolant + Q/(h_coolant * A) + # For screening, use correlation: T_wall ~ T_coolant + q * (t_wall / k_wall + 1/h_coolant) + # Simplified: wall runs ~100-200K above coolant + T_wall_estimate = T_coolant_out + 150 # K above coolant + + # Pressure drop estimation + # Number of channels (estimate if not provided) + if num_channels is None: + # Roughly 1 channel per mm of circumference at throat + num_channels = max(20, int(math.pi * Dt * 1000)) + + # Channel dimensions (rough estimate) + channel_width = (math.pi * Dt) / num_channels * 0.6 # 60% channel, 40% rib + channel_height = channel_width * channel_aspect_ratio + channel_area = channel_width * channel_height + + # Coolant velocity + total_channel_area = num_channels * channel_area + v_coolant = mdot_coolant_available / (coolant_props.density * total_channel_area) + + # Pressure drop (Darcy-Weisbach approximation) + # ΔP = f * (L/D_h) * (ρ * v²/2) + D_h = 4 * channel_area / (2 * (channel_width + channel_height)) # Hydraulic diameter + Re = coolant_props.density * v_coolant * D_h / coolant_props.viscosity + f = 0.316 / Re**0.25 if Re > 2300 else 64 / max(Re, 100) # Friction factor + + L_total = Lc + Ln # Total cooled length + dp = f * (L_total / D_h) * (coolant_props.density * v_coolant**2 / 2) + + # Add losses for bends, manifolds + dp *= 1.5 + + # Feasibility assessment + feasible = True + + if T_wall_estimate > T_wall_max: + feasible = False + warnings.append( + f"Estimated wall temp {T_wall_estimate:.0f}K exceeds limit {T_wall_max:.0f}K" + ) + + if flow_margin < 1.0: + feasible = False + warnings.append( + f"Insufficient coolant flow: need {mdot_coolant_required:.2f} kg/s, " + f"have {mdot_coolant_available:.2f} kg/s" + ) + + if T_coolant_out > coolant_props.max_temp: + warnings.append( + f"Coolant outlet temp {T_coolant_out:.0f}K exceeds max {coolant_props.max_temp:.0f}K" + ) + + if v_coolant > 50: + warnings.append(f"High coolant velocity {v_coolant:.1f} m/s may cause erosion") + + if dp > 5e6: + warnings.append(f"High pressure drop {dp/1e6:.1f} MPa") + + # Heat flux at throat check + if q_throat.value > 50e6: # > 50 MW/m² + warnings.append( + f"Very high throat heat flux {q_throat.value/1e6:.1f} MW/m² - " + "film cooling may be needed" + ) + + return CoolingFeasibility( + feasible=feasible, + max_wall_temp=kelvin(T_wall_estimate), + max_allowed_temp=kelvin(T_wall_max), + coolant_temp_rise=kelvin(delta_T_actual), + coolant_outlet_temp=kelvin(T_coolant_out), + throat_heat_flux=q_throat, + total_heat_load=Quantity(Q_total, "N", "force"), # W, using N as proxy + required_coolant_flow=Quantity(mdot_coolant_required, "kg/s", "mass_flow"), + available_coolant_flow=Quantity(mdot_coolant_available, "kg/s", "mass_flow"), + flow_margin=flow_margin, + pressure_drop=pascals(dp), + channel_velocity=v_coolant, + warnings=warnings, + ) + + +@beartype +def format_cooling_summary(result: CoolingFeasibility) -> str: + """Format cooling feasibility results as readable string. + + Args: + result: CoolingFeasibility from check_cooling_feasibility() + + Returns: + Formatted multi-line string + """ + status = "✓ FEASIBLE" if result.feasible else "✗ NOT FEASIBLE" + + lines = [ + f"{'=' * 60}", + f"REGENERATIVE COOLING ANALYSIS", + f"Status: {status}", + f"{'=' * 60}", + "", + "THERMAL:", + f" Throat Heat Flux: {result.throat_heat_flux.value/1e6:.1f} MW/m²", + f" Total Heat Load: {result.total_heat_load.value/1e6:.2f} MW", + f" Max Wall Temp: {result.max_wall_temp.value:.0f} K", + f" Allowed Wall Temp: {result.max_allowed_temp.value:.0f} K", + "", + "COOLANT:", + f" Temperature Rise: {result.coolant_temp_rise.value:.0f} K", + f" Outlet Temperature: {result.coolant_outlet_temp.value:.0f} K", + f" Required Flow: {result.required_coolant_flow.value:.2f} kg/s", + f" Available Flow: {result.available_coolant_flow.value:.2f} kg/s", + f" Flow Margin: {result.flow_margin:.2f}x", + "", + "HYDRAULICS:", + f" Channel Velocity: {result.channel_velocity:.1f} m/s", + f" Pressure Drop: {result.pressure_drop.to('bar').value:.1f} bar", + ] + + if result.warnings: + lines.extend(["", "WARNINGS:"]) + for warning in result.warnings: + lines.append(f" ⚠ {warning}") + + lines.append(f"{'=' * 60}") + + return "\n".join(lines) + diff --git a/openrocketengine/units.py b/openrocketengine/units.py index c039bf0..02af45d 100644 --- a/openrocketengine/units.py +++ b/openrocketengine/units.py @@ -99,6 +99,11 @@ # Density "kg/m^3": (1.0, "density"), "lbm/ft^3": (16.0185, "density"), + # Power + "W": (1.0, "power"), + "kW": (1000.0, "power"), + "MW": (1e6, "power"), + "hp": (745.7, "power"), # Mechanical horsepower # Specific impulse (time dimension but special meaning) # Note: Isp in seconds is the same in SI and Imperial # Dimensionless @@ -604,6 +609,30 @@ def dimensionless(value: float | int) -> Quantity: return Quantity(value, "1", "dimensionless") +@beartype +def watts(value: float | int) -> Quantity: + """Create a power quantity in Watts.""" + return Quantity(value, "W", "power") + + +@beartype +def kilowatts(value: float | int) -> Quantity: + """Create a power quantity in kilowatts.""" + return Quantity(value, "kW", "power") + + +@beartype +def megawatts(value: float | int) -> Quantity: + """Create a power quantity in megawatts.""" + return Quantity(value, "MW", "power") + + +@beartype +def horsepower(value: float | int) -> Quantity: + """Create a power quantity in horsepower.""" + return Quantity(value, "hp", "power") + + # ============================================================================= # Constants # ============================================================================= diff --git a/pyproject.toml b/pyproject.toml index bed610e..695ec72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "openrocketengine" +name = "rocket" version = "0.2.0" -description = "OpenRocketEngine - Tools for liquid rocket engine design and analysis" +description = "Rocket - Tools for liquid rocket engine design and analysis" readme = "README.md" requires-python = ">=3.11" license = "MIT" @@ -27,6 +27,8 @@ dependencies = [ "numba>=0.60", "matplotlib>=3.9", "rocketcea>=1.2.1", + "polars>=1.35.0", + "tqdm>=4.66", ] [project.optional-dependencies] @@ -45,6 +47,9 @@ Repository = "https://github.com/openrocketengine/openrocketengine" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.build.targets.wheel] +packages = ["rocket"] + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-v --tb=short" diff --git a/rocket/__init__.py b/rocket/__init__.py new file mode 100644 index 0000000..60d5d51 --- /dev/null +++ b/rocket/__init__.py @@ -0,0 +1,159 @@ +"""OpenRocketEngine - Tools for liquid rocket engine design and analysis. + +This package provides a comprehensive toolkit for designing and analyzing +liquid propellant rocket engines using isentropic flow equations. + +Example: + >>> from rocket import EngineInputs, design_engine + >>> from rocket.units import newtons, megapascals, kelvin, meters, pascals + >>> + >>> inputs = EngineInputs( + ... thrust=newtons(5000), + ... chamber_pressure=megapascals(2.0), + ... chamber_temp=kelvin(3200), + ... exit_pressure=pascals(101325), + ... molecular_weight=22.0, + ... gamma=1.2, + ... lstar=meters(1.0), + ... mixture_ratio=2.0, + ... ) + >>> performance, geometry = design_engine(inputs) + >>> print(f"Isp: {performance.isp.value:.1f} s") +""" + +__version__ = "0.2.0" + +# Core engine design +from rocket.engine import ( + EngineGeometry, + EngineInputs, + EnginePerformance, + compute_geometry, + compute_performance, + design_engine, + format_geometry_summary, + format_performance_summary, + isp_at_altitude, + thrust_at_altitude, +) + +# Nozzle contour generation +from rocket.nozzle import ( + NozzleContour, + conical_contour, + full_chamber_contour, + generate_nozzle_from_geometry, + rao_bell_contour, +) + +# Visualization +from rocket.plotting import ( + plot_cycle_comparison_bars, + plot_cycle_radar, + plot_cycle_tradeoff, + plot_engine_cross_section, + plot_engine_dashboard, + plot_mass_breakdown, + plot_nozzle_contour, + plot_performance_vs_altitude, +) + +# Propellants and thermochemistry +from rocket.propellants import ( + CombustionProperties, + get_combustion_properties, + get_optimal_mixture_ratio, + is_cea_available, + list_database_propellants, +) + +# Output management +from rocket.output import ( + OutputContext, + clean_outputs, + get_default_output_dir, + list_outputs, +) + +# Analysis framework +from rocket.analysis import ( + Distribution, + LogNormal, + MultiObjectiveOptimizer, + Normal, + ParametricStudy, + ParetoResults, + Range, + StudyResults, + Triangular, + UncertaintyAnalysis, + UncertaintyResults, + Uniform, +) + +# System-level design +from rocket.system import ( + EngineSystemResult, + design_engine_system, + format_system_summary, +) + +__all__ = [ + # Version + "__version__", + # Engine dataclasses + "EngineInputs", + "EnginePerformance", + "EngineGeometry", + # Engine computation + "compute_performance", + "compute_geometry", + "design_engine", + "thrust_at_altitude", + "isp_at_altitude", + "format_performance_summary", + "format_geometry_summary", + # Nozzle + "NozzleContour", + "rao_bell_contour", + "conical_contour", + "full_chamber_contour", + "generate_nozzle_from_geometry", + # Plotting + "plot_engine_cross_section", + "plot_nozzle_contour", + "plot_performance_vs_altitude", + "plot_engine_dashboard", + "plot_mass_breakdown", + "plot_cycle_comparison_bars", + "plot_cycle_radar", + "plot_cycle_tradeoff", + # Propellants + "CombustionProperties", + "get_combustion_properties", + "get_optimal_mixture_ratio", + "is_cea_available", + "list_database_propellants", + # Output management + "OutputContext", + "get_default_output_dir", + "list_outputs", + "clean_outputs", + # Analysis framework + "ParametricStudy", + "UncertaintyAnalysis", + "MultiObjectiveOptimizer", + "StudyResults", + "UncertaintyResults", + "ParetoResults", + "Range", + "Distribution", + "Normal", + "Uniform", + "Triangular", + "LogNormal", + # System-level design + "EngineSystemResult", + "design_engine_system", + "format_system_summary", +] diff --git a/rocket/analysis.py b/rocket/analysis.py new file mode 100644 index 0000000..5cd1adf --- /dev/null +++ b/rocket/analysis.py @@ -0,0 +1,1184 @@ +"""Parametric analysis and uncertainty quantification for Rocket. + +This module provides general-purpose tools for trade studies, sensitivity +analysis, and uncertainty quantification. The design is introspection-based +to avoid brittleness when dataclass fields change. + +Key Design Principles: +- Works with ANY frozen dataclass + computation function +- Uses dataclass introspection to validate parameters (no hardcoding) +- Automatically discovers output metrics from return types +- Unit-aware parameter ranges + +Example: + >>> from rocket import EngineInputs, design_engine + >>> from rocket.analysis import ParametricStudy, Range + >>> + >>> study = ParametricStudy( + ... compute=design_engine, + ... base=inputs, + ... vary={"chamber_pressure": Range(5, 15, n=11, unit="MPa")}, + ... ) + >>> results = study.run() + >>> results.plot("chamber_pressure", "isp_vac") +""" + +import dataclasses +import itertools +from collections.abc import Callable, Sequence +from dataclasses import dataclass, fields, is_dataclass, replace +from pathlib import Path +from typing import Any, Generic, TypeVar + +import numpy as np +import polars as pl +from beartype import beartype +from numpy.typing import NDArray +from tqdm import tqdm + +from rocket.units import CONVERSIONS, Quantity + +# Type variables for generic analysis +T_Input = TypeVar("T_Input") # Input dataclass type +T_Output = TypeVar("T_Output") # Output type (can be dataclass or tuple) + + +# ============================================================================= +# Parameter Range Specifications +# ============================================================================= + + +@beartype +@dataclass(frozen=True, slots=True) +class Range: + """Specification for a parameter range in a parametric study. + + Supports both dimensionless parameters and Quantity fields with units. + + Examples: + >>> Range(5, 15, n=11, unit="MPa") # 5-15 MPa in 11 steps + >>> Range(2.0, 3.5, n=5) # Dimensionless parameter + >>> Range(values=[2.5, 2.7, 3.0, 3.2]) # Explicit values + """ + + start: float | int | None = None + stop: float | int | None = None + n: int = 10 + unit: str | None = None + values: Sequence[float | int] | None = None + + def __post_init__(self) -> None: + """Validate range specification.""" + if self.values is not None: + if self.start is not None or self.stop is not None: + raise ValueError("Cannot specify both values and start/stop") + else: + if self.start is None or self.stop is None: + raise ValueError("Must specify either values or start/stop") + + def generate(self) -> NDArray[np.float64]: + """Generate array of parameter values.""" + if self.values is not None: + return np.array(self.values, dtype=np.float64) + return np.linspace(self.start, self.stop, self.n) + + def to_quantities(self, dimension: str) -> list[Quantity]: + """Convert range values to Quantity objects. + + Args: + dimension: The dimension of the quantity (e.g., "pressure") + + Returns: + List of Quantity objects + """ + values = self.generate() + if self.unit is None: + raise ValueError(f"Unit required to convert to Quantity for dimension {dimension}") + + return [Quantity(float(v), self.unit, dimension) for v in values] + + +@beartype +@dataclass(frozen=True, slots=True) +class Distribution: + """Base class for probability distributions in uncertainty analysis.""" + + pass + + +@beartype +@dataclass(frozen=True, slots=True) +class Normal(Distribution): + """Normal (Gaussian) distribution. + + Args: + mean: Distribution mean + std: Standard deviation + unit: Optional unit for Quantity fields + """ + + mean: float | int + std: float | int + unit: str | None = None + + def sample(self, n: int, rng: np.random.Generator) -> NDArray[np.float64]: + """Generate n samples from the distribution.""" + return rng.normal(self.mean, self.std, n) + + +@beartype +@dataclass(frozen=True, slots=True) +class Uniform(Distribution): + """Uniform distribution. + + Args: + low: Lower bound + high: Upper bound + unit: Optional unit for Quantity fields + """ + + low: float | int + high: float | int + unit: str | None = None + + def sample(self, n: int, rng: np.random.Generator) -> NDArray[np.float64]: + """Generate n samples from the distribution.""" + return rng.uniform(self.low, self.high, n) + + +@beartype +@dataclass(frozen=True, slots=True) +class Triangular(Distribution): + """Triangular distribution. + + Args: + low: Lower bound + mode: Most likely value + high: Upper bound + unit: Optional unit for Quantity fields + """ + + low: float | int + mode: float | int + high: float | int + unit: str | None = None + + def sample(self, n: int, rng: np.random.Generator) -> NDArray[np.float64]: + """Generate n samples from the distribution.""" + return rng.triangular(self.low, self.mode, self.high, n) + + +@beartype +@dataclass(frozen=True, slots=True) +class LogNormal(Distribution): + """Log-normal distribution. + + Args: + mean: Mean of the underlying normal distribution + sigma: Standard deviation of the underlying normal distribution + unit: Optional unit for Quantity fields + """ + + mean: float | int + sigma: float | int + unit: str | None = None + + def sample(self, n: int, rng: np.random.Generator) -> NDArray[np.float64]: + """Generate n samples from the distribution.""" + return rng.lognormal(self.mean, self.sigma, n) + + +# ============================================================================= +# Introspection Utilities +# ============================================================================= + + +def _get_dataclass_fields(obj: Any) -> dict[str, dataclasses.Field]: + """Get all fields from a dataclass, including nested ones.""" + if not is_dataclass(obj): + raise TypeError(f"Expected dataclass, got {type(obj).__name__}") + return {f.name: f for f in fields(obj)} + + +def _get_field_info(base: Any, field_name: str) -> tuple[Any, str | None]: + """Get the current value and dimension (if Quantity) of a field. + + Args: + base: The base dataclass instance + field_name: Name of the field to inspect + + Returns: + Tuple of (current_value, dimension_or_none) + """ + if not hasattr(base, field_name): + raise ValueError(f"Field '{field_name}' not found in {type(base).__name__}") + + value = getattr(base, field_name) + + if isinstance(value, Quantity): + return value, value.dimension + return value, None + + +def _create_modified_input( + base: T_Input, + field_name: str, + value: float | Quantity, + original_dimension: str | None, +) -> T_Input: + """Create a modified copy of the input with one field changed. + + Handles both Quantity and plain numeric fields. + """ + current_value = getattr(base, field_name) + + if isinstance(current_value, Quantity): + # Field is a Quantity - ensure we create a proper Quantity + if isinstance(value, Quantity): + new_value = value + else: + # Value is numeric, need unit from original + new_value = Quantity(float(value), current_value.unit, current_value.dimension) + else: + # Field is a plain numeric type + new_value = value + + return replace(base, **{field_name: new_value}) + + +def _extract_metrics(result: Any, prefix: str = "") -> dict[str, float]: + """Recursively extract all numeric values from a result. + + Handles dataclasses, tuples, and nested structures. + Returns flat dict with keys for nested values. + + For tuples of dataclasses (common pattern like (Performance, Geometry)), + fields are extracted without prefixes to keep names clean. + """ + metrics: dict[str, float] = {} + + if isinstance(result, tuple): + # Handle tuple of results (e.g., (performance, geometry)) + # Extract fields directly without prefixes for cleaner column names + for item in result: + metrics.update(_extract_metrics(item, prefix)) + + elif is_dataclass(result) and not isinstance(result, type): + # Handle dataclass + for field in fields(result): + field_value = getattr(result, field.name) + field_key = f"{prefix}{field.name}" if prefix else field.name + + if isinstance(field_value, Quantity): + metrics[field_key] = float(field_value.value) + elif isinstance(field_value, (int, float)): + metrics[field_key] = float(field_value) + elif is_dataclass(field_value): + metrics.update(_extract_metrics(field_value, f"{field_key}.")) + + return metrics + + +# ============================================================================= +# Study Results +# ============================================================================= + + +@beartype +@dataclass +class StudyResults: + """Results from a parametric study or uncertainty analysis. + + Contains all input combinations, computed outputs, and extracted metrics. + Provides methods for plotting, filtering, and export. + + Attributes: + inputs: List of input parameter combinations + outputs: List of computed results + metrics: Dict mapping metric names to arrays of values + parameters: Dict mapping parameter names to arrays of values + constraints_passed: Boolean array indicating which runs passed constraints + """ + + inputs: list[Any] + outputs: list[Any] + metrics: dict[str, NDArray[np.float64]] + parameters: dict[str, NDArray[np.float64]] + constraints_passed: NDArray[np.bool_] | None = None + + @property + def n_runs(self) -> int: + """Number of runs in the study.""" + return len(self.inputs) + + @property + def n_feasible(self) -> int: + """Number of runs that passed all constraints.""" + if self.constraints_passed is None: + return self.n_runs + return int(np.sum(self.constraints_passed)) + + def get_metric(self, name: str, feasible_only: bool = False) -> NDArray[np.float64]: + """Get values for a specific metric. + + Args: + name: Metric name (e.g., "isp", "throat_diameter") + feasible_only: If True, only return values where constraints passed + + Returns: + Array of metric values + """ + if name not in self.metrics: + available = list(self.metrics.keys()) + raise ValueError(f"Unknown metric '{name}'. Available: {available}") + + values = self.metrics[name] + if feasible_only and self.constraints_passed is not None: + return values[self.constraints_passed] + return values + + def get_parameter(self, name: str, feasible_only: bool = False) -> NDArray[np.float64]: + """Get values for a specific input parameter. + + Args: + name: Parameter name (e.g., "chamber_pressure") + feasible_only: If True, only return values where constraints passed + + Returns: + Array of parameter values + """ + if name not in self.parameters: + available = list(self.parameters.keys()) + raise ValueError(f"Unknown parameter '{name}'. Available: {available}") + + values = self.parameters[name] + if feasible_only and self.constraints_passed is not None: + return values[self.constraints_passed] + return values + + def get_best( + self, + metric: str, + maximize: bool = True, + feasible_only: bool = True, + ) -> tuple[Any, Any, float]: + """Get the best run according to a metric. + + Args: + metric: Metric to optimize + maximize: If True, find maximum; if False, find minimum + feasible_only: Only consider runs that passed constraints + + Returns: + Tuple of (best_input, best_output, best_metric_value) + """ + values = self.get_metric(metric, feasible_only=False) + mask = self.constraints_passed if feasible_only and self.constraints_passed is not None else np.ones(len(values), dtype=bool) + + if not np.any(mask): + raise ValueError("No feasible solutions found") + + masked_values = np.where(mask, values, -np.inf if maximize else np.inf) + best_idx = int(np.argmax(masked_values) if maximize else np.argmin(masked_values)) + + return self.inputs[best_idx], self.outputs[best_idx], float(values[best_idx]) + + def to_dataframe(self) -> pl.DataFrame: + """Export results to a Polars DataFrame. + + Returns: + Polars DataFrame with parameters and metrics + """ + data = {**self.parameters, **self.metrics} + if self.constraints_passed is not None: + data["feasible"] = self.constraints_passed + return pl.DataFrame(data) + + def to_csv(self, path: str | Path) -> None: + """Export results to CSV file. + + Args: + path: Output file path + """ + df = self.to_dataframe() + df.write_csv(path) + + def list_metrics(self) -> list[str]: + """List all available metric names.""" + return list(self.metrics.keys()) + + def list_parameters(self) -> list[str]: + """List all varied parameter names.""" + return list(self.parameters.keys()) + + +# ============================================================================= +# Parametric Study +# ============================================================================= + + +@beartype +class ParametricStudy(Generic[T_Input, T_Output]): + """General-purpose parametric study framework. + + Runs a computation over a grid of parameter variations, automatically + discovering valid parameters through dataclass introspection. + + This design is non-brittle: + - Adding new fields to input dataclasses automatically makes them available + - No hardcoded parameter names + - Works with any frozen dataclass + computation function + + Example: + >>> study = ParametricStudy( + ... compute=design_engine, + ... base=inputs, + ... vary={ + ... "chamber_pressure": Range(5, 15, n=11, unit="MPa"), + ... "mixture_ratio": Range(2.5, 3.5, n=5), + ... }, + ... ) + >>> results = study.run() + """ + + def __init__( + self, + compute: Callable[[T_Input], T_Output], + base: T_Input, + vary: dict[str, Range | Sequence[Any]], + constraints: list[Callable[[T_Output], bool]] | None = None, + ) -> None: + """Initialize parametric study. + + Args: + compute: Function that takes input and returns output + base: Base input dataclass with default values + vary: Dict mapping field names to Range specifications or plain sequences. + Use Range for unit-aware sweeps, or a plain list for discrete values. + constraints: Optional list of constraint functions. + Each takes output and returns True if feasible. + """ + self.compute = compute + self.base = base + self.vary = vary + self.constraints = constraints or [] + + # Validate that all varied parameters exist in the base dataclass + self._validate_parameters() + + def _validate_parameters(self) -> None: + """Validate that all varied parameters exist and have compatible types.""" + valid_fields = _get_dataclass_fields(self.base) + + for param_name, param_spec in self.vary.items(): + if param_name not in valid_fields: + raise ValueError( + f"Parameter '{param_name}' not found in {type(self.base).__name__}. " + f"Valid fields: {list(valid_fields.keys())}" + ) + + # Check unit compatibility for Quantity fields (only if Range is used) + current_value = getattr(self.base, param_name) + if isinstance(current_value, Quantity) and isinstance(param_spec, Range): + if param_spec.unit is None: + raise ValueError( + f"Parameter '{param_name}' is a Quantity, but no unit specified in Range. " + f"Current unit: {current_value.unit}" + ) + # Verify unit is valid and has compatible dimension + if param_spec.unit not in CONVERSIONS: + raise ValueError(f"Unknown unit '{param_spec.unit}' for parameter '{param_name}'") + + range_dim = CONVERSIONS[param_spec.unit][1] + if range_dim != current_value.dimension: + raise ValueError( + f"Unit '{param_spec.unit}' has dimension '{range_dim}', " + f"but field '{param_name}' has dimension '{current_value.dimension}'" + ) + + def _generate_grid(self) -> list[dict[str, float | Quantity]]: + """Generate all parameter combinations.""" + # Generate values for each parameter + param_values: dict[str, list[Any]] = {} + + for param_name, param_spec in self.vary.items(): + current_value = getattr(self.base, param_name) + + if isinstance(param_spec, Range): + # Range specification + if isinstance(current_value, Quantity): + # Generate Quantity values + param_values[param_name] = param_spec.to_quantities(current_value.dimension) + else: + # Generate plain numeric values + param_values[param_name] = list(param_spec.generate()) + else: + # Plain sequence - use values as-is + param_values[param_name] = list(param_spec) + + # Generate all combinations + keys = list(param_values.keys()) + value_lists = [param_values[k] for k in keys] + combinations = list(itertools.product(*value_lists)) + + return [dict(zip(keys, combo, strict=True)) for combo in combinations] + + def run(self, progress: bool = False) -> StudyResults: + """Run the parametric study. + + Args: + progress: If True, print progress (requires tqdm for fancy progress bar) + + Returns: + StudyResults containing all inputs, outputs, and extracted metrics + """ + grid = self._generate_grid() + n_total = len(grid) + + inputs_list: list[T_Input] = [] + outputs_list: list[T_Output] = [] + all_metrics: list[dict[str, float]] = [] + all_params: list[dict[str, float]] = [] + constraints_passed: list[bool] = [] + + # Create iterator with optional progress bar + iterator: Any = grid + if progress: + iterator = tqdm(grid, desc="Running study", total=n_total) + + for i, param_combo in enumerate(iterator): + # Create modified input + modified_input = self.base + for param_name, param_value in param_combo.items(): + modified_input = _create_modified_input( + modified_input, + param_name, + param_value, + current_value.dimension if isinstance((current_value := getattr(self.base, param_name)), Quantity) else None, + ) + + # Run computation + try: + output = self.compute(modified_input) + success = True + except Exception as e: + # Store None for failed runs + output = None # type: ignore + success = False + if progress and not hasattr(iterator, 'set_postfix'): + print(f" Run {i+1}/{n_total} failed: {e}") + + inputs_list.append(modified_input) + outputs_list.append(output) + + # Extract metrics + if success and output is not None: + metrics = _extract_metrics(output) + all_metrics.append(metrics) + + # Check constraints + passed = all(constraint(output) for constraint in self.constraints) + else: + all_metrics.append({}) + passed = False + + constraints_passed.append(passed) + + # Extract parameter values (numeric form for plotting) + param_dict: dict[str, float] = {} + for param_name, param_value in param_combo.items(): + if isinstance(param_value, Quantity): + param_dict[param_name] = float(param_value.value) + else: + param_dict[param_name] = float(param_value) + all_params.append(param_dict) + + # Consolidate metrics into arrays + if all_metrics and all_metrics[0]: + metric_names = set() + for m in all_metrics: + metric_names.update(m.keys()) + + metrics_arrays = { + name: np.array([m.get(name, np.nan) for m in all_metrics]) + for name in metric_names + } + else: + metrics_arrays = {} + + # Consolidate parameters into arrays + if all_params: + param_names = list(all_params[0].keys()) + params_arrays = { + name: np.array([p[name] for p in all_params]) + for name in param_names + } + else: + params_arrays = {} + + return StudyResults( + inputs=inputs_list, + outputs=outputs_list, + metrics=metrics_arrays, + parameters=params_arrays, + constraints_passed=np.array(constraints_passed), + ) + + +# ============================================================================= +# Uncertainty Analysis +# ============================================================================= + + +@beartype +class UncertaintyAnalysis(Generic[T_Input, T_Output]): + """Monte Carlo uncertainty quantification. + + Samples input parameters from specified distributions and propagates + uncertainty through the computation. + + Example: + >>> analysis = UncertaintyAnalysis( + ... compute=design_engine, + ... base=inputs, + ... distributions={ + ... "gamma": Normal(1.22, 0.02), + ... "chamber_temp": Normal(3200, 50, unit="K"), + ... }, + ... ) + >>> results = analysis.run(n_samples=1000) + >>> print(f"Isp = {results.mean('isp'):.1f} ± {results.std('isp'):.1f} s") + """ + + def __init__( + self, + compute: Callable[[T_Input], T_Output], + base: T_Input, + distributions: dict[str, Distribution], + constraints: list[Callable[[T_Output], bool]] | None = None, + seed: int | None = None, + ) -> None: + """Initialize uncertainty analysis. + + Args: + compute: Function that takes input and returns output + base: Base input dataclass with nominal values + distributions: Dict mapping field names to Distribution specifications + constraints: Optional constraint functions + seed: Random seed for reproducibility + """ + self.compute = compute + self.base = base + self.distributions = distributions + self.constraints = constraints or [] + self.rng = np.random.default_rng(seed) + + self._validate_parameters() + + def _validate_parameters(self) -> None: + """Validate that all uncertain parameters exist.""" + valid_fields = _get_dataclass_fields(self.base) + + for param_name, dist in self.distributions.items(): + if param_name not in valid_fields: + raise ValueError( + f"Parameter '{param_name}' not found in {type(self.base).__name__}. " + f"Valid fields: {list(valid_fields.keys())}" + ) + + # Check unit compatibility for Quantity fields + current_value = getattr(self.base, param_name) + if isinstance(current_value, Quantity) and (not hasattr(dist, 'unit') or dist.unit is None): # type: ignore + raise ValueError( + f"Parameter '{param_name}' is a Quantity, but no unit specified in Distribution" + ) + + def run(self, n_samples: int = 1000, progress: bool = False) -> "UncertaintyResults": + """Run Monte Carlo uncertainty analysis. + + Args: + n_samples: Number of Monte Carlo samples + progress: If True, show progress indicator + + Returns: + UncertaintyResults with statistics and samples + """ + # Generate all samples upfront + samples: dict[str, NDArray[np.float64]] = {} + for param_name, dist in self.distributions.items(): + samples[param_name] = dist.sample(n_samples, self.rng) + + inputs_list: list[T_Input] = [] + outputs_list: list[T_Output] = [] + all_metrics: list[dict[str, float]] = [] + constraints_passed: list[bool] = [] + + iterator: Any = range(n_samples) + if progress: + iterator = tqdm(range(n_samples), desc="Sampling") + + for i in iterator: + # Create modified input with sampled values + modified_input = self.base + + for param_name, dist in self.distributions.items(): + sampled_value = samples[param_name][i] + current_value = getattr(self.base, param_name) + + if isinstance(current_value, Quantity): + # Create Quantity with sampled value + unit = dist.unit # type: ignore + new_value = Quantity(float(sampled_value), unit, current_value.dimension) + else: + new_value = float(sampled_value) + + modified_input = replace(modified_input, **{param_name: new_value}) + + # Run computation + try: + output = self.compute(modified_input) + success = True + except Exception: + output = None # type: ignore + success = False + + inputs_list.append(modified_input) + outputs_list.append(output) + + if success and output is not None: + metrics = _extract_metrics(output) + all_metrics.append(metrics) + passed = all(constraint(output) for constraint in self.constraints) + else: + all_metrics.append({}) + passed = False + + constraints_passed.append(passed) + + # Consolidate metrics + if all_metrics and all_metrics[0]: + metric_names = set() + for m in all_metrics: + metric_names.update(m.keys()) + + metrics_arrays = { + name: np.array([m.get(name, np.nan) for m in all_metrics]) + for name in metric_names + } + else: + metrics_arrays = {} + + return UncertaintyResults( + inputs=inputs_list, + outputs=outputs_list, + metrics=metrics_arrays, + samples=samples, + constraints_passed=np.array(constraints_passed), + n_samples=n_samples, + ) + + +@beartype +@dataclass +class UncertaintyResults: + """Results from uncertainty analysis. + + Provides statistical summaries and access to all samples. + """ + + inputs: list[Any] + outputs: list[Any] + metrics: dict[str, NDArray[np.float64]] + samples: dict[str, NDArray[np.float64]] + constraints_passed: NDArray[np.bool_] + n_samples: int + + def mean(self, metric: str, feasible_only: bool = False) -> float: + """Get mean value of a metric.""" + values = self._get_values(metric, feasible_only) + return float(np.nanmean(values)) + + def std(self, metric: str, feasible_only: bool = False) -> float: + """Get standard deviation of a metric.""" + values = self._get_values(metric, feasible_only) + return float(np.nanstd(values)) + + def percentile( + self, metric: str, p: float | Sequence[float], feasible_only: bool = False + ) -> float | NDArray[np.float64]: + """Get percentile(s) of a metric. + + Args: + metric: Metric name + p: Percentile(s) to compute (0-100) + feasible_only: Only use feasible samples + + Returns: + Percentile value(s) + """ + values = self._get_values(metric, feasible_only) + result = np.nanpercentile(values, p) + if isinstance(p, (int, float)): + return float(result) + return result + + def confidence_interval( + self, metric: str, confidence: float = 0.95, feasible_only: bool = False + ) -> tuple[float, float]: + """Get confidence interval for a metric. + + Args: + metric: Metric name + confidence: Confidence level (0-1), default 0.95 for 95% CI + feasible_only: Only use feasible samples + + Returns: + Tuple of (lower_bound, upper_bound) + """ + alpha = 1 - confidence + lower_p = alpha / 2 * 100 + upper_p = (1 - alpha / 2) * 100 + + values = self._get_values(metric, feasible_only) + return ( + float(np.nanpercentile(values, lower_p)), + float(np.nanpercentile(values, upper_p)), + ) + + def probability_of_success(self) -> float: + """Get fraction of samples that passed all constraints.""" + return float(np.mean(self.constraints_passed)) + + def _get_values(self, metric: str, feasible_only: bool) -> NDArray[np.float64]: + """Get metric values, optionally filtered to feasible only.""" + if metric not in self.metrics: + available = list(self.metrics.keys()) + raise ValueError(f"Unknown metric '{metric}'. Available: {available}") + + values = self.metrics[metric] + if feasible_only: + values = values[self.constraints_passed] + return values + + def summary(self, metrics: list[str] | None = None) -> str: + """Generate a text summary of uncertainty results. + + Args: + metrics: List of metrics to summarize. If None, summarizes all. + + Returns: + Formatted string summary + """ + if metrics is None: + metrics = [m for m in self.metrics if not m.endswith("_si")] + + lines = [ + "Uncertainty Analysis Results", + "=" * 50, + f"Samples: {self.n_samples}", + f"Feasible: {np.sum(self.constraints_passed)} ({self.probability_of_success()*100:.1f}%)", + "", + f"{'Metric':<25} {'Mean':>12} {'Std':>12} {'95% CI':>20}", + "-" * 70, + ] + + for metric in metrics: + if metric in self.metrics: + mean = self.mean(metric) + std = self.std(metric) + ci = self.confidence_interval(metric) + lines.append( + f"{metric:<25} {mean:>12.4g} {std:>12.4g} [{ci[0]:.4g}, {ci[1]:.4g}]" + ) + + return "\n".join(lines) + + def to_dataframe(self) -> pl.DataFrame: + """Export results to Polars DataFrame.""" + data = {**self.samples, **self.metrics, "feasible": self.constraints_passed} + return pl.DataFrame(data) + + def to_csv(self, path: str | Path) -> None: + """Export results to CSV file. + + Args: + path: Output file path + """ + df = self.to_dataframe() + df.write_csv(path) + + +# ============================================================================= +# Multi-Objective Optimization +# ============================================================================= + + +@beartype +def compute_pareto_front( + objectives: NDArray[np.float64], + maximize: Sequence[bool], +) -> NDArray[np.bool_]: + """Identify Pareto-optimal points in a set of objectives. + + A point is Pareto-optimal if no other point dominates it (i.e., no + other point is better in all objectives simultaneously). + + Args: + objectives: Array of shape (n_points, n_objectives) + maximize: List of booleans indicating whether to maximize each objective + + Returns: + Boolean array indicating which points are Pareto-optimal + """ + n_points = objectives.shape[0] + is_pareto = np.ones(n_points, dtype=bool) + + # Flip signs for maximization (we'll minimize internally) + obj = objectives.copy() + for i, is_max in enumerate(maximize): + if is_max: + obj[:, i] = -obj[:, i] + + for i in range(n_points): + if not is_pareto[i]: + continue + + for j in range(n_points): + if i == j or not is_pareto[j]: + continue + + # j dominates i if j is <= in all objectives and < in at least one + if np.all(obj[j] <= obj[i]) and np.any(obj[j] < obj[i]): + is_pareto[i] = False + break + + return is_pareto + + +@beartype +class MultiObjectiveOptimizer(Generic[T_Input, T_Output]): + """Multi-objective optimizer for finding Pareto-optimal designs. + + Uses a combination of grid search and local refinement to find + designs on the Pareto frontier. + + Example: + >>> optimizer = MultiObjectiveOptimizer( + ... compute=design_engine, + ... base=inputs, + ... objectives=["isp_vac", "thrust_to_weight"], + ... maximize=[True, True], + ... vary={"chamber_pressure": Range(5, 20, n=10, unit="MPa")}, + ... ) + >>> pareto_results = optimizer.run() + """ + + def __init__( + self, + compute: Callable[[T_Input], T_Output], + base: T_Input, + objectives: list[str], + maximize: list[bool], + vary: dict[str, Range | Sequence[Any]], + constraints: list[Callable[[T_Output], bool]] | None = None, + ) -> None: + """Initialize multi-objective optimizer. + + Args: + compute: Function that takes input and returns output + base: Base input dataclass + objectives: List of metric names to optimize + maximize: List of booleans for each objective (True = maximize) + vary: Dict mapping field names to Range specifications or plain sequences + constraints: Optional constraint functions + """ + self.compute = compute + self.base = base + self.objectives = objectives + self.maximize = maximize + self.vary = vary + self.constraints = constraints or [] + + if len(objectives) != len(maximize): + raise ValueError("objectives and maximize must have same length") + + def run(self, progress: bool = False) -> "ParetoResults": + """Run the multi-objective optimization. + + First performs a parametric sweep, then identifies Pareto-optimal points. + + Args: + progress: If True, show progress indicator + + Returns: + ParetoResults with Pareto-optimal designs + """ + # Run parametric study + study = ParametricStudy( + compute=self.compute, + base=self.base, + vary=self.vary, + constraints=self.constraints, + ) + results = study.run(progress=progress) + + # Extract objectives + obj_arrays = [] + for obj_name in self.objectives: + if obj_name not in results.metrics: + raise ValueError(f"Objective '{obj_name}' not found in results") + obj_arrays.append(results.get_metric(obj_name)) + + objectives_matrix = np.column_stack(obj_arrays) + + # Filter to feasible points + if results.constraints_passed is not None: + feasible_mask = results.constraints_passed + else: + feasible_mask = np.ones(len(objectives_matrix), dtype=bool) + + # Remove NaN values + valid_mask = feasible_mask & ~np.any(np.isnan(objectives_matrix), axis=1) + valid_indices = np.where(valid_mask)[0] + + if len(valid_indices) == 0: + return ParetoResults( + all_results=results, + pareto_indices=[], + pareto_inputs=[], + pareto_outputs=[], + pareto_objectives=np.array([]).reshape(0, len(self.objectives)), + objective_names=self.objectives, + maximize=self.maximize, + ) + + # Compute Pareto front on valid points only + valid_objectives = objectives_matrix[valid_mask] + is_pareto = compute_pareto_front(valid_objectives, self.maximize) + + # Map back to original indices + pareto_indices = valid_indices[is_pareto].tolist() + pareto_inputs = [results.inputs[i] for i in pareto_indices] + pareto_outputs = [results.outputs[i] for i in pareto_indices] + pareto_objectives = valid_objectives[is_pareto] + + return ParetoResults( + all_results=results, + pareto_indices=pareto_indices, + pareto_inputs=pareto_inputs, + pareto_outputs=pareto_outputs, + pareto_objectives=pareto_objectives, + objective_names=self.objectives, + maximize=self.maximize, + ) + + +@beartype +@dataclass +class ParetoResults: + """Results from multi-objective optimization. + + Contains the Pareto-optimal designs and full study results. + """ + + all_results: StudyResults + pareto_indices: list[int] + pareto_inputs: list[Any] + pareto_outputs: list[Any] + pareto_objectives: NDArray[np.float64] + objective_names: list[str] + maximize: list[bool] + + @property + def n_pareto(self) -> int: + """Number of Pareto-optimal points.""" + return len(self.pareto_indices) + + def get_best(self, objective: str) -> tuple[Any, Any, float]: + """Get the best design for a specific objective. + + Args: + objective: Name of objective to optimize + + Returns: + Tuple of (input, output, objective_value) + """ + if objective not in self.objective_names: + raise ValueError(f"Unknown objective: {objective}") + + obj_idx = self.objective_names.index(objective) + values = self.pareto_objectives[:, obj_idx] + + if self.maximize[obj_idx]: + best_idx = int(np.argmax(values)) + else: + best_idx = int(np.argmin(values)) + + return ( + self.pareto_inputs[best_idx], + self.pareto_outputs[best_idx], + float(values[best_idx]), + ) + + def get_compromise(self, weights: list[float] | None = None) -> tuple[Any, Any]: + """Get a compromise solution from the Pareto front. + + Uses weighted sum of normalized objectives. + + Args: + weights: Weights for each objective (default: equal weights) + + Returns: + Tuple of (input, output) for the compromise solution + """ + if weights is None: + weights = [1.0 / len(self.objectives) for _ in self.objective_names] + + if len(weights) != len(self.objective_names): + raise ValueError("weights must have same length as objectives") + + # Normalize objectives to [0, 1] + obj_norm = self.pareto_objectives.copy() + for i in range(obj_norm.shape[1]): + col = obj_norm[:, i] + col_min, col_max = np.min(col), np.max(col) + if col_max > col_min: + obj_norm[:, i] = (col - col_min) / (col_max - col_min) + else: + obj_norm[:, i] = 0.5 + + # Flip if minimizing + if not self.maximize[i]: + obj_norm[:, i] = 1 - obj_norm[:, i] + + # Weighted sum + scores = np.sum(obj_norm * np.array(weights), axis=1) + best_idx = int(np.argmax(scores)) + + return self.pareto_inputs[best_idx], self.pareto_outputs[best_idx] + + def summary(self) -> str: + """Generate text summary of Pareto results.""" + lines = [ + "Multi-Objective Optimization Results", + "=" * 50, + f"Total designs evaluated: {self.all_results.n_runs}", + f"Feasible designs: {self.all_results.n_feasible}", + f"Pareto-optimal designs: {self.n_pareto}", + "", + "Objectives:", + ] + + for i, (name, is_max) in enumerate(zip(self.objective_names, self.maximize, strict=True)): + direction = "maximize" if is_max else "minimize" + if self.n_pareto > 0: + values = self.pareto_objectives[:, i] + lines.append( + f" {name} ({direction}): " + f"range [{np.min(values):.4g}, {np.max(values):.4g}]" + ) + else: + lines.append(f" {name} ({direction}): no feasible points") + + return "\n".join(lines) + diff --git a/rocket/cycles/__init__.py b/rocket/cycles/__init__.py new file mode 100644 index 0000000..23c6f3c --- /dev/null +++ b/rocket/cycles/__init__.py @@ -0,0 +1,55 @@ +"""Engine cycle analysis module for Rocket. + +This module provides analysis tools for different rocket engine cycles: +- Pressure-fed (simplest) +- Gas generator (turbopump-fed with separate combustion) +- Expander (turbine driven by heated propellant) +- Staged combustion (preburner exhaust into main chamber) + +Each cycle type has different performance characteristics, complexity, +and feasibility constraints. + +Example: + >>> from rocket import EngineInputs, design_engine + >>> from rocket.cycles import GasGeneratorCycle, analyze_cycle + >>> + >>> inputs = EngineInputs.from_propellants("LOX", "CH4", ...) + >>> performance, geometry = design_engine(inputs) + >>> + >>> cycle = GasGeneratorCycle( + ... turbine_inlet_temp=kelvin(900), + ... pump_efficiency=0.70, + ... ) + >>> result = analyze_cycle(inputs, performance, geometry, cycle) + >>> print(f"Net Isp: {result.net_isp.value:.1f} s") +""" + +from rocket.cycles.base import ( + CycleConfiguration, + CyclePerformance, + CycleType, + analyze_cycle, + format_cycle_summary, +) +from rocket.cycles.gas_generator import GasGeneratorCycle +from rocket.cycles.pressure_fed import PressureFedCycle +from rocket.cycles.staged_combustion import ( + FullFlowStagedCombustion, + StagedCombustionCycle, +) + +__all__ = [ + # Base types + "CycleConfiguration", + "CyclePerformance", + "CycleType", + # Cycle configurations + "PressureFedCycle", + "GasGeneratorCycle", + "StagedCombustionCycle", + "FullFlowStagedCombustion", + # Analysis function + "analyze_cycle", + "format_cycle_summary", +] + diff --git a/rocket/cycles/base.py b/rocket/cycles/base.py new file mode 100644 index 0000000..63cff81 --- /dev/null +++ b/rocket/cycles/base.py @@ -0,0 +1,354 @@ +"""Base types for engine cycle analysis. + +This module defines the common interfaces and data structures used by +all engine cycle types. +""" + +import math +from dataclasses import dataclass +from enum import Enum, auto +from typing import Protocol, runtime_checkable + +from beartype import beartype + +from rocket.engine import EngineGeometry, EngineInputs, EnginePerformance +from rocket.units import Quantity, pascals, seconds, kg_per_second + + +class CycleType(Enum): + """Engine cycle type enumeration.""" + + PRESSURE_FED = auto() + GAS_GENERATOR = auto() + EXPANDER = auto() + STAGED_COMBUSTION = auto() + FULL_FLOW_STAGED = auto() + ELECTRIC_PUMP = auto() + + +@beartype +@dataclass(frozen=True, slots=True) +class CyclePerformance: + """Performance results from engine cycle analysis. + + Captures the system-level performance including losses from + turbine drive systems, pump power requirements, and pressure margins. + + Attributes: + net_isp: Effective Isp after accounting for cycle losses [s] + net_thrust: Delivered thrust after losses [N] + cycle_efficiency: Ratio of net Isp to ideal Isp [-] + pump_power_ox: Oxidizer pump power requirement [W] + pump_power_fuel: Fuel pump power requirement [W] + turbine_power: Total turbine power available [W] + turbine_mass_flow: Mass flow through turbine [kg/s] + tank_pressure_ox: Required oxidizer tank pressure [Pa] + tank_pressure_fuel: Required fuel tank pressure [Pa] + npsh_margin_ox: Net Positive Suction Head margin for ox pump [Pa] + npsh_margin_fuel: NPSH margin for fuel pump [Pa] + cycle_type: Type of cycle analyzed + feasible: Whether the cycle closes (power balance satisfied) + warnings: List of any warnings or marginal conditions + """ + + net_isp: Quantity + net_thrust: Quantity + cycle_efficiency: float + pump_power_ox: Quantity + pump_power_fuel: Quantity + turbine_power: Quantity + turbine_mass_flow: Quantity + tank_pressure_ox: Quantity + tank_pressure_fuel: Quantity + npsh_margin_ox: Quantity + npsh_margin_fuel: Quantity + cycle_type: CycleType + feasible: bool + warnings: list[str] + + +@runtime_checkable +class CycleConfiguration(Protocol): + """Protocol that all cycle configurations must implement. + + This protocol ensures that any cycle configuration can be used + with the generic analyze_cycle() function. + """ + + @property + def cycle_type(self) -> CycleType: + """Return the cycle type.""" + ... + + def analyze( + self, + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + ) -> CyclePerformance: + """Analyze the cycle and return performance results. + + Args: + inputs: Engine input parameters + performance: Computed engine performance + geometry: Computed engine geometry + + Returns: + CyclePerformance with system-level results + """ + ... + + +@beartype +def analyze_cycle( + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + cycle: CycleConfiguration, +) -> CyclePerformance: + """Analyze an engine cycle configuration. + + This is the main entry point for cycle analysis. It delegates to + the specific cycle implementation's analyze() method. + + Args: + inputs: Engine input parameters + performance: Computed engine performance (from design_engine) + geometry: Computed engine geometry (from design_engine) + cycle: Cycle configuration (PressureFedCycle, GasGeneratorCycle, etc.) + + Returns: + CyclePerformance with net performance and feasibility assessment + + Example: + >>> inputs = EngineInputs.from_propellants("LOX", "CH4", ...) + >>> performance, geometry = design_engine(inputs) + >>> cycle = GasGeneratorCycle(turbine_inlet_temp=kelvin(900), ...) + >>> result = analyze_cycle(inputs, performance, geometry, cycle) + """ + return cycle.analyze(inputs, performance, geometry) + + +# ============================================================================= +# Utility Functions +# ============================================================================= + + +@beartype +def pump_power( + mass_flow: Quantity, + pressure_rise: Quantity, + density: float, + efficiency: float, +) -> Quantity: + """Calculate pump power requirement. + + Uses the basic hydraulic power equation: + P = (mdot * delta_P) / (rho * eta) + + Args: + mass_flow: Mass flow rate through pump [kg/s] + pressure_rise: Pressure rise across pump [Pa] + density: Fluid density [kg/m³] + efficiency: Pump efficiency (0-1) + + Returns: + Pump power in Watts + """ + mdot = mass_flow.to("kg/s").value + dp = pressure_rise.to("Pa").value + + # Volumetric flow rate + Q = mdot / density # m³/s + + # Hydraulic power + P_hydraulic = Q * dp # W + + # Shaft power (accounting for efficiency) + P_shaft = P_hydraulic / efficiency + + return Quantity(P_shaft, "W", "power") + + +@beartype +def turbine_power( + mass_flow: Quantity, + inlet_temp: Quantity, + pressure_ratio: float, + gamma: float, + efficiency: float, + R: float = 287.0, # J/(kg·K), approximate for combustion products +) -> Quantity: + """Calculate turbine power output. + + Uses isentropic turbine equations with efficiency factor. + + Args: + mass_flow: Mass flow through turbine [kg/s] + inlet_temp: Turbine inlet temperature [K] + pressure_ratio: Inlet pressure / outlet pressure [-] + gamma: Ratio of specific heats for turbine gas [-] + efficiency: Turbine isentropic efficiency (0-1) + R: Specific gas constant [J/(kg·K)] + + Returns: + Turbine power output in Watts + """ + mdot = mass_flow.to("kg/s").value + T_in = inlet_temp.to("K").value + + # Isentropic temperature ratio + T_ratio_ideal = pressure_ratio ** ((gamma - 1) / gamma) + + # Actual temperature drop + delta_T_ideal = T_in * (1 - 1 / T_ratio_ideal) + delta_T_actual = delta_T_ideal * efficiency + + # Specific heat at constant pressure + cp = gamma * R / (gamma - 1) + + # Turbine power + P = mdot * cp * delta_T_actual + + return Quantity(P, "W", "power") + + +@beartype +def npsh_available( + tank_pressure: Quantity, + fluid_density: float, + vapor_pressure: Quantity, + inlet_height: float = 0.0, + line_losses: Quantity | None = None, +) -> Quantity: + """Calculate Net Positive Suction Head available at pump inlet. + + NPSH_a = (P_tank - P_vapor) / (rho * g) + h - losses + + Args: + tank_pressure: Tank ullage pressure [Pa] + fluid_density: Propellant density [kg/m³] + vapor_pressure: Propellant vapor pressure [Pa] + inlet_height: Height of fluid above pump inlet [m] + line_losses: Pressure losses in feed lines [Pa] + + Returns: + NPSH available in Pascals (pressure equivalent) + """ + g = 9.80665 # m/s² + + P_tank = tank_pressure.to("Pa").value + P_vapor = vapor_pressure.to("Pa").value + losses = line_losses.to("Pa").value if line_losses else 0.0 + + # NPSH in meters of head + npsh_m = (P_tank - P_vapor) / (fluid_density * g) + inlet_height - losses / (fluid_density * g) + + # Convert to pressure equivalent + npsh_pa = npsh_m * fluid_density * g + + return pascals(npsh_pa) + + +@beartype +def estimate_line_losses( + mass_flow: Quantity, + density: float, + pipe_diameter: float, + pipe_length: float, + num_elbows: int = 2, + num_valves: int = 2, +) -> Quantity: + """Estimate pressure losses in feed lines. + + Uses Darcy-Weisbach equation with loss coefficients for fittings. + + Args: + mass_flow: Mass flow rate [kg/s] + density: Fluid density [kg/m³] + pipe_diameter: Pipe inner diameter [m] + pipe_length: Total pipe length [m] + num_elbows: Number of 90° elbows + num_valves: Number of valves + + Returns: + Total pressure loss [Pa] + """ + mdot = mass_flow.to("kg/s").value + D = pipe_diameter + L = pipe_length + + # Calculate velocity + A = math.pi * (D / 2) ** 2 + V = mdot / (density * A) + + # Dynamic pressure + q = 0.5 * density * V ** 2 + + # Friction factor (assuming turbulent flow, smooth pipe) + # Using Blasius correlation as approximation + Re = density * V * D / 1e-3 # Approximate viscosity + if Re > 2300: + f = 0.316 / Re ** 0.25 + else: + f = 64 / Re + + # Pipe friction losses + dp_pipe = f * (L / D) * q + + # Fitting losses (K-factors) + K_elbow = 0.3 # 90° elbow + K_valve = 0.2 # Gate valve (open) + + dp_fittings = (num_elbows * K_elbow + num_valves * K_valve) * q + + return pascals(dp_pipe + dp_fittings) + + +@beartype +def format_cycle_summary(result: CyclePerformance) -> str: + """Format cycle analysis results as readable string. + + Args: + result: CyclePerformance from analyze_cycle() + + Returns: + Formatted multi-line string + """ + status = "✓ FEASIBLE" if result.feasible else "✗ INFEASIBLE" + + lines = [ + f"{'=' * 60}", + f"CYCLE ANALYSIS: {result.cycle_type.name}", + f"Status: {status}", + f"{'=' * 60}", + "", + "PERFORMANCE:", + f" Net Isp: {result.net_isp.value:.1f} s", + f" Net Thrust: {result.net_thrust.to('kN').value:.2f} kN", + f" Cycle Efficiency: {result.cycle_efficiency * 100:.1f}%", + "", + "POWER BALANCE:", + f" Turbine Power: {result.turbine_power.value / 1000:.1f} kW", + f" Pump Power (Ox): {result.pump_power_ox.value / 1000:.1f} kW", + f" Pump Power (Fuel): {result.pump_power_fuel.value / 1000:.1f} kW", + f" Turbine Flow: {result.turbine_mass_flow.value:.3f} kg/s", + "", + "TANK REQUIREMENTS:", + f" Ox Tank Pressure: {result.tank_pressure_ox.to('bar').value:.1f} bar", + f" Fuel Tank Pressure:{result.tank_pressure_fuel.to('bar').value:.1f} bar", + "", + "NPSH MARGINS:", + f" Ox NPSH Margin: {result.npsh_margin_ox.to('bar').value:.2f} bar", + f" Fuel NPSH Margin: {result.npsh_margin_fuel.to('bar').value:.2f} bar", + ] + + if result.warnings: + lines.extend(["", "WARNINGS:"]) + for warning in result.warnings: + lines.append(f" ⚠ {warning}") + + lines.append(f"{'=' * 60}") + + return "\n".join(lines) + diff --git a/rocket/cycles/gas_generator.py b/rocket/cycles/gas_generator.py new file mode 100644 index 0000000..9b7be8e --- /dev/null +++ b/rocket/cycles/gas_generator.py @@ -0,0 +1,347 @@ +"""Gas generator engine cycle analysis. + +The gas generator (GG) cycle is the most common turbopump-fed cycle. +A small portion of propellants is burned in a separate gas generator +to drive the turbine, then exhausted overboard (or through a secondary nozzle). + +Advantages: +- Proven, reliable technology +- Simpler than staged combustion +- Lower turbine temperatures +- Decoupled turbine from main chamber + +Disadvantages: +- GG exhaust is "wasted" (reduces effective Isp by 1-3%) +- Limited chamber pressure compared to staged combustion +- Requires separate GG and associated plumbing + +Examples: +- SpaceX Merlin (LOX/RP-1) +- Rocketdyne F-1 (LOX/RP-1) +- RS-68 (LOX/LH2) +- Vulcain (LOX/LH2) +""" + +import math +from dataclasses import dataclass + +from beartype import beartype + +from rocket.cycles.base import ( + CyclePerformance, + CycleType, + estimate_line_losses, + npsh_available, + pump_power, + turbine_power, +) +from rocket.engine import EngineGeometry, EngineInputs, EnginePerformance +from rocket.tanks import get_propellant_density +from rocket.units import Quantity, kelvin, kg_per_second, pascals, seconds + + +# Typical vapor pressures for common propellants [Pa] +VAPOR_PRESSURES: dict[str, float] = { + "LOX": 101325, + "LH2": 101325, + "CH4": 101325, + "RP1": 1000, + "Ethanol": 5900, +} + + +def _get_vapor_pressure(propellant: str) -> float: + """Get vapor pressure for a propellant.""" + name = propellant.upper().replace("-", "").replace(" ", "") + for key, value in VAPOR_PRESSURES.items(): + if key.upper() == name: + return value + return 1000.0 + + +@beartype +@dataclass(frozen=True, slots=True) +class GasGeneratorCycle: + """Configuration for a gas generator engine cycle. + + The gas generator produces hot gas to drive the turbines that power + the propellant pumps. The GG exhaust is typically dumped overboard. + + Attributes: + turbine_inlet_temp: Gas generator combustion temperature [K] + Typically 700-1000K to protect turbine blades + pump_efficiency_ox: Oxidizer pump efficiency (0.6-0.75 typical) + pump_efficiency_fuel: Fuel pump efficiency (0.6-0.75 typical) + turbine_efficiency: Turbine isentropic efficiency (0.5-0.7 typical) + turbine_pressure_ratio: Turbine inlet/outlet pressure ratio (2-6 typical) + gg_mixture_ratio: GG O/F ratio (fuel-rich, typically 0.3-0.5) + mechanical_efficiency: Mechanical losses in turbopump (0.95-0.98) + tank_pressure_ox: Oxidizer tank pressure [Pa] + tank_pressure_fuel: Fuel tank pressure [Pa] + """ + + turbine_inlet_temp: Quantity = None # type: ignore # Will validate in __post_init__ + pump_efficiency_ox: float = 0.70 + pump_efficiency_fuel: float = 0.70 + turbine_efficiency: float = 0.60 + turbine_pressure_ratio: float = 4.0 + gg_mixture_ratio: float = 0.4 # Fuel-rich + mechanical_efficiency: float = 0.97 + tank_pressure_ox: Quantity | None = None + tank_pressure_fuel: Quantity | None = None + + def __post_init__(self) -> None: + """Validate inputs.""" + if self.turbine_inlet_temp is None: + object.__setattr__(self, 'turbine_inlet_temp', kelvin(900)) + + @property + def cycle_type(self) -> CycleType: + return CycleType.GAS_GENERATOR + + def analyze( + self, + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + ) -> CyclePerformance: + """Analyze gas generator cycle and compute net performance. + + The key equations are: + 1. Pump power = mdot * delta_P / (rho * eta) + 2. Turbine power = mdot_gg * cp * delta_T * eta + 3. Power balance: P_turbine = P_pump_ox + P_pump_fuel + 4. Net Isp = (F_main - mdot_gg * ue_gg) / (mdot_total * g0) + + Args: + inputs: Engine input parameters + performance: Computed engine performance + geometry: Computed engine geometry + + Returns: + CyclePerformance with net performance and power balance + """ + warnings: list[str] = [] + + # Extract values + pc = inputs.chamber_pressure.to("Pa").value + mdot_total = performance.mdot.to("kg/s").value + mdot_ox = performance.mdot_ox.to("kg/s").value + mdot_fuel = performance.mdot_fuel.to("kg/s").value + isp = performance.isp.value + thrust = inputs.thrust.to("N").value + + # Get propellant properties + # Try to determine from engine name + ox_name = "LOX" + fuel_name = "RP1" + if inputs.name: + name_upper = inputs.name.upper() + if "CH4" in name_upper or "METHANE" in name_upper or "METHALOX" in name_upper: + fuel_name = "CH4" + elif "LH2" in name_upper or "HYDROGEN" in name_upper or "HYDROLOX" in name_upper: + fuel_name = "LH2" + + try: + rho_ox = get_propellant_density(ox_name) + except ValueError: + rho_ox = 1141.0 + warnings.append(f"Unknown oxidizer, assuming LOX density: {rho_ox} kg/m³") + + try: + rho_fuel = get_propellant_density(fuel_name) + except ValueError: + rho_fuel = 810.0 + warnings.append(f"Unknown fuel, assuming RP-1 density: {rho_fuel} kg/m³") + + # Determine tank pressures + if self.tank_pressure_ox is not None: + p_tank_ox = self.tank_pressure_ox.to("Pa").value + else: + # Typical tank pressure for turbopump-fed: 2-5 bar + p_tank_ox = 300000 # 3 bar default + + if self.tank_pressure_fuel is not None: + p_tank_fuel = self.tank_pressure_fuel.to("Pa").value + else: + p_tank_fuel = 250000 # 2.5 bar default + + # Calculate pump pressure rise + # Pump must raise from tank pressure to chamber pressure + injector drop + margins + injector_dp = pc * 0.20 # 20% pressure drop across injector + p_pump_outlet = pc + injector_dp + + dp_ox = p_pump_outlet - p_tank_ox + dp_fuel = p_pump_outlet - p_tank_fuel + + # Pump power requirements + P_pump_ox = pump_power( + mass_flow=kg_per_second(mdot_ox), + pressure_rise=pascals(dp_ox), + density=rho_ox, + efficiency=self.pump_efficiency_ox, + ).value + + P_pump_fuel = pump_power( + mass_flow=kg_per_second(mdot_fuel), + pressure_rise=pascals(dp_fuel), + density=rho_fuel, + efficiency=self.pump_efficiency_fuel, + ).value + + # Total pump power (accounting for mechanical losses) + P_pump_total = (P_pump_ox + P_pump_fuel) / self.mechanical_efficiency + + # Gas generator analysis + # Turbine power must equal pump power + P_turbine_required = P_pump_total + + # GG exhaust properties (fuel-rich combustion products) + # Approximate gamma and R for fuel-rich GG + gamma_gg = 1.25 # Lower gamma for fuel-rich + R_gg = 350.0 # J/(kg·K), approximate for fuel-rich products + T_gg = self.turbine_inlet_temp.to("K").value + + # Turbine specific work + # w = cp * T_in * eta * (1 - 1/PR^((gamma-1)/gamma)) + cp_gg = gamma_gg * R_gg / (gamma_gg - 1) + T_ratio = self.turbine_pressure_ratio ** ((gamma_gg - 1) / gamma_gg) + w_turbine = cp_gg * T_gg * self.turbine_efficiency * (1 - 1/T_ratio) + + # GG mass flow required + mdot_gg = P_turbine_required / w_turbine + + # Check GG flow is reasonable (typically 1-5% of total) + gg_fraction = mdot_gg / mdot_total + if gg_fraction > 0.10: + warnings.append( + f"GG flow is {gg_fraction*100:.1f}% of total - unusually high" + ) + + # GG propellant split + mdot_gg_ox = mdot_gg * self.gg_mixture_ratio / (1 + self.gg_mixture_ratio) + mdot_gg_fuel = mdot_gg / (1 + self.gg_mixture_ratio) + + # Net performance calculation + # The GG exhaust has much lower velocity than main chamber + # Approximate GG exhaust velocity + # For low MR (fuel-rich): Isp_gg ~ 200-250s + isp_gg = 220.0 # s, approximate for fuel-rich GG exhaust + g0 = 9.80665 + ue_gg = isp_gg * g0 + + # GG exhaust thrust (negative contribution to net thrust) + F_gg = mdot_gg * ue_gg + + # Net thrust and Isp + # Main chamber produces full thrust + # But we've "spent" mdot_gg propellant for low-Isp exhaust + F_main = thrust + net_thrust = F_main # GG exhaust typically dumps to atmosphere + + # Effective total mass flow (main + GG) + mdot_effective = mdot_total # GG flow comes from same tanks + + # Net Isp considering the GG "loss" + # Two ways to think about it: + # 1. All propellant flows through main chamber at full Isp + # 2. GG flow produces low-Isp exhaust + # Net: weighted average of Isp + net_isp = (F_main + F_gg * 0.3) / (mdot_effective * g0) # GG contributes ~30% of its thrust + + # Alternative: simple debit approach + # net_isp = isp * (1 - gg_fraction) + isp_gg * gg_fraction + net_isp_alt = isp * (1 - gg_fraction * 0.7) # ~70% loss on GG flow + + # Use the more conservative estimate + net_isp = min(net_isp, net_isp_alt) + + cycle_efficiency = net_isp / isp + + # NPSH analysis + p_vapor_ox = _get_vapor_pressure(ox_name) + p_vapor_fuel = _get_vapor_pressure(fuel_name) + + npsh_ox = npsh_available( + tank_pressure=pascals(p_tank_ox), + fluid_density=rho_ox, + vapor_pressure=pascals(p_vapor_ox), + ) + + npsh_fuel = npsh_available( + tank_pressure=pascals(p_tank_fuel), + fluid_density=rho_fuel, + vapor_pressure=pascals(p_vapor_fuel), + ) + + # Feasibility checks + feasible = True + + if npsh_ox.to("Pa").value < 50000: # < 0.5 bar + warnings.append("Low NPSH margin for oxidizer pump - risk of cavitation") + + if npsh_fuel.to("Pa").value < 50000: + warnings.append("Low NPSH margin for fuel pump - risk of cavitation") + + if T_gg > 1100: + warnings.append( + f"Turbine inlet temp {T_gg:.0f}K exceeds typical limit (~1000K)" + ) + + if gg_fraction > 0.05: + warnings.append( + f"GG fraction {gg_fraction*100:.1f}% is high - consider staged combustion" + ) + + return CyclePerformance( + net_isp=seconds(net_isp), + net_thrust=Quantity(net_thrust, "N", "force"), + cycle_efficiency=cycle_efficiency, + pump_power_ox=Quantity(P_pump_ox, "W", "power"), + pump_power_fuel=Quantity(P_pump_fuel, "W", "power"), + turbine_power=Quantity(P_turbine_required, "W", "power"), + turbine_mass_flow=kg_per_second(mdot_gg), + tank_pressure_ox=pascals(p_tank_ox), + tank_pressure_fuel=pascals(p_tank_fuel), + npsh_margin_ox=npsh_ox, + npsh_margin_fuel=npsh_fuel, + cycle_type=self.cycle_type, + feasible=feasible, + warnings=warnings, + ) + + +@beartype +def estimate_turbopump_mass( + pump_power: Quantity, + turbine_power: Quantity, + propellant_type: str = "LOX/RP1", +) -> Quantity: + """Estimate turbopump mass from power requirements. + + Uses historical correlations from existing engines. + + Args: + pump_power: Total pump power [W] + turbine_power: Turbine power [W] + propellant_type: Propellant combination for correlation selection + + Returns: + Estimated turbopump mass [kg] + """ + P = max(pump_power.value, turbine_power.value) + + # Historical correlation: mass ~ k * P^0.6 + # k varies by propellant type and technology level + if "LH2" in propellant_type.upper(): + k = 0.015 # LH2 pumps are larger due to low density + else: + k = 0.008 # LOX/RP-1, LOX/CH4 + + mass = k * P ** 0.6 + + # Minimum mass for small turbopumps + mass = max(mass, 5.0) + + return Quantity(mass, "kg", "mass") + diff --git a/rocket/cycles/pressure_fed.py b/rocket/cycles/pressure_fed.py new file mode 100644 index 0000000..d56c502 --- /dev/null +++ b/rocket/cycles/pressure_fed.py @@ -0,0 +1,288 @@ +"""Pressure-fed engine cycle analysis. + +Pressure-fed engines use high-pressure gas (typically helium) to push +propellants from tanks into the combustion chamber. They are the simplest +cycle type but require heavy tanks to contain the high pressures. + +Advantages: +- Simplicity and reliability (no turbopumps) +- Fewer failure modes +- Lower development cost + +Disadvantages: +- Heavy tanks (must withstand full chamber pressure + margins) +- Limited chamber pressure (~3 MPa practical limit) +- Lower performance (limited Isp due to pressure constraints) + +Typical applications: +- Upper stages +- Spacecraft thrusters +- Student/amateur rockets +""" + +from dataclasses import dataclass + +from beartype import beartype + +from rocket.cycles.base import ( + CyclePerformance, + CycleType, + estimate_line_losses, + npsh_available, +) +from rocket.engine import EngineGeometry, EngineInputs, EnginePerformance +from rocket.tanks import get_propellant_density +from rocket.units import Quantity, kg_per_second, pascals, seconds + + +# Typical vapor pressures for common propellants [Pa] +# At nominal storage temperatures +VAPOR_PRESSURES: dict[str, float] = { + "LOX": 101325, # ~1 atm at -183°C + "LH2": 101325, # ~1 atm at -253°C + "CH4": 101325, # ~1 atm at -161°C + "RP1": 1000, # Very low at 20°C + "Ethanol": 5900, # ~0.06 atm at 20°C + "N2O4": 96000, # ~0.95 atm at 20°C + "MMH": 4800, # Low at 20°C + "N2O": 5200000, # ~51 atm at 20°C (self-pressurizing) +} + + +def _get_vapor_pressure(propellant: str) -> float: + """Get vapor pressure for a propellant.""" + # Normalize name + name = propellant.upper().replace("-", "").replace(" ", "") + + for key, value in VAPOR_PRESSURES.items(): + if key.upper() == name: + return value + + # Default to low vapor pressure if unknown + return 1000.0 + + +@beartype +@dataclass(frozen=True, slots=True) +class PressureFedCycle: + """Configuration for a pressure-fed engine cycle. + + In a pressure-fed system, the tank pressure must exceed the chamber + pressure plus all line losses and injector pressure drop. + + Attributes: + injector_dp_fraction: Injector pressure drop as fraction of Pc (typically 0.15-0.25) + line_loss_fraction: Feed line losses as fraction of Pc (typically 0.05-0.10) + tank_pressure_margin: Safety margin on tank pressure (typically 1.1-1.2) + pressurant: Pressurant gas type (typically "helium" or "nitrogen") + ox_line_diameter: Oxidizer feed line diameter [m] + fuel_line_diameter: Fuel feed line diameter [m] + ox_line_length: Oxidizer feed line length [m] + fuel_line_length: Fuel feed line length [m] + """ + + injector_dp_fraction: float = 0.20 + line_loss_fraction: float = 0.05 + tank_pressure_margin: float = 1.15 + pressurant: str = "helium" + ox_line_diameter: float = 0.05 # m + fuel_line_diameter: float = 0.04 # m + ox_line_length: float = 2.0 # m + fuel_line_length: float = 2.0 # m + + @property + def cycle_type(self) -> CycleType: + return CycleType.PRESSURE_FED + + def analyze( + self, + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + ) -> CyclePerformance: + """Analyze pressure-fed cycle and determine tank pressures. + + Args: + inputs: Engine input parameters + performance: Computed engine performance + geometry: Computed engine geometry + + Returns: + CyclePerformance with pressure requirements and feasibility + """ + warnings: list[str] = [] + + # Extract values + pc = inputs.chamber_pressure.to("Pa").value + mdot_ox = performance.mdot_ox.to("kg/s").value + mdot_fuel = performance.mdot_fuel.to("kg/s").value + + # Get propellant densities + # Extract propellant names from engine name or use defaults + ox_name = "LOX" # Default + fuel_name = "RP1" # Default + if inputs.name: + name_upper = inputs.name.upper() + if "LOX" in name_upper or "LO2" in name_upper: + ox_name = "LOX" + if "CH4" in name_upper or "METHANE" in name_upper: + fuel_name = "CH4" + elif "RP1" in name_upper or "KEROSENE" in name_upper: + fuel_name = "RP1" + elif "ETHANOL" in name_upper: + fuel_name = "Ethanol" + elif "LH2" in name_upper or "HYDROGEN" in name_upper: + fuel_name = "LH2" + + try: + rho_ox = get_propellant_density(ox_name) + except ValueError: + rho_ox = 1141.0 # Default LOX + warnings.append(f"Unknown oxidizer, assuming LOX density: {rho_ox} kg/m³") + + try: + rho_fuel = get_propellant_density(fuel_name) + except ValueError: + rho_fuel = 810.0 # Default RP-1 + warnings.append(f"Unknown fuel, assuming RP-1 density: {rho_fuel} kg/m³") + + # Calculate pressure budget + # Tank pressure must overcome: chamber + injector drop + line losses + dp_injector = pc * self.injector_dp_fraction + dp_lines_ox = pc * self.line_loss_fraction + dp_lines_fuel = pc * self.line_loss_fraction + + # More detailed line loss estimate + dp_lines_ox_calc = estimate_line_losses( + mass_flow=kg_per_second(mdot_ox), + density=rho_ox, + pipe_diameter=self.ox_line_diameter, + pipe_length=self.ox_line_length, + ).to("Pa").value + + dp_lines_fuel_calc = estimate_line_losses( + mass_flow=kg_per_second(mdot_fuel), + density=rho_fuel, + pipe_diameter=self.fuel_line_diameter, + pipe_length=self.fuel_line_length, + ).to("Pa").value + + # Use maximum of estimated and calculated + dp_lines_ox = max(dp_lines_ox, dp_lines_ox_calc) + dp_lines_fuel = max(dp_lines_fuel, dp_lines_fuel_calc) + + # Required tank pressures + p_tank_ox = (pc + dp_injector + dp_lines_ox) * self.tank_pressure_margin + p_tank_fuel = (pc + dp_injector + dp_lines_fuel) * self.tank_pressure_margin + + # NPSH analysis + p_vapor_ox = _get_vapor_pressure(ox_name) + p_vapor_fuel = _get_vapor_pressure(fuel_name) + + npsh_ox = npsh_available( + tank_pressure=pascals(p_tank_ox), + fluid_density=rho_ox, + vapor_pressure=pascals(p_vapor_ox), + line_losses=pascals(dp_lines_ox), + ) + + npsh_fuel = npsh_available( + tank_pressure=pascals(p_tank_fuel), + fluid_density=rho_fuel, + vapor_pressure=pascals(p_vapor_fuel), + line_losses=pascals(dp_lines_fuel), + ) + + # Check feasibility + feasible = True + + # Pressure-fed practical limit is ~3-4 MPa + if pc > 4e6: + warnings.append( + f"Chamber pressure {pc/1e6:.1f} MPa exceeds typical pressure-fed limit (~3-4 MPa)" + ) + + # Tank pressure feasibility + if p_tank_ox > 6e6: + warnings.append( + f"Ox tank pressure {p_tank_ox/1e6:.1f} MPa is very high for pressure-fed" + ) + if p_tank_fuel > 6e6: + warnings.append( + f"Fuel tank pressure {p_tank_fuel/1e6:.1f} MPa is very high for pressure-fed" + ) + + # For pressure-fed, there are no turbopumps, so no pump power + # All "pumping" is done by the pressurized tanks + pump_power_ox = Quantity(0.0, "W", "power") + pump_power_fuel = Quantity(0.0, "W", "power") + turbine_power = Quantity(0.0, "W", "power") + turbine_flow = kg_per_second(0.0) + + # Net performance equals ideal performance (no turbine drive losses) + net_isp = performance.isp + net_thrust = inputs.thrust + cycle_efficiency = 1.0 # No cycle losses for pressure-fed + + return CyclePerformance( + net_isp=net_isp, + net_thrust=net_thrust, + cycle_efficiency=cycle_efficiency, + pump_power_ox=pump_power_ox, + pump_power_fuel=pump_power_fuel, + turbine_power=turbine_power, + turbine_mass_flow=turbine_flow, + tank_pressure_ox=pascals(p_tank_ox), + tank_pressure_fuel=pascals(p_tank_fuel), + npsh_margin_ox=npsh_ox, + npsh_margin_fuel=npsh_fuel, + cycle_type=self.cycle_type, + feasible=feasible, + warnings=warnings, + ) + + +@beartype +def estimate_pressurant_mass( + propellant_volume: Quantity, + tank_pressure: Quantity, + pressurant: str = "helium", + initial_temp: float = 300.0, # K + blowdown_ratio: float = 2.0, +) -> Quantity: + """Estimate pressurant gas mass required. + + Uses ideal gas law with blowdown consideration. + In blowdown mode, tank pressure drops as propellant is expelled. + + Args: + propellant_volume: Volume of propellant to expel [m³] + tank_pressure: Initial tank pressure [Pa] + pressurant: Gas type ("helium" or "nitrogen") + initial_temp: Pressurant initial temperature [K] + blowdown_ratio: Initial/final pressure ratio for blowdown + + Returns: + Required pressurant mass [kg] + """ + # Gas constants + R_helium = 2077.0 # J/(kg·K) + R_nitrogen = 296.8 # J/(kg·K) + + R = R_helium if pressurant.lower() == "helium" else R_nitrogen + + V = propellant_volume.to("m^3").value + P = tank_pressure.to("Pa").value + + # For pressure-regulated system: m = P * V / (R * T) + # For blowdown: need to account for pressure decay + # Simplified: assume average pressure + P_avg = P / (1 + 1/blowdown_ratio) * 2 + + mass = P_avg * V / (R * initial_temp) + + # Add margin for residuals and cooling + mass *= 1.2 + + return Quantity(mass, "kg", "mass") + diff --git a/rocket/cycles/staged_combustion.py b/rocket/cycles/staged_combustion.py new file mode 100644 index 0000000..21acb94 --- /dev/null +++ b/rocket/cycles/staged_combustion.py @@ -0,0 +1,488 @@ +"""Staged combustion engine cycle analysis. + +Staged combustion is the highest-performance liquid engine cycle. Unlike +gas generators where turbine exhaust is dumped overboard, staged combustion +routes all turbine exhaust into the main combustion chamber. + +Variants: +- Oxidizer-rich staged combustion (ORSC): Preburner runs oxidizer-rich + Example: RD-180, RD-191, NK-33 +- Fuel-rich staged combustion (FRSC): Preburner runs fuel-rich + Example: RS-25 (SSME), BE-4 +- Full-flow staged combustion (FFSC): Both ox-rich AND fuel-rich preburners + Example: SpaceX Raptor + +Advantages: +- Highest Isp (all propellant goes through main chamber) +- High chamber pressure capability +- High thrust-to-weight ratio + +Disadvantages: +- Most complex cycle +- Expensive development +- Challenging turbine environments (especially ORSC) + +References: + - Sutton & Biblarz, Chapter 6 + - Humble, Henry & Larson, "Space Propulsion Analysis and Design" +""" + +import math +from dataclasses import dataclass + +from beartype import beartype + +from rocket.cycles.base import ( + CyclePerformance, + CycleType, + npsh_available, + pump_power, +) +from rocket.engine import EngineGeometry, EngineInputs, EnginePerformance +from rocket.tanks import get_propellant_density +from rocket.units import Quantity, kelvin, kg_per_second, pascals, seconds + + +# Typical vapor pressures for common propellants [Pa] +VAPOR_PRESSURES: dict[str, float] = { + "LOX": 101325, + "LH2": 101325, + "CH4": 101325, + "RP1": 1000, +} + + +def _get_vapor_pressure(propellant: str) -> float: + """Get vapor pressure for a propellant.""" + name = propellant.upper().replace("-", "").replace(" ", "") + for key, value in VAPOR_PRESSURES.items(): + if key.upper() == name: + return value + return 1000.0 + + +@beartype +@dataclass(frozen=True, slots=True) +class StagedCombustionCycle: + """Configuration for a staged combustion engine cycle. + + In staged combustion, the preburner exhaust (which drove the turbine) + is routed into the main combustion chamber, so no propellant is wasted. + + Attributes: + preburner_temp: Preburner combustion temperature [K] + Typically 700-900K for fuel-rich, 500-700K for ox-rich + pump_efficiency_ox: Oxidizer pump efficiency (0.7-0.8 typical) + pump_efficiency_fuel: Fuel pump efficiency (0.7-0.8 typical) + turbine_efficiency: Turbine isentropic efficiency (0.6-0.75 typical) + turbine_pressure_ratio: Turbine pressure ratio (1.5-3.0 typical) + preburner_mixture_ratio: Preburner O/F ratio + Fuel-rich: 0.3-0.6 (for SSME-type) + Ox-rich: 50-100 (for RD-180-type) + oxidizer_rich: If True, uses ox-rich preburner (ORSC) + mechanical_efficiency: Mechanical losses (0.95-0.98) + tank_pressure_ox: Oxidizer tank pressure [Pa] + tank_pressure_fuel: Fuel tank pressure [Pa] + """ + + preburner_temp: Quantity | None = None + pump_efficiency_ox: float = 0.75 + pump_efficiency_fuel: float = 0.75 + turbine_efficiency: float = 0.70 + turbine_pressure_ratio: float = 2.0 + preburner_mixture_ratio: float = 0.5 # Fuel-rich default + oxidizer_rich: bool = False + mechanical_efficiency: float = 0.97 + tank_pressure_ox: Quantity | None = None + tank_pressure_fuel: Quantity | None = None + + @property + def cycle_type(self) -> CycleType: + return CycleType.STAGED_COMBUSTION + + def analyze( + self, + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + ) -> CyclePerformance: + """Analyze staged combustion cycle. + + The key difference from gas generator is that turbine exhaust + goes to the main chamber, so there's no Isp penalty from + dumping low-energy gases. + + The power balance is more complex because the preburner + operates at a pressure higher than the main chamber. + """ + warnings: list[str] = [] + + # Extract values + pc = inputs.chamber_pressure.to("Pa").value + mdot_total = performance.mdot.to("kg/s").value + mdot_ox = performance.mdot_ox.to("kg/s").value + mdot_fuel = performance.mdot_fuel.to("kg/s").value + isp = performance.isp.value + + # Set default preburner temperature + if self.preburner_temp is not None: + T_pb = self.preburner_temp.to("K").value + else: + # Default based on cycle type + T_pb = 600.0 if self.oxidizer_rich else 800.0 + + # Get propellant properties + ox_name = "LOX" + fuel_name = "RP1" + if inputs.name: + name_upper = inputs.name.upper() + if "CH4" in name_upper or "METHANE" in name_upper: + fuel_name = "CH4" + elif "LH2" in name_upper or "HYDROGEN" in name_upper: + fuel_name = "LH2" + + try: + rho_ox = get_propellant_density(ox_name) + except ValueError: + rho_ox = 1141.0 + warnings.append(f"Unknown oxidizer, assuming LOX density: {rho_ox} kg/m³") + + try: + rho_fuel = get_propellant_density(fuel_name) + except ValueError: + rho_fuel = 810.0 + warnings.append(f"Unknown fuel, assuming RP-1 density: {rho_fuel} kg/m³") + + # Tank pressures + if self.tank_pressure_ox is not None: + p_tank_ox = self.tank_pressure_ox.to("Pa").value + else: + p_tank_ox = 400000 # 4 bar typical for staged combustion + + if self.tank_pressure_fuel is not None: + p_tank_fuel = self.tank_pressure_fuel.to("Pa").value + else: + p_tank_fuel = 350000 # 3.5 bar + + # Preburner pressure must be higher than chamber pressure + # Turbine pressure drop + injector losses + p_preburner = pc * 1.3 # 30% higher than chamber + + # Pump pressure rises + # Pumps must deliver to preburner pressure (higher than PC) + injector_dp = pc * 0.20 + p_pump_outlet = p_preburner + injector_dp + + dp_ox = p_pump_outlet - p_tank_ox + dp_fuel = p_pump_outlet - p_tank_fuel + + # Pump power requirements + P_pump_ox = pump_power( + mass_flow=kg_per_second(mdot_ox), + pressure_rise=pascals(dp_ox), + density=rho_ox, + efficiency=self.pump_efficiency_ox, + ).value + + P_pump_fuel = pump_power( + mass_flow=kg_per_second(mdot_fuel), + pressure_rise=pascals(dp_fuel), + density=rho_fuel, + efficiency=self.pump_efficiency_fuel, + ).value + + # Total pump power + P_pump_total = (P_pump_ox + P_pump_fuel) / self.mechanical_efficiency + + # Preburner/turbine analysis + # In staged combustion, ALL propellant eventually goes through main chamber + # Preburner flow drives turbine, then goes to main chamber + + if self.oxidizer_rich: + # Ox-rich: most of oxidizer through preburner with small fuel + # mdot_pb = mdot_ox + mdot_pb_fuel + # Preburner MR is very high (50-100), so mdot_pb_fuel is small + mdot_pb_fuel = mdot_ox / self.preburner_mixture_ratio + mdot_pb = mdot_ox + mdot_pb_fuel + # Remaining fuel goes directly to main chamber + mdot_direct_fuel = mdot_fuel - mdot_pb_fuel + + if mdot_direct_fuel < 0: + warnings.append("Preburner consumes more fuel than available - infeasible") + mdot_direct_fuel = 0 + + # Preburner exhaust is mostly oxygen with some combustion products + gamma_pb = 1.30 # Higher gamma for ox-rich + R_pb = 280.0 # J/(kg·K) + + else: + # Fuel-rich: most of fuel through preburner with small oxidizer + mdot_pb_ox = mdot_fuel * self.preburner_mixture_ratio + mdot_pb = mdot_fuel + mdot_pb_ox + # Remaining oxidizer goes directly to main chamber + mdot_direct_ox = mdot_ox - mdot_pb_ox + + if mdot_direct_ox < 0: + warnings.append("Preburner consumes more oxidizer than available - infeasible") + mdot_direct_ox = 0 + + # Preburner exhaust is fuel-rich combustion products + gamma_pb = 1.20 # Lower gamma for fuel-rich + R_pb = 400.0 # J/(kg·K), higher for lighter products + + # Turbine power available + cp_pb = gamma_pb * R_pb / (gamma_pb - 1) + T_ratio = self.turbine_pressure_ratio ** ((gamma_pb - 1) / gamma_pb) + w_turbine = cp_pb * T_pb * self.turbine_efficiency * (1 - 1/T_ratio) + + P_turbine_available = mdot_pb * w_turbine + + # Power balance check + power_margin = P_turbine_available / P_pump_total if P_pump_total > 0 else float('inf') + + if power_margin < 1.0: + warnings.append( + f"Power balance not achieved: turbine provides {P_turbine_available/1e6:.1f} MW, " + f"pumps need {P_pump_total/1e6:.1f} MW" + ) + + # Net performance + # In staged combustion, ALL propellant goes through main chamber + # at (nearly) full Isp, so cycle efficiency is very high + # Small losses from: + # 1. Preburner inefficiency + # 2. Slightly different combustion from preburned products + + # Estimate efficiency loss (typically 1-3%) + efficiency_loss = 0.02 # 2% loss typical for staged combustion + net_isp = isp * (1 - efficiency_loss) + cycle_efficiency = 1 - efficiency_loss + + # NPSH analysis + p_vapor_ox = _get_vapor_pressure(ox_name) + p_vapor_fuel = _get_vapor_pressure(fuel_name) + + npsh_ox = npsh_available( + tank_pressure=pascals(p_tank_ox), + fluid_density=rho_ox, + vapor_pressure=pascals(p_vapor_ox), + ) + + npsh_fuel = npsh_available( + tank_pressure=pascals(p_tank_fuel), + fluid_density=rho_fuel, + vapor_pressure=pascals(p_vapor_fuel), + ) + + # Feasibility assessment + feasible = power_margin >= 0.95 # Allow small margin + + if power_margin < 1.0: + feasible = False + + if self.oxidizer_rich and T_pb > 700: + warnings.append( + f"Ox-rich preburner at {T_pb:.0f}K - requires specialized turbine materials" + ) + + if not self.oxidizer_rich and T_pb > 1000: + warnings.append( + f"Fuel-rich preburner temp {T_pb:.0f}K is high" + ) + + if pc > 25e6: + warnings.append( + f"Chamber pressure {pc/1e6:.0f} MPa is very high - " + "typical staged combustion limit ~30 MPa" + ) + + return CyclePerformance( + net_isp=seconds(net_isp), + net_thrust=inputs.thrust, # Staged combustion delivers full thrust + cycle_efficiency=cycle_efficiency, + pump_power_ox=Quantity(P_pump_ox, "W", "power"), + pump_power_fuel=Quantity(P_pump_fuel, "W", "power"), + turbine_power=Quantity(P_turbine_available, "W", "power"), + turbine_mass_flow=kg_per_second(mdot_pb), + tank_pressure_ox=pascals(p_tank_ox), + tank_pressure_fuel=pascals(p_tank_fuel), + npsh_margin_ox=npsh_ox, + npsh_margin_fuel=npsh_fuel, + cycle_type=self.cycle_type, + feasible=feasible, + warnings=warnings, + ) + + +@beartype +@dataclass(frozen=True, slots=True) +class FullFlowStagedCombustion: + """Configuration for full-flow staged combustion (FFSC). + + FFSC uses TWO preburners: + - Fuel-rich preburner: drives fuel turbopump + - Ox-rich preburner: drives oxidizer turbopump + + This provides the highest possible performance and allows + independent control of each turbopump. + + Example: SpaceX Raptor + + Attributes: + fuel_preburner_temp: Fuel-rich preburner temperature [K] + ox_preburner_temp: Ox-rich preburner temperature [K] + pump_efficiency: Pump efficiency for both pumps + turbine_efficiency: Turbine efficiency for both turbines + fuel_turbine_pr: Fuel turbine pressure ratio + ox_turbine_pr: Ox turbine pressure ratio + """ + + fuel_preburner_temp: Quantity | None = None + ox_preburner_temp: Quantity | None = None + pump_efficiency: float = 0.77 + turbine_efficiency: float = 0.72 + fuel_turbine_pr: float = 1.8 + ox_turbine_pr: float = 1.5 + tank_pressure_ox: Quantity | None = None + tank_pressure_fuel: Quantity | None = None + + @property + def cycle_type(self) -> CycleType: + return CycleType.FULL_FLOW_STAGED + + def analyze( + self, + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + ) -> CyclePerformance: + """Analyze full-flow staged combustion cycle.""" + warnings: list[str] = [] + + # Extract values + pc = inputs.chamber_pressure.to("Pa").value + mdot_ox = performance.mdot_ox.to("kg/s").value + mdot_fuel = performance.mdot_fuel.to("kg/s").value + isp = performance.isp.value + + # Default preburner temperatures + T_fuel_pb = self.fuel_preburner_temp.to("K").value if self.fuel_preburner_temp else 800.0 + T_ox_pb = self.ox_preburner_temp.to("K").value if self.ox_preburner_temp else 600.0 + + # Get propellant properties + try: + rho_ox = get_propellant_density("LOX") + except ValueError: + rho_ox = 1141.0 + + try: + rho_fuel = get_propellant_density("CH4") # FFSC typically uses methane + except ValueError: + rho_fuel = 422.0 + + # Tank pressures + p_tank_ox = self.tank_pressure_ox.to("Pa").value if self.tank_pressure_ox else 500000 + p_tank_fuel = self.tank_pressure_fuel.to("Pa").value if self.tank_pressure_fuel else 450000 + + # Preburner pressures (higher than chamber) + p_preburner = pc * 1.25 + + # Pump requirements + dp_ox = p_preburner - p_tank_ox + pc * 0.2 + dp_fuel = p_preburner - p_tank_fuel + pc * 0.2 + + P_pump_ox = pump_power( + mass_flow=kg_per_second(mdot_ox), + pressure_rise=pascals(dp_ox), + density=rho_ox, + efficiency=self.pump_efficiency, + ).value + + P_pump_fuel = pump_power( + mass_flow=kg_per_second(mdot_fuel), + pressure_rise=pascals(dp_fuel), + density=rho_fuel, + efficiency=self.pump_efficiency, + ).value + + # In FFSC, all oxidizer goes through ox-rich preburner + # All fuel goes through fuel-rich preburner + # Each preburner drives its respective turbopump + + # Fuel-rich preburner (drives fuel pump) + # Small amount of ox mixed with all fuel + gamma_fuel_pb = 1.18 + R_fuel_pb = 450.0 + cp_fuel_pb = gamma_fuel_pb * R_fuel_pb / (gamma_fuel_pb - 1) + T_ratio_fuel = self.fuel_turbine_pr ** ((gamma_fuel_pb - 1) / gamma_fuel_pb) + w_fuel_turbine = cp_fuel_pb * T_fuel_pb * self.turbine_efficiency * (1 - 1/T_ratio_fuel) + + # Ox-rich preburner (drives ox pump) + gamma_ox_pb = 1.30 + R_ox_pb = 280.0 + cp_ox_pb = gamma_ox_pb * R_ox_pb / (gamma_ox_pb - 1) + T_ratio_ox = self.ox_turbine_pr ** ((gamma_ox_pb - 1) / gamma_ox_pb) + w_ox_turbine = cp_ox_pb * T_ox_pb * self.turbine_efficiency * (1 - 1/T_ratio_ox) + + # Power available from each turbine + # In FFSC, all propellant flows through preburners + P_fuel_turbine = mdot_fuel * w_fuel_turbine + P_ox_turbine = mdot_ox * w_ox_turbine + + # Check power balance + fuel_margin = P_fuel_turbine / P_pump_fuel if P_pump_fuel > 0 else float('inf') + ox_margin = P_ox_turbine / P_pump_ox if P_pump_ox > 0 else float('inf') + + feasible = True + if fuel_margin < 0.95: + warnings.append(f"Fuel turbopump power margin low: {fuel_margin:.2f}") + feasible = False + if ox_margin < 0.95: + warnings.append(f"Ox turbopump power margin low: {ox_margin:.2f}") + feasible = False + + # FFSC has minimal Isp loss (all propellant to main chamber) + efficiency_loss = 0.01 # ~1% loss + net_isp = isp * (1 - efficiency_loss) + cycle_efficiency = 1 - efficiency_loss + + # NPSH + p_vapor_ox = _get_vapor_pressure("LOX") + p_vapor_fuel = _get_vapor_pressure("CH4") + + npsh_ox = npsh_available( + tank_pressure=pascals(p_tank_ox), + fluid_density=rho_ox, + vapor_pressure=pascals(p_vapor_ox), + ) + + npsh_fuel = npsh_available( + tank_pressure=pascals(p_tank_fuel), + fluid_density=rho_fuel, + vapor_pressure=pascals(p_vapor_fuel), + ) + + # Warnings + if pc > 30e6: + warnings.append(f"Chamber pressure {pc/1e6:.0f} MPa is at FFSC limit") + + if T_ox_pb > 700: + warnings.append("Ox-rich preburner temp requires advanced turbine materials") + + return CyclePerformance( + net_isp=seconds(net_isp), + net_thrust=inputs.thrust, + cycle_efficiency=cycle_efficiency, + pump_power_ox=Quantity(P_pump_ox, "W", "power"), + pump_power_fuel=Quantity(P_pump_fuel, "W", "power"), + turbine_power=Quantity(P_fuel_turbine + P_ox_turbine, "W", "power"), + turbine_mass_flow=kg_per_second(mdot_ox + mdot_fuel), # All flow through preburners + tank_pressure_ox=pascals(p_tank_ox), + tank_pressure_fuel=pascals(p_tank_fuel), + npsh_margin_ox=npsh_ox, + npsh_margin_fuel=npsh_fuel, + cycle_type=self.cycle_type, + feasible=feasible, + warnings=warnings, + ) + diff --git a/rocket/engine.py b/rocket/engine.py new file mode 100644 index 0000000..dc944c8 --- /dev/null +++ b/rocket/engine.py @@ -0,0 +1,632 @@ +"""Engine module for OpenRocketEngine. + +This module provides the core data structures and computation functions for +rocket engine design and analysis. + +Design principles: +- Immutable dataclasses for all engine parameters +- Pure functions for all computations (no side effects) +- Explicit data flow: inputs -> performance -> geometry +- Type safety with beartype runtime checking +""" + +import math +from dataclasses import dataclass + +from beartype import beartype + +from rocket.isentropic import ( + G0_SI, + area_ratio_from_mach, + bell_nozzle_length, + chamber_volume, + characteristic_velocity, + cylindrical_chamber_length, + diameter_from_area, + mach_from_pressure_ratio, + mass_flow_rate, + specific_gas_constant, + specific_impulse, + throat_area, + thrust_coefficient, + thrust_coefficient_vacuum, +) +from rocket.units import ( + Quantity, + kelvin, + kg_per_second, + meters, + meters_per_second, + pascals, + seconds, + square_meters, +) + +# ============================================================================= +# Input Data Structures +# ============================================================================= + + +@beartype +@dataclass(frozen=True, slots=True) +class EngineInputs: + """All inputs required to define a rocket engine. + + This immutable dataclass contains all the parameters needed to compute + engine performance and geometry. All physical quantities use the Quantity + class for type safety and unit handling. + + Attributes: + thrust: Sea-level thrust [force] + chamber_pressure: Chamber (stagnation) pressure [pressure] + chamber_temp: Chamber (stagnation) temperature [temperature] + exit_pressure: Nozzle exit pressure [pressure] + ambient_pressure: Ambient pressure for performance calculation [pressure] + molecular_weight: Molecular weight of exhaust gases [kg/kmol] + gamma: Ratio of specific heats (Cp/Cv) [-] + lstar: Characteristic chamber length [length] + mixture_ratio: Oxidizer to fuel mass ratio [-] + contraction_ratio: Chamber area / throat area [-] + contraction_angle: Chamber convergence half-angle [degrees] + bell_fraction: Bell nozzle length as fraction of 15° cone [-] + name: Optional engine name for identification + """ + + thrust: Quantity # Sea-level thrust + chamber_pressure: Quantity # pc + chamber_temp: Quantity # Tc + exit_pressure: Quantity # pe + molecular_weight: float # kg/kmol + gamma: float # Cp/Cv, dimensionless + lstar: Quantity # L*, characteristic length + mixture_ratio: float # O/F ratio, dimensionless + ambient_pressure: Quantity | None = None # pa, defaults to pe + contraction_ratio: float = 4.0 # Ac/At, dimensionless + contraction_angle: float = 45.0 # degrees + bell_fraction: float = 0.8 # fraction of 15° cone length + name: str | None = None + + def __post_init__(self) -> None: + """Validate inputs after initialization.""" + # Validate dimensions + if self.thrust.dimension != "force": + raise ValueError(f"thrust must be force, got {self.thrust.dimension}") + if self.chamber_pressure.dimension != "pressure": + raise ValueError( + f"chamber_pressure must be pressure, got {self.chamber_pressure.dimension}" + ) + if self.chamber_temp.dimension != "temperature": + raise ValueError( + f"chamber_temp must be temperature, got {self.chamber_temp.dimension}" + ) + if self.exit_pressure.dimension != "pressure": + raise ValueError( + f"exit_pressure must be pressure, got {self.exit_pressure.dimension}" + ) + if self.lstar.dimension != "length": + raise ValueError(f"lstar must be length, got {self.lstar.dimension}") + if self.ambient_pressure is not None and self.ambient_pressure.dimension != "pressure": + raise ValueError( + f"ambient_pressure must be pressure, got {self.ambient_pressure.dimension}" + ) + + # Validate physical constraints + if self.gamma <= 1.0: + raise ValueError(f"gamma must be > 1, got {self.gamma}") + if self.molecular_weight <= 0: + raise ValueError(f"molecular_weight must be > 0, got {self.molecular_weight}") + if self.mixture_ratio <= 0: + raise ValueError(f"mixture_ratio must be > 0, got {self.mixture_ratio}") + if self.contraction_ratio < 1.0: + raise ValueError(f"contraction_ratio must be >= 1, got {self.contraction_ratio}") + if not (0 < self.contraction_angle < 90): + raise ValueError( + f"contraction_angle must be between 0 and 90 degrees, got {self.contraction_angle}" + ) + if not (0 < self.bell_fraction <= 1.0): + raise ValueError(f"bell_fraction must be between 0 and 1, got {self.bell_fraction}") + + @property + def effective_ambient_pressure(self) -> Quantity: + """Return ambient pressure, defaulting to exit pressure if not specified.""" + if self.ambient_pressure is not None: + return self.ambient_pressure + return self.exit_pressure + + @classmethod + def from_propellants( + cls, + oxidizer: str, + fuel: str, + thrust: Quantity, + chamber_pressure: Quantity, + mixture_ratio: float | None = None, + exit_pressure: Quantity | None = None, + lstar: Quantity | None = None, + ambient_pressure: Quantity | None = None, + contraction_ratio: float = 4.0, + contraction_angle: float = 45.0, + bell_fraction: float = 0.8, + name: str | None = None, + ) -> "EngineInputs": + """Create EngineInputs from propellant names, automatically computing thermochemistry. + + This factory method uses RocketCEA (NASA CEA) to determine the combustion + properties (chamber temperature, molecular weight, and gamma) from the + specified propellant combination. + + Args: + oxidizer: Oxidizer name (e.g., "LOX", "N2O4", "N2O", "H2O2") + fuel: Fuel name (e.g., "RP1", "LH2", "CH4", "Ethanol", "MMH") + thrust: Sea-level thrust + chamber_pressure: Chamber pressure + mixture_ratio: O/F mass ratio. If None, uses optimal ratio for max Isp. + exit_pressure: Nozzle exit pressure. Defaults to 1 atm (101325 Pa). + lstar: Characteristic length. Defaults to 1.0 m (typical for biprop). + ambient_pressure: Ambient pressure for performance calc. Defaults to exit_pressure. + contraction_ratio: Chamber/throat area ratio. Default 4.0. + contraction_angle: Convergent section half-angle [deg]. Default 45. + bell_fraction: Bell length as fraction of 15° cone. Default 0.8. + name: Optional engine name. + + Returns: + EngineInputs with thermochemistry computed from propellant combination. + + Example: + >>> inputs = EngineInputs.from_propellants( + ... oxidizer="LOX", + ... fuel="RP1", + ... thrust=kilonewtons(100), + ... chamber_pressure=megapascals(7), + ... mixture_ratio=2.7, + ... ) + >>> print(f"Tc = {inputs.chamber_temp}") + """ + from rocket.propellants import ( + get_combustion_properties, + get_optimal_mixture_ratio, + ) + + # Default exit pressure to 1 atm + if exit_pressure is None: + exit_pressure = pascals(101325) + + # Default L* to 1.0 m + if lstar is None: + lstar = meters(1.0) + + # Get chamber pressure in Pa for CEA + pc_pa = chamber_pressure.to("Pa").value + + # Find optimal mixture ratio if not specified + if mixture_ratio is None: + mixture_ratio, _ = get_optimal_mixture_ratio( + oxidizer=oxidizer, + fuel=fuel, + chamber_pressure_pa=pc_pa, + metric="isp", + ) + + # Get combustion properties from CEA + props = get_combustion_properties( + oxidizer=oxidizer, + fuel=fuel, + mixture_ratio=mixture_ratio, + chamber_pressure_pa=pc_pa, + ) + + # Generate name if not provided + if name is None: + name = f"{oxidizer}/{fuel} Engine" + + return cls( + thrust=thrust, + chamber_pressure=chamber_pressure, + chamber_temp=kelvin(props.chamber_temp_k), + exit_pressure=exit_pressure, + molecular_weight=props.molecular_weight, + gamma=props.gamma, + lstar=lstar, + mixture_ratio=mixture_ratio, + ambient_pressure=ambient_pressure, + contraction_ratio=contraction_ratio, + contraction_angle=contraction_angle, + bell_fraction=bell_fraction, + name=name, + ) + + +# ============================================================================= +# Output Data Structures +# ============================================================================= + + +@beartype +@dataclass(frozen=True, slots=True) +class EnginePerformance: + """Computed performance metrics for a rocket engine. + + All values are computed from EngineInputs using isentropic flow equations. + + Attributes: + isp: Specific impulse at sea level [s] + isp_vac: Specific impulse in vacuum [s] + cstar: Characteristic velocity [m/s] + exhaust_velocity: Nozzle exit velocity [m/s] + thrust_coeff: Thrust coefficient at sea level [-] + thrust_coeff_vac: Vacuum thrust coefficient [-] + mdot: Total mass flow rate [kg/s] + mdot_ox: Oxidizer mass flow rate [kg/s] + mdot_fuel: Fuel mass flow rate [kg/s] + expansion_ratio: Nozzle expansion ratio Ae/At [-] + exit_mach: Mach number at nozzle exit [-] + """ + + isp: Quantity # seconds + isp_vac: Quantity # seconds + cstar: Quantity # m/s + exhaust_velocity: Quantity # m/s + thrust_coeff: float # dimensionless + thrust_coeff_vac: float # dimensionless + mdot: Quantity # kg/s + mdot_ox: Quantity # kg/s + mdot_fuel: Quantity # kg/s + expansion_ratio: float # dimensionless + exit_mach: float # dimensionless + + +@beartype +@dataclass(frozen=True, slots=True) +class EngineGeometry: + """Computed geometry for a rocket engine. + + All dimensions are computed from EngineInputs and EnginePerformance. + + Attributes: + throat_area: Throat cross-sectional area [m^2] + throat_diameter: Throat diameter [m] + exit_area: Nozzle exit area [m^2] + exit_diameter: Nozzle exit diameter [m] + chamber_area: Chamber cross-sectional area [m^2] + chamber_diameter: Chamber diameter [m] + chamber_volume: Total chamber volume [m^3] + chamber_length: Cylindrical chamber length [m] + nozzle_length: Nozzle length (from throat to exit) [m] + expansion_ratio: Ae/At [-] + contraction_ratio: Ac/At [-] + """ + + throat_area: Quantity + throat_diameter: Quantity + exit_area: Quantity + exit_diameter: Quantity + chamber_area: Quantity + chamber_diameter: Quantity + chamber_volume: Quantity + chamber_length: Quantity + nozzle_length: Quantity + expansion_ratio: float + contraction_ratio: float + + +# ============================================================================= +# Computation Functions +# ============================================================================= + + +@beartype +def compute_performance(inputs: EngineInputs) -> EnginePerformance: + """Compute engine performance from inputs. + + This is a pure function that takes engine inputs and returns computed + performance metrics using isentropic flow equations. + + Args: + inputs: Engine input parameters + + Returns: + Computed performance metrics + """ + # Extract values in SI units + thrust_N = inputs.thrust.to("N").value + pc_Pa = inputs.chamber_pressure.to("Pa").value + Tc_K = inputs.chamber_temp.to("K").value + pe_Pa = inputs.exit_pressure.to("Pa").value + pa_Pa = inputs.effective_ambient_pressure.to("Pa").value + MW = inputs.molecular_weight + gamma = inputs.gamma + MR = inputs.mixture_ratio + + # Compute gas properties + R = specific_gas_constant(MW) + + # Pressure ratios + pe_pc = pe_Pa / pc_Pa + pa_pc = pa_Pa / pc_Pa + + # Exit Mach number from pressure ratio + exit_mach = mach_from_pressure_ratio(pc_Pa / pe_Pa, gamma) + + # Expansion ratio from exit Mach + expansion_ratio = area_ratio_from_mach(exit_mach, gamma) + + # Characteristic velocity + cstar_val = characteristic_velocity(gamma, R, Tc_K) + + # Thrust coefficients + Cf = thrust_coefficient(gamma, pe_pc, pa_pc, expansion_ratio) + Cf_vac = thrust_coefficient_vacuum(gamma, pe_pc, expansion_ratio) + + # Specific impulse + Isp_val = specific_impulse(cstar_val, Cf, G0_SI) + Isp_vac_val = specific_impulse(cstar_val, Cf_vac, G0_SI) + + # Mass flow rate + mdot_val = mass_flow_rate(thrust_N, Isp_val, G0_SI) + mdot_ox_val = mdot_val * MR / (MR + 1.0) + mdot_fuel_val = mdot_val / (MR + 1.0) + + # Exhaust velocity + ue_val = Isp_val * G0_SI + + return EnginePerformance( + isp=seconds(Isp_val), + isp_vac=seconds(Isp_vac_val), + cstar=meters_per_second(cstar_val), + exhaust_velocity=meters_per_second(ue_val), + thrust_coeff=Cf, + thrust_coeff_vac=Cf_vac, + mdot=kg_per_second(mdot_val), + mdot_ox=kg_per_second(mdot_ox_val), + mdot_fuel=kg_per_second(mdot_fuel_val), + expansion_ratio=expansion_ratio, + exit_mach=exit_mach, + ) + + +@beartype +def compute_geometry(inputs: EngineInputs, performance: EnginePerformance) -> EngineGeometry: + """Compute engine geometry from inputs and performance. + + This is a pure function that takes engine inputs and computed performance + to determine physical dimensions. + + Args: + inputs: Engine input parameters + performance: Computed performance metrics + + Returns: + Computed geometry + """ + # Extract values + pc_Pa = inputs.chamber_pressure.to("Pa").value + mdot_val = performance.mdot.to("kg/s").value + cstar_val = performance.cstar.to("m/s").value + lstar_m = inputs.lstar.to("m").value + expansion_ratio = performance.expansion_ratio + contraction_ratio = inputs.contraction_ratio + contraction_angle_rad = math.radians(inputs.contraction_angle) + bell_fraction = inputs.bell_fraction + + # Throat geometry + At = throat_area(mdot_val, cstar_val, pc_Pa) + Dt = diameter_from_area(At) + Rt = Dt / 2.0 + + # Exit geometry + Ae = At * expansion_ratio + De = diameter_from_area(Ae) + Re = De / 2.0 + + # Chamber geometry + Ac = At * contraction_ratio + Dc = diameter_from_area(Ac) + Rc = Dc / 2.0 + + # Chamber volume from L* + Vc = chamber_volume(lstar_m, At) + + # Cylindrical chamber length + Lcyl = cylindrical_chamber_length(Vc, Ac, Rc, Rt, contraction_angle_rad) + # Ensure positive length + Lcyl = max(Lcyl, 0.0) + + # Nozzle length (bell nozzle) + Ln = bell_nozzle_length(Rt, Re, bell_fraction) + + return EngineGeometry( + throat_area=square_meters(At), + throat_diameter=meters(Dt), + exit_area=square_meters(Ae), + exit_diameter=meters(De), + chamber_area=square_meters(Ac), + chamber_diameter=meters(Dc), + chamber_volume=Quantity(Vc, "m^3", "volume"), + chamber_length=meters(Lcyl), + nozzle_length=meters(Ln), + expansion_ratio=expansion_ratio, + contraction_ratio=contraction_ratio, + ) + + +@beartype +def design_engine(inputs: EngineInputs) -> tuple[EnginePerformance, EngineGeometry]: + """Complete engine design from inputs. + + Convenience function that computes both performance and geometry. + + Args: + inputs: Engine input parameters + + Returns: + Tuple of (performance, geometry) + """ + performance = compute_performance(inputs) + geometry = compute_geometry(inputs, performance) + return performance, geometry + + +# ============================================================================= +# Analysis Functions +# ============================================================================= + + +@beartype +def thrust_at_altitude( + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + ambient_pressure: Quantity, +) -> Quantity: + """Calculate thrust at a given ambient pressure (altitude). + + Args: + inputs: Engine input parameters + performance: Computed performance (used for expansion ratio) + geometry: Computed geometry (used for exit area) + ambient_pressure: Ambient pressure at altitude + + Returns: + Thrust at the specified altitude + """ + pc_Pa = inputs.chamber_pressure.to("Pa").value + pe_Pa = inputs.exit_pressure.to("Pa").value + pa_Pa = ambient_pressure.to("Pa").value + gamma = inputs.gamma + cstar_val = performance.cstar.to("m/s").value + mdot_val = performance.mdot.to("kg/s").value + expansion_ratio = performance.expansion_ratio + + pe_pc = pe_Pa / pc_Pa + pa_pc = pa_Pa / pc_Pa + + Cf = thrust_coefficient(gamma, pe_pc, pa_pc, expansion_ratio) + thrust_N = mdot_val * cstar_val * Cf + + return Quantity(thrust_N, "N", "force") + + +@beartype +def isp_at_altitude( + inputs: EngineInputs, + performance: EnginePerformance, + ambient_pressure: Quantity, +) -> Quantity: + """Calculate specific impulse at a given ambient pressure (altitude). + + Args: + inputs: Engine input parameters + performance: Computed performance + ambient_pressure: Ambient pressure at altitude + + Returns: + Specific impulse at the specified altitude + """ + pc_Pa = inputs.chamber_pressure.to("Pa").value + pe_Pa = inputs.exit_pressure.to("Pa").value + pa_Pa = ambient_pressure.to("Pa").value + gamma = inputs.gamma + cstar_val = performance.cstar.to("m/s").value + expansion_ratio = performance.expansion_ratio + + pe_pc = pe_Pa / pc_Pa + pa_pc = pa_Pa / pc_Pa + + Cf = thrust_coefficient(gamma, pe_pc, pa_pc, expansion_ratio) + Isp = specific_impulse(cstar_val, Cf, G0_SI) + + return seconds(Isp) + + +# ============================================================================= +# Summary and Display +# ============================================================================= + + +@beartype +def format_performance_summary(inputs: EngineInputs, performance: EnginePerformance) -> str: + """Format a human-readable performance summary. + + Args: + inputs: Engine input parameters + performance: Computed performance + + Returns: + Formatted string summary + """ + name = inputs.name or "Unnamed Engine" + lines = [ + f"{'=' * 60}", + f"ENGINE PERFORMANCE SUMMARY: {name}", + f"{'=' * 60}", + "", + "INPUTS:", + f" Thrust (SL): {inputs.thrust}", + f" Chamber Pressure: {inputs.chamber_pressure}", + f" Chamber Temp: {inputs.chamber_temp}", + f" Exit Pressure: {inputs.exit_pressure}", + f" Molecular Weight: {inputs.molecular_weight:.2f} kg/kmol", + f" Gamma: {inputs.gamma:.3f}", + f" Mixture Ratio: {inputs.mixture_ratio:.2f}", + "", + "PERFORMANCE:", + f" Isp (SL): {performance.isp.value:.1f} s", + f" Isp (Vac): {performance.isp_vac.value:.1f} s", + f" C*: {performance.cstar.value:.1f} m/s", + f" Exit Velocity: {performance.exhaust_velocity.value:.1f} m/s", + f" Thrust Coeff (SL): {performance.thrust_coeff:.3f}", + f" Thrust Coeff (Vac): {performance.thrust_coeff_vac:.3f}", + f" Exit Mach: {performance.exit_mach:.2f}", + "", + "MASS FLOW:", + f" Total: {performance.mdot.value:.3f} kg/s", + f" Oxidizer: {performance.mdot_ox.value:.3f} kg/s", + f" Fuel: {performance.mdot_fuel.value:.3f} kg/s", + "", + f" Expansion Ratio: {performance.expansion_ratio:.2f}", + f"{'=' * 60}", + ] + return "\n".join(lines) + + +@beartype +def format_geometry_summary(inputs: EngineInputs, geometry: EngineGeometry) -> str: + """Format a human-readable geometry summary. + + Args: + inputs: Engine input parameters + geometry: Computed geometry + + Returns: + Formatted string summary + """ + name = inputs.name or "Unnamed Engine" + lines = [ + f"{'=' * 60}", + f"ENGINE GEOMETRY SUMMARY: {name}", + f"{'=' * 60}", + "", + "THROAT:", + f" Area: {geometry.throat_area.value * 1e4:.4f} cm^2", + f" Diameter: {geometry.throat_diameter.value * 100:.3f} cm", + "", + "EXIT:", + f" Area: {geometry.exit_area.value * 1e4:.2f} cm^2", + f" Diameter: {geometry.exit_diameter.value * 100:.2f} cm", + "", + "CHAMBER:", + f" Area: {geometry.chamber_area.value * 1e4:.2f} cm^2", + f" Diameter: {geometry.chamber_diameter.value * 100:.2f} cm", + f" Volume: {geometry.chamber_volume.value * 1e6:.1f} cm^3", + f" Length (cyl): {geometry.chamber_length.value * 100:.2f} cm", + "", + "NOZZLE:", + f" Length: {geometry.nozzle_length.value * 100:.2f} cm", + "", + "RATIOS:", + f" Expansion (Ae/At): {geometry.expansion_ratio:.2f}", + f" Contraction (Ac/At):{geometry.contraction_ratio:.2f}", + f"{'=' * 60}", + ] + return "\n".join(lines) + diff --git a/rocket/examples/__init__.py b/rocket/examples/__init__.py new file mode 100644 index 0000000..9e1f835 --- /dev/null +++ b/rocket/examples/__init__.py @@ -0,0 +1,2 @@ +"""Example scripts for OpenRocketEngine.""" + diff --git a/rocket/examples/basic_engine.py b/rocket/examples/basic_engine.py new file mode 100644 index 0000000..430fb8c --- /dev/null +++ b/rocket/examples/basic_engine.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python +"""Basic engine design example for OpenRocketEngine. + +This example demonstrates the complete workflow for designing a small +liquid rocket engine: + +1. Define engine inputs (thrust, pressures, temperatures, etc.) +2. Compute performance metrics (Isp, c*, Cf, mass flow rates) +3. Compute geometry (throat, chamber, exit dimensions) +4. Generate nozzle contour +5. Visualize the design +6. Export contour for CAD + +All outputs are organized into a timestamped directory structure. +""" + +from rocket import OutputContext +from rocket.engine import ( + EngineInputs, + compute_geometry, + compute_performance, + format_geometry_summary, + format_performance_summary, +) +from rocket.nozzle import ( + full_chamber_contour, + generate_nozzle_from_geometry, +) +from rocket.plotting import ( + plot_engine_cross_section, + plot_engine_dashboard, + plot_nozzle_contour, + plot_performance_vs_altitude, +) +from rocket.units import kelvin, megapascals, meters, newtons, pascals + + +def main() -> None: + """Run the basic engine design example.""" + print("=" * 70) + print("OpenRocketEngine - Basic Engine Design Example") + print("=" * 70) + print() + + # ========================================================================= + # Step 1: Define Engine Inputs + # ========================================================================= + # + # We're designing a small pressure-fed engine with the following specs: + # - ~5 kN (1100 lbf) sea-level thrust + # - LOX/Ethanol propellants (assumed combustion properties) + # - Pressure-fed, so moderate chamber pressure + # + # The thermochemical properties (Tc, gamma, MW) would normally come from + # NASA CEA or similar tool. Here we use representative values for + # LOX/Ethanol at O/F = 1.3 + + print("Step 1: Defining engine inputs...") + print() + + inputs = EngineInputs( + name="Student Engine Mk1", + # Performance targets + thrust=newtons(5000), # 5 kN sea-level thrust + # Chamber conditions + chamber_pressure=megapascals(2.0), # 2 MPa (~290 psi) + chamber_temp=kelvin(3200), # Flame temperature from CEA + exit_pressure=pascals(101325), # Expanded to sea level + # Propellant properties (from CEA for LOX/Ethanol) + molecular_weight=21.5, # kg/kmol + gamma=1.22, # Cp/Cv + mixture_ratio=1.3, # O/F mass ratio + # Chamber geometry parameters + lstar=meters(1.0), # Characteristic length (typical for biprop) + contraction_ratio=4.0, # Ac/At + contraction_angle=45.0, # degrees + bell_fraction=0.8, # 80% bell nozzle + ) + + print(f" Engine Name: {inputs.name}") + print(f" Design Thrust: {inputs.thrust}") + print(f" Chamber Pressure: {inputs.chamber_pressure}") + print(f" Chamber Temp: {inputs.chamber_temp}") + print(f" Exit Pressure: {inputs.exit_pressure}") + print(f" Molecular Weight: {inputs.molecular_weight} kg/kmol") + print(f" Gamma: {inputs.gamma}") + print(f" Mixture Ratio: {inputs.mixture_ratio}") + print() + + # ========================================================================= + # Step 2: Compute Performance + # ========================================================================= + + print("Step 2: Computing performance...") + print() + + performance = compute_performance(inputs) + + print(f" Specific Impulse (SL): {performance.isp.value:.1f} s") + print(f" Specific Impulse (Vac): {performance.isp_vac.value:.1f} s") + print(f" Characteristic Velocity: {performance.cstar.value:.0f} m/s") + print(f" Thrust Coefficient (SL): {performance.thrust_coeff:.3f}") + print(f" Exit Mach Number: {performance.exit_mach:.2f}") + print(f" Expansion Ratio: {performance.expansion_ratio:.1f}") + print() + print(f" Total Mass Flow: {performance.mdot.value:.3f} kg/s") + print(f" Oxidizer Flow: {performance.mdot_ox.value:.3f} kg/s") + print(f" Fuel Flow: {performance.mdot_fuel.value:.3f} kg/s") + print() + + # ========================================================================= + # Step 3: Compute Geometry + # ========================================================================= + + print("Step 3: Computing geometry...") + print() + + geometry = compute_geometry(inputs, performance) + + # Convert to more convenient units for display + Dt_mm = geometry.throat_diameter.to("m").value * 1000 + De_mm = geometry.exit_diameter.to("m").value * 1000 + Dc_mm = geometry.chamber_diameter.to("m").value * 1000 + Lc_mm = geometry.chamber_length.to("m").value * 1000 + Ln_mm = geometry.nozzle_length.to("m").value * 1000 + + print(f" Throat Diameter: {Dt_mm:.1f} mm") + print(f" Exit Diameter: {De_mm:.1f} mm") + print(f" Chamber Diameter: {Dc_mm:.1f} mm") + print(f" Chamber Length: {Lc_mm:.1f} mm") + print(f" Nozzle Length: {Ln_mm:.1f} mm") + print(f" Expansion Ratio: {geometry.expansion_ratio:.1f}") + print(f" Contraction Ratio: {geometry.contraction_ratio:.1f}") + print() + + # ========================================================================= + # Step 4: Generate Nozzle Contour + # ========================================================================= + + print("Step 4: Generating nozzle contour...") + print() + + # Generate just the divergent nozzle section + nozzle_contour = generate_nozzle_from_geometry( + geometry, bell_fraction=inputs.bell_fraction, num_points=100 + ) + + print(f" Contour Type: {nozzle_contour.contour_type}") + print(f" Number of Points: {len(nozzle_contour.x)}") + print(f" Nozzle Length: {nozzle_contour.length * 1000:.1f} mm") + print() + + # Generate full chamber contour (chamber + convergent + divergent) + full_contour = full_chamber_contour( + inputs, geometry, nozzle_contour, num_chamber_points=50, num_convergent_points=30 + ) + + print(f" Full Contour Type: {full_contour.contour_type}") + print(f" Total Points: {len(full_contour.x)}") + print(f" Total Length: {full_contour.length * 1000:.1f} mm") + print() + + # ========================================================================= + # Step 5-7: Save All Outputs to Organized Directory + # ========================================================================= + + print("Step 5-7: Saving outputs...") + print() + + # Use OutputContext to organize all outputs + with OutputContext("student_engine_mk1", include_timestamp=True) as ctx: + # Add metadata about this run + ctx.add_metadata("engine_name", inputs.name) + ctx.add_metadata("thrust_N", inputs.thrust.to("N").value) + ctx.add_metadata("chamber_pressure_MPa", inputs.chamber_pressure.to("MPa").value) + + # Export contours to CSV (automatically goes to data/) + ctx.log("Exporting nozzle contours...") + nozzle_contour.to_csv(ctx.path("nozzle_contour.csv")) + full_contour.to_csv(ctx.path("full_chamber_contour.csv")) + + # Save text summaries (automatically goes to reports/) + ctx.log("Saving performance summaries...") + ctx.save_text(format_performance_summary(inputs, performance), "performance_summary.txt") + ctx.save_text(format_geometry_summary(inputs, geometry), "geometry_summary.txt") + + # Save design summary as JSON + ctx.save_summary({ + "engine_name": inputs.name, + "performance": { + "isp_sl_s": performance.isp.value, + "isp_vac_s": performance.isp_vac.value, + "cstar_m_s": performance.cstar.value, + "thrust_coeff_sl": performance.thrust_coeff, + "thrust_coeff_vac": performance.thrust_coeff_vac, + "exit_mach": performance.exit_mach, + "mdot_kg_s": performance.mdot.value, + "mdot_ox_kg_s": performance.mdot_ox.value, + "mdot_fuel_kg_s": performance.mdot_fuel.value, + }, + "geometry": { + "throat_diameter_mm": Dt_mm, + "exit_diameter_mm": De_mm, + "chamber_diameter_mm": Dc_mm, + "chamber_length_mm": Lc_mm, + "nozzle_length_mm": Ln_mm, + "expansion_ratio": geometry.expansion_ratio, + "contraction_ratio": geometry.contraction_ratio, + }, + "inputs": { + "thrust_N": inputs.thrust.to("N").value, + "chamber_pressure_Pa": inputs.chamber_pressure.to("Pa").value, + "chamber_temp_K": inputs.chamber_temp.to("K").value, + "molecular_weight": inputs.molecular_weight, + "gamma": inputs.gamma, + "mixture_ratio": inputs.mixture_ratio, + }, + }) + + # Create visualizations (automatically goes to plots/) + ctx.log("Generating visualizations...") + + fig1 = plot_engine_cross_section( + geometry, full_contour, inputs, show_dimensions=True, + title=f"{inputs.name} Cross-Section" + ) + fig1.savefig(ctx.path("engine_cross_section.png"), dpi=150, bbox_inches="tight") + + fig2 = plot_nozzle_contour(nozzle_contour, title=f"{inputs.name} Nozzle Contour") + fig2.savefig(ctx.path("nozzle_contour.png"), dpi=150, bbox_inches="tight") + + fig3 = plot_performance_vs_altitude(inputs, performance, geometry, max_altitude_km=80) + fig3.savefig(ctx.path("altitude_performance.png"), dpi=150, bbox_inches="tight") + + fig4 = plot_engine_dashboard(inputs, performance, geometry, full_contour) + fig4.savefig(ctx.path("engine_dashboard.png"), dpi=150, bbox_inches="tight") + + ctx.log("All outputs saved!") + print() + print(f" Output directory: {ctx.output_dir}") + + print() + print("=" * 70) + print("Design complete!") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/rocket/examples/cycle_comparison.py b/rocket/examples/cycle_comparison.py new file mode 100644 index 0000000..d625229 --- /dev/null +++ b/rocket/examples/cycle_comparison.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python +"""Engine cycle comparison example for Rocket. + +This example demonstrates how to compare different engine cycle architectures +for a given set of requirements: + +1. Pressure-fed (simplest, lowest performance) +2. Gas generator (most common, good balance) +3. Staged combustion (highest performance, most complex) + +Understanding cycle tradeoffs is critical for: +- Selecting the right architecture for your mission +- Understanding performance vs. complexity tradeoffs +- Estimating system-level impacts (tank pressure, turbomachinery) +""" + +from rocket import ( + EngineInputs, + OutputContext, + design_engine, + plot_cycle_comparison_bars, + plot_cycle_radar, + plot_cycle_tradeoff, +) +from rocket.cycles import ( + GasGeneratorCycle, + PressureFedCycle, + StagedCombustionCycle, +) +from rocket.units import kelvin, kilonewtons, megapascals + + +def print_header(text: str) -> None: + """Print a formatted section header.""" + print() + print("┌" + "─" * 68 + "┐") + print(f"│ {text:<66} │") + print("└" + "─" * 68 + "┘") + + +def main() -> None: + """Run the engine cycle comparison example.""" + print() + print("╔" + "═" * 68 + "╗") + print("║" + " " * 16 + "ENGINE CYCLE COMPARISON STUDY" + " " * 23 + "║") + print("║" + " " * 18 + "LOX/CH4 Methalox Engine" + " " * 27 + "║") + print("╚" + "═" * 68 + "╝") + + # ========================================================================= + # Common Engine Requirements + # ========================================================================= + + print_header("ENGINE REQUIREMENTS") + + # Base engine thermochemistry (same for all cycles) + base_inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(500), + chamber_pressure=megapascals(15), + mixture_ratio=3.2, + name="Methalox-500", + ) + + print(f" Thrust target: {base_inputs.thrust.to('kN').value:.0f} kN") + print(f" Chamber pressure: {base_inputs.chamber_pressure.to('MPa').value:.0f} MPa") + print(f" Propellants: LOX / CH4") + print(f" Mixture ratio: {base_inputs.mixture_ratio}") + print() + + # Get baseline performance and geometry + performance, geometry = design_engine(base_inputs) + + print(f" Ideal Isp (vac): {performance.isp_vac.value:.1f} s") + print(f" Ideal c*: {performance.cstar.to('m/s').value:.0f} m/s") + print(f" Mass flow: {performance.mdot.to('kg/s').value:.1f} kg/s") + + # Store results for comparison + results: list[dict] = [] + + # ========================================================================= + # Cycle 1: Pressure-Fed + # ========================================================================= + + print_header("CYCLE 1: PRESSURE-FED") + + print(" Architecture:") + print(" - Propellants pushed by tank pressure (no pumps)") + print(" - Requires high tank pressure → heavy tanks") + print(" - Simplest system, highest reliability") + print() + + pressure_fed = PressureFedCycle( + tank_pressure_margin=1.3, + line_loss_fraction=0.05, + injector_dp_fraction=0.15, + ) + + pf_result = pressure_fed.analyze(base_inputs, performance, geometry) + + print(" Results:") + print(f" Tank pressure (ox): {pf_result.tank_pressure_ox.to('MPa').value:.1f} MPa") + print(f" Tank pressure (fuel): {pf_result.tank_pressure_fuel.to('MPa').value:.1f} MPa") + print(f" Net Isp: {pf_result.net_isp.to('s').value:.1f} s") + print(f" Net thrust: {pf_result.net_thrust.to('kN').value:.0f} kN") + print(f" Cycle efficiency: {pf_result.cycle_efficiency*100:.1f}%") + if pf_result.warnings: + print(f" Warnings: {len(pf_result.warnings)}") + for w in pf_result.warnings: + print(f" ⚠ {w}") + + results.append({ + "name": "Pressure-Fed", + "net_isp": pf_result.net_isp.to("s").value, + "tank_pressure_MPa": pf_result.tank_pressure_ox.to("MPa").value, + "pump_power_kW": 0, + "efficiency": pf_result.cycle_efficiency, + "simplicity": 1.0, + "reliability": 0.95, + }) + + # ========================================================================= + # Cycle 2: Gas Generator + # ========================================================================= + + print_header("CYCLE 2: GAS GENERATOR") + + print(" Architecture:") + print(" - Small combustor (GG) drives turbine") + print(" - Turbine exhaust dumped overboard (Isp loss)") + print(" - Moderate complexity, proven technology") + print() + + gas_generator = GasGeneratorCycle( + turbine_inlet_temp=kelvin(900), + pump_efficiency_ox=0.70, + pump_efficiency_fuel=0.70, + turbine_efficiency=0.65, + gg_mixture_ratio=0.4, + ) + + gg_result = gas_generator.analyze(base_inputs, performance, geometry) + + total_pump_power_gg = ( + gg_result.pump_power_ox.to("kW").value + + gg_result.pump_power_fuel.to("kW").value + ) + + print(" Results:") + print(f" Turbine mass flow: {gg_result.turbine_mass_flow.to('kg/s').value:.1f} kg/s") + print(f" Net Isp: {gg_result.net_isp.to('s').value:.1f} s") + print(f" Net thrust: {gg_result.net_thrust.to('kN').value:.0f} kN") + print(f" Cycle efficiency: {gg_result.cycle_efficiency*100:.1f}%") + print(f" Isp loss vs ideal: {performance.isp_vac.value - gg_result.net_isp.to('s').value:.1f} s") + if gg_result.warnings: + print(f" Warnings: {len(gg_result.warnings)}") + for w in gg_result.warnings: + print(f" ⚠ {w}") + + results.append({ + "name": "Gas Generator", + "net_isp": gg_result.net_isp.to("s").value, + "tank_pressure_MPa": gg_result.tank_pressure_ox.to("MPa").value if gg_result.tank_pressure_ox else 0.5, + "pump_power_kW": total_pump_power_gg, + "efficiency": gg_result.cycle_efficiency, + "simplicity": 0.6, + "reliability": 0.90, + }) + + # ========================================================================= + # Cycle 3: Staged Combustion (Oxidizer-Rich) + # ========================================================================= + + print_header("CYCLE 3: STAGED COMBUSTION (OX-RICH)") + + print(" Architecture:") + print(" - Preburner runs oxygen-rich") + print(" - ALL flow goes through main chamber (no dump)") + print(" - Highest performance, most complex") + print() + + staged_combustion = StagedCombustionCycle( + preburner_temp=kelvin(750), + pump_efficiency_ox=0.75, + pump_efficiency_fuel=0.75, + turbine_efficiency=0.70, + preburner_mixture_ratio=50.0, + oxidizer_rich=True, + ) + + sc_result = staged_combustion.analyze(base_inputs, performance, geometry) + + total_pump_power_sc = ( + sc_result.pump_power_ox.to("kW").value + + sc_result.pump_power_fuel.to("kW").value + ) + + print(" Results:") + print(f" Turbine mass flow: {sc_result.turbine_mass_flow.to('kg/s').value:.1f} kg/s") + print(f" Net Isp: {sc_result.net_isp.to('s').value:.1f} s") + print(f" Net thrust: {sc_result.net_thrust.to('kN').value:.0f} kN") + print(f" Cycle efficiency: {sc_result.cycle_efficiency*100:.1f}%") + if sc_result.warnings: + print(f" Warnings: {len(sc_result.warnings)}") + for w in sc_result.warnings: + print(f" ⚠ {w}") + + results.append({ + "name": "Staged Combustion", + "net_isp": sc_result.net_isp.to("s").value, + "tank_pressure_MPa": sc_result.tank_pressure_ox.to("MPa").value if sc_result.tank_pressure_ox else 0.5, + "pump_power_kW": total_pump_power_sc, + "efficiency": sc_result.cycle_efficiency, + "simplicity": 0.3, + "reliability": 0.85, + }) + + # ========================================================================= + # Comparison Summary + # ========================================================================= + + print_header("COMPARISON SUMMARY") + + print() + print(f" {'Cycle':<20} {'Net Isp':<12} {'Efficiency':<12} {'Tank P (MPa)':<12}") + print(" " + "-" * 56) + + for r in results: + isp_str = f"{r['net_isp']:.1f} s" + eff_str = f"{r['efficiency']*100:.1f}%" + tank_str = f"{r['tank_pressure_MPa']:.1f}" + print(f" {r['name']:<20} {isp_str:<12} {eff_str:<12} {tank_str:<12}") + + # Compute comparison from actual results + if len(results) >= 3: + isp_gain = results[2]["net_isp"] - results[1]["net_isp"] + isp_gain_pct = 100 * isp_gain / results[1]["net_isp"] if results[1]["net_isp"] > 0 else 0 + + print() + print(f" Staged combustion vs Gas Generator:") + print(f" Isp gain: {isp_gain:.1f} s ({isp_gain_pct:.1f}%)") + + print() + + # ========================================================================= + # Save Results + # ========================================================================= + + print_header("SAVING RESULTS") + + with OutputContext("cycle_comparison", include_timestamp=True) as ctx: + # Generate visualizations + print() + print(" Generating visualizations...") + + # 1. Bar chart comparison + fig_bars = plot_cycle_comparison_bars( + results, + metrics=["net_isp", "efficiency", "tank_pressure_MPa", "pump_power_kW"], + title="LOX/CH4 Engine Cycle Comparison", + ) + fig_bars.savefig(ctx.path("cycle_comparison_bars.png"), dpi=150, bbox_inches="tight") + print(f" - cycle_comparison_bars.png") + + # 2. Radar chart + fig_radar = plot_cycle_radar( + results, + metrics=["net_isp", "efficiency", "simplicity", "reliability"], + title="Cycle Trade-offs (Normalized)", + ) + fig_radar.savefig(ctx.path("cycle_radar.png"), dpi=150, bbox_inches="tight") + print(f" - cycle_radar.png") + + # 3. Trade-off scatter plot + fig_tradeoff = plot_cycle_tradeoff( + results, + x_metric="net_isp", + y_metric="simplicity", + size_metric="pump_power_kW", + title="Performance vs Simplicity Trade Space", + ) + fig_tradeoff.savefig(ctx.path("cycle_tradeoff.png"), dpi=150, bbox_inches="tight") + print(f" - cycle_tradeoff.png") + + # Save summary data + ctx.save_summary({ + "requirements": { + "thrust_kN": base_inputs.thrust.to("kN").value, + "chamber_pressure_MPa": base_inputs.chamber_pressure.to("MPa").value, + "propellants": "LOX/CH4", + "mixture_ratio": base_inputs.mixture_ratio, + }, + "ideal_performance": { + "isp_vac_s": performance.isp_vac.value, + "cstar_m_s": performance.cstar.value, + "mdot_kg_s": performance.mdot.value, + }, + "cycles": results, + }) + + print() + print(f" Results saved to: {ctx.output_dir}") + + print() + print("╔" + "═" * 68 + "╗") + print("║" + " " * 24 + "ANALYSIS COMPLETE" + " " * 27 + "║") + print("╚" + "═" * 68 + "╝") + print() + + +if __name__ == "__main__": + main() diff --git a/rocket/examples/optimization.py b/rocket/examples/optimization.py new file mode 100644 index 0000000..5014408 --- /dev/null +++ b/rocket/examples/optimization.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python +"""Multi-objective optimization example for Rocket. + +This example demonstrates how to find Pareto-optimal engine designs +that balance competing objectives: + +1. Maximize Isp (specific impulse) +2. Minimize engine mass (via throat size as proxy) +3. Satisfy thermal constraints + +Real engine design involves tradeoffs - you can't maximize everything. +Pareto fronts show the best achievable combinations. +""" + +from rocket import ( + EngineInputs, + MultiObjectiveOptimizer, + OutputContext, + Range, + design_engine, +) +from rocket.units import kilonewtons, megapascals + + +def main() -> None: + """Run the multi-objective optimization example.""" + print("=" * 70) + print("Rocket - Multi-Objective Optimization Example") + print("=" * 70) + print() + + # ========================================================================= + # Define the Optimization Problem + # ========================================================================= + + print("Problem: Design a LOX/CH4 engine balancing Isp vs. compactness") + print("-" * 70) + print() + print("Objectives:") + print(" 1. Maximize vacuum Isp (performance)") + print(" 2. Minimize throat diameter (smaller = lighter, cheaper)") + print() + print("Design variables:") + print(" - Chamber pressure: 5-25 MPa") + print(" - Mixture ratio: 2.5-4.0") + print() + print("Constraints:") + print(" - Expansion ratio < 80 (practical nozzle size)") + print(" - Throat diameter > 3 cm (manufacturability)") + print() + + # Baseline design + baseline = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(100), + chamber_pressure=megapascals(10), + mixture_ratio=3.2, + name="Methalox-Baseline", + ) + + # Define constraints + def expansion_constraint(result: tuple) -> bool: + """Expansion ratio must be < 80.""" + _, geometry = result + return geometry.expansion_ratio < 80 + + def throat_constraint(result: tuple) -> bool: + """Throat diameter must be > 3 cm for manufacturability.""" + _, geometry = result + return geometry.throat_diameter.to("m").value * 100 > 3.0 + + # ========================================================================= + # Run Multi-Objective Optimization + # ========================================================================= + + print("Running optimization...") + print() + + optimizer = MultiObjectiveOptimizer( + compute=design_engine, + base=baseline, + objectives=["isp_vac", "throat_diameter"], + maximize=[True, False], # Max Isp, Min throat (smaller = better) + vary={ + "chamber_pressure": Range(5, 25, n=15, unit="MPa"), + "mixture_ratio": Range(2.5, 4.0, n=10), + }, + constraints=[expansion_constraint, throat_constraint], + ) + + pareto_results = optimizer.run(progress=True) + + print() + print(f"Total designs evaluated: {pareto_results.n_total}") + print(f"Feasible designs: {pareto_results.n_feasible}") + print(f"Pareto-optimal designs: {pareto_results.n_pareto}") + print() + + # ========================================================================= + # Analyze Pareto Front + # ========================================================================= + + print("=" * 70) + print("PARETO-OPTIMAL DESIGNS") + print("=" * 70) + print() + print("These designs represent the best tradeoffs between Isp and size:") + print() + print(f" {'#':<4} {'Pc (MPa)':<10} {'MR':<8} {'Isp (vac)':<12} {'Throat (cm)':<12} {'ε':<8}") + print(" " + "-" * 54) + + pareto_df = pareto_results.pareto_front() + + for i, row in enumerate(pareto_df.iter_rows(named=True)): + pc_mpa = row["chamber_pressure"] / 1e6 + dt_cm = row["throat_diameter"] * 100 + print(f" {i+1:<4} {pc_mpa:<10.0f} {row['mixture_ratio']:<8.1f} {row['isp_vac']:<12.1f} {dt_cm:<12.2f} {row['expansion_ratio']:<8.1f}") + + print() + + # ========================================================================= + # Interpret Results + # ========================================================================= + + print("=" * 70) + print("DESIGN RECOMMENDATIONS") + print("=" * 70) + print() + + # Best Isp design + best_isp_idx = pareto_df["isp_vac"].arg_max() + best_isp_row = pareto_df.row(best_isp_idx, named=True) + + print("For MAXIMUM PERFORMANCE (highest Isp):") + print(f" Chamber pressure: {best_isp_row['chamber_pressure']/1e6:.0f} MPa") + print(f" Mixture ratio: {best_isp_row['mixture_ratio']:.1f}") + print(f" Isp (vac): {best_isp_row['isp_vac']:.1f} s") + print(f" Throat diameter: {best_isp_row['throat_diameter']*100:.2f} cm") + print() + + # Smallest design + best_size_idx = pareto_df["throat_diameter"].arg_min() + best_size_row = pareto_df.row(best_size_idx, named=True) + + print("For MINIMUM SIZE (smallest throat):") + print(f" Chamber pressure: {best_size_row['chamber_pressure']/1e6:.0f} MPa") + print(f" Mixture ratio: {best_size_row['mixture_ratio']:.1f}") + print(f" Isp (vac): {best_size_row['isp_vac']:.1f} s") + print(f" Throat diameter: {best_size_row['throat_diameter']*100:.2f} cm") + print() + + # Balanced design (middle of Pareto front) + mid_idx = len(pareto_df) // 2 + mid_row = pareto_df.row(mid_idx, named=True) + + print("For BALANCED DESIGN (middle of Pareto front):") + print(f" Chamber pressure: {mid_row['chamber_pressure']/1e6:.0f} MPa") + print(f" Mixture ratio: {mid_row['mixture_ratio']:.1f}") + print(f" Isp (vac): {mid_row['isp_vac']:.1f} s") + print(f" Throat diameter: {mid_row['throat_diameter']*100:.2f} cm") + print() + + # ========================================================================= + # Tradeoff Analysis + # ========================================================================= + + print("=" * 70) + print("TRADEOFF ANALYSIS") + print("=" * 70) + print() + + isp_range = pareto_df["isp_vac"].max() - pareto_df["isp_vac"].min() + dt_range = (pareto_df["throat_diameter"].max() - pareto_df["throat_diameter"].min()) * 100 + + print(f" Isp range on Pareto front: {pareto_df['isp_vac'].min():.1f} - {pareto_df['isp_vac'].max():.1f} s (Δ = {isp_range:.1f} s)") + print(f" Throat range on Pareto front: {pareto_df['throat_diameter'].min()*100:.2f} - {pareto_df['throat_diameter'].max()*100:.2f} cm (Δ = {dt_range:.2f} cm)") + print() + print(" Interpretation:") + print(f" - You can gain up to {isp_range:.1f} s of Isp...") + print(f" - ...at the cost of {dt_range:.1f} cm larger throat") + print(f" - Marginal tradeoff: {isp_range/dt_range:.1f} s of Isp per cm of throat") + print() + + # ========================================================================= + # Save Results + # ========================================================================= + + print("=" * 70) + print("Saving Results") + print("=" * 70) + print() + + with OutputContext("optimization_results", include_timestamp=True) as ctx: + # Export all results + ctx.log("Exporting full design space...") + pareto_results.all_results.to_csv(ctx.path("all_designs.csv")) + + ctx.log("Exporting Pareto front...") + pareto_df.write_csv(ctx.path("pareto_front.csv")) + + # Save summary + ctx.save_summary({ + "problem": { + "objectives": ["isp_vac (maximize)", "throat_diameter (minimize)"], + "design_variables": { + "chamber_pressure_MPa": [5, 25], + "mixture_ratio": [2.5, 4.0], + }, + "constraints": [ + "expansion_ratio < 80", + "throat_diameter > 3 cm", + ], + }, + "results": { + "total_designs": pareto_results.n_total, + "feasible_designs": pareto_results.n_feasible, + "pareto_optimal": pareto_results.n_pareto, + }, + "recommendations": { + "max_performance": { + "chamber_pressure_MPa": best_isp_row["chamber_pressure"] / 1e6, + "mixture_ratio": best_isp_row["mixture_ratio"], + "isp_vac_s": best_isp_row["isp_vac"], + }, + "min_size": { + "chamber_pressure_MPa": best_size_row["chamber_pressure"] / 1e6, + "mixture_ratio": best_size_row["mixture_ratio"], + "throat_diameter_cm": best_size_row["throat_diameter"] * 100, + }, + "balanced": { + "chamber_pressure_MPa": mid_row["chamber_pressure"] / 1e6, + "mixture_ratio": mid_row["mixture_ratio"], + "isp_vac_s": mid_row["isp_vac"], + "throat_diameter_cm": mid_row["throat_diameter"] * 100, + }, + }, + }) + + print() + print(f" All results saved to: {ctx.output_dir}") + + print() + print("=" * 70) + print("Optimization Complete!") + print("=" * 70) + print() + + +if __name__ == "__main__": + main() + diff --git a/rocket/examples/propellant_design.py b/rocket/examples/propellant_design.py new file mode 100644 index 0000000..9be7a5f --- /dev/null +++ b/rocket/examples/propellant_design.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python +"""Propellant-based engine design example for Rocket. + +This example demonstrates the simplified workflow where you specify +propellants and the library automatically determines combustion properties. + +No need to manually look up Tc, gamma, or molecular weight! +""" + +from rocket import OutputContext, design_engine, plot_engine_dashboard +from rocket.engine import EngineInputs +from rocket.nozzle import full_chamber_contour, generate_nozzle_from_geometry +from rocket.units import kilonewtons, megapascals + + +def main() -> None: + """Run the propellant-based design example.""" + print("=" * 70) + print("Rocket - Propellant-Based Design") + print("=" * 70) + print() + print("Using NASA CEA (via RocketCEA) for thermochemistry calculations") + print() + + # Store all engine designs for comparison + designs: list[tuple[str, EngineInputs, any, any]] = [] + + # ========================================================================= + # Design a LOX/RP-1 Engine (like Merlin) + # ========================================================================= + + print("-" * 70) + print("Design 1: LOX/RP-1 Engine (Kerolox)") + print("-" * 70) + print() + + # Just specify propellants, thrust, and pressure - that's it! + lox_rp1 = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="RP1", + thrust=kilonewtons(100), # 100 kN + chamber_pressure=megapascals(7), # 7 MPa (~1000 psi) + mixture_ratio=2.7, # Typical for LOX/RP-1 + name="Kerolox-100", + ) + + print(f"Engine: {lox_rp1.name}") + print(" Propellants: LOX / RP-1") + print(f" Mixture Ratio: {lox_rp1.mixture_ratio}") + print(f" Chamber Temp: {lox_rp1.chamber_temp.to('K').value:.0f} K (auto-calculated!)") + print(f" Gamma: {lox_rp1.gamma:.3f} (auto-calculated!)") + print(f" Molecular Weight: {lox_rp1.molecular_weight:.1f} kg/kmol (auto-calculated!)") + print() + + perf1, geom1 = design_engine(lox_rp1) + print("Performance:") + print(f" Isp (SL): {perf1.isp.value:.1f} s") + print(f" Isp (Vac): {perf1.isp_vac.value:.1f} s") + print(f" Thrust Coeff: {perf1.thrust_coeff:.3f}") + print(f" Mass Flow: {perf1.mdot.value:.2f} kg/s") + print() + print("Geometry:") + print(f" Throat Diameter: {geom1.throat_diameter.to('m').value * 100:.1f} cm") + print(f" Exit Diameter: {geom1.exit_diameter.to('m').value * 100:.1f} cm") + print(f" Expansion Ratio: {geom1.expansion_ratio:.1f}") + print() + + designs.append(("LOX/RP-1", lox_rp1, perf1, geom1)) + + # ========================================================================= + # Design a LOX/Methane Engine (like Raptor) + # ========================================================================= + + print("-" * 70) + print("Design 2: LOX/Methane Engine (Methalox)") + print("-" * 70) + print() + + lox_ch4 = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(200), + chamber_pressure=megapascals(10), # Higher pressure + mixture_ratio=3.2, + name="Methalox-200", + ) + + print(f"Engine: {lox_ch4.name}") + print(f" Chamber Temp: {lox_ch4.chamber_temp.to('K').value:.0f} K") + print(f" Gamma: {lox_ch4.gamma:.3f}") + print() + + perf2, geom2 = design_engine(lox_ch4) + print("Performance:") + print(f" Isp (SL): {perf2.isp.value:.1f} s") + print(f" Isp (Vac): {perf2.isp_vac.value:.1f} s") + print() + + designs.append(("LOX/CH4", lox_ch4, perf2, geom2)) + + # ========================================================================= + # Design a LOX/LH2 Engine (like RS-25/SSME) + # ========================================================================= + + print("-" * 70) + print("Design 3: LOX/LH2 Engine (Hydrolox)") + print("-" * 70) + print() + + lox_lh2 = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="LH2", + thrust=kilonewtons(50), # Smaller for demo + chamber_pressure=megapascals(15), # High pressure like SSME + mixture_ratio=6.0, # Typical for LOX/LH2 + name="Hydrolox-50", + ) + + print(f"Engine: {lox_lh2.name}") + print(f" Chamber Temp: {lox_lh2.chamber_temp.to('K').value:.0f} K") + print(f" Molecular Weight: {lox_lh2.molecular_weight:.1f} kg/kmol (low = high Isp!)") + print() + + perf3, geom3 = design_engine(lox_lh2) + print("Performance:") + print(f" Isp (SL): {perf3.isp.value:.1f} s") + print(f" Isp (Vac): {perf3.isp_vac.value:.1f} s <- Highest!") + print() + + designs.append(("LOX/LH2", lox_lh2, perf3, geom3)) + + # ========================================================================= + # Comparison Summary + # ========================================================================= + + print("=" * 70) + print("COMPARISON SUMMARY") + print("=" * 70) + print() + print(f"{'Engine':<20} {'Isp(SL)':<10} {'Isp(Vac)':<10} {'MW':<8} {'Tc (K)':<10}") + print("-" * 70) + + for name, inputs, perf, _ in designs: + print( + f"{name:<20} " + f"{perf.isp.value:<10.1f} " + f"{perf.isp_vac.value:<10.1f} " + f"{inputs.molecular_weight:<8.1f} " + f"{inputs.chamber_temp.to('K').value:<10.0f}" + ) + + print() + + # ========================================================================= + # Generate Dashboards for All Engines + # ========================================================================= + + print("=" * 70) + print("GENERATING VISUALIZATIONS") + print("=" * 70) + print() + + with OutputContext("propellant_comparison", include_timestamp=True) as ctx: + # Save comparison summary + ctx.save_summary({ + "designs": [ + { + "name": name, + "propellants": f"{inputs.name}", + "isp_sl": perf.isp.value, + "isp_vac": perf.isp_vac.value, + "molecular_weight": inputs.molecular_weight, + "chamber_temp_K": inputs.chamber_temp.to("K").value, + "gamma": inputs.gamma, + "thrust_kN": inputs.thrust.to("kN").value, + "chamber_pressure_MPa": inputs.chamber_pressure.to("MPa").value, + } + for name, inputs, perf, _ in designs + ] + }, "comparison_summary.json") + + for name, inputs, perf, geom in designs: + ctx.log(f"Generating dashboard for {inputs.name}...") + nozzle = generate_nozzle_from_geometry(geom) + contour = full_chamber_contour(inputs, geom, nozzle) + fig = plot_engine_dashboard(inputs, perf, geom, contour) + + safe_name = inputs.name.lower().replace("-", "_").replace(" ", "_") + fig.savefig(ctx.path(f"{safe_name}_dashboard.png"), dpi=150, bbox_inches="tight") + + print() + print(f" All outputs saved to: {ctx.output_dir}") + + print() + print("=" * 70) + print("Done!") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/rocket/examples/thermal_analysis.py b/rocket/examples/thermal_analysis.py new file mode 100644 index 0000000..70c294a --- /dev/null +++ b/rocket/examples/thermal_analysis.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python +"""Thermal analysis example for Rocket. + +This example demonstrates thermal screening to determine if an engine +design can be regeneratively cooled: + +1. Estimate heat flux using the Bartz correlation +2. Check cooling feasibility for different coolants +3. Understand the relationship between chamber pressure and cooling + +High chamber pressure engines (like Raptor) face severe thermal challenges. +This analysis helps catch infeasible designs early. +""" + +from rocket import ( + EngineInputs, + OutputContext, + design_engine, +) +from rocket.thermal import ( + check_cooling_feasibility, + estimate_heat_flux, + heat_flux_profile, +) +from rocket.units import kelvin, kilonewtons, megapascals + + +def print_header(text: str) -> None: + """Print a formatted section header.""" + print() + print("┌" + "─" * 68 + "┐") + print(f"│ {text:<66} │") + print("└" + "─" * 68 + "┘") + + +def main() -> None: + """Run the thermal analysis example.""" + print() + print("╔" + "═" * 68 + "╗") + print("║" + " " * 18 + "THERMAL FEASIBILITY ANALYSIS" + " " * 22 + "║") + print("║" + " " * 15 + "Can This Engine Be Regeneratively Cooled?" + " " * 12 + "║") + print("╚" + "═" * 68 + "╝") + + # ========================================================================= + # Design a High-Performance Engine + # ========================================================================= + + print_header("ENGINE DESIGN") + + # High chamber pressure like Raptor + inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(500), + chamber_pressure=megapascals(25), # High pressure! + mixture_ratio=3.2, + name="High-Pc-Methalox", + ) + + performance, geometry = design_engine(inputs) + + print(f" Engine: {inputs.name}") + print(f" Thrust: {inputs.thrust.to('kN').value:.0f} kN") + print(f" Chamber pressure: {inputs.chamber_pressure.to('MPa').value:.0f} MPa") + print(f" Chamber temperature: {inputs.chamber_temp.to('K').value:.0f} K") + print() + print(f" Isp (vac): {performance.isp_vac.value:.1f} s") + print(f" Throat diameter: {geometry.throat_diameter.to('m').value*100:.2f} cm") + + # ========================================================================= + # Heat Flux Estimation + # ========================================================================= + + print_header("HEAT FLUX ANALYSIS") + + print(" Using Bartz correlation for convective heat transfer...") + print() + + # Estimate heat flux at key locations + locations = ["chamber", "throat", "exit"] + + print(f" {'Location':<15} {'Heat Flux (MW/m²)':<20}") + print(" " + "-" * 35) + + max_q = 0.0 + max_location = "" + + for location in locations: + q = estimate_heat_flux( + inputs=inputs, + performance=performance, + geometry=geometry, + location=location, + ) + q_mw = q.to("W/m^2").value / 1e6 + + if q_mw > max_q: + max_q = q_mw + max_location = location + + print(f" {location:<15} {q_mw:<20.1f}") + + print() + print(f" Maximum heat flux: {max_q:.1f} MW/m² at {max_location}") + + # ========================================================================= + # Heat Flux Profile Along Nozzle + # ========================================================================= + + print_header("AXIAL HEAT FLUX PROFILE") + + x_positions, heat_fluxes = heat_flux_profile( + inputs=inputs, + performance=performance, + geometry=geometry, + n_points=11, + ) + + print(f" {'x/L':<10} {'Heat Flux (MW/m²)':<20}") + print(" " + "-" * 30) + + for x, q in zip(x_positions, heat_fluxes, strict=True): + q_mw = q / 1e6 # heat_flux_profile returns W/m² + bar = "█" * int(q_mw / 5) # Simple bar chart + print(f" {x:<10.2f} {q_mw:<10.1f} {bar}") + + # ========================================================================= + # Cooling Feasibility Check - Methane + # ========================================================================= + + print_header("COOLING FEASIBILITY: METHANE (CH4)") + + print(" Checking if methane can cool this engine...") + print() + + ch4_cooling = check_cooling_feasibility( + inputs=inputs, + performance=performance, + geometry=geometry, + coolant="CH4", + coolant_inlet_temp=kelvin(110), # Near boiling point + max_wall_temp=kelvin(920), # Material limit + ) + + if ch4_cooling.feasible: + print(" ✓ FEASIBLE with methane cooling!") + else: + print(" ✗ NOT FEASIBLE with methane cooling") + + print() + print(f" Max wall temperature: {ch4_cooling.max_wall_temp.to('K').value:.0f} K (limit: {ch4_cooling.max_allowed_temp.to('K').value:.0f} K)") + print(f" Throat heat flux: {ch4_cooling.throat_heat_flux.to('W/m^2').value/1e6:.1f} MW/m²") + print(f" Coolant outlet temp: {ch4_cooling.coolant_outlet_temp.to('K').value:.0f} K") + print(f" Flow margin: {ch4_cooling.flow_margin:.2f}x") + if ch4_cooling.warnings: + print(f" Warnings:") + for w in ch4_cooling.warnings: + print(f" ⚠ {w}") + + # ========================================================================= + # Cooling Feasibility Check - RP-1 (for comparison) + # ========================================================================= + + print_header("COOLING FEASIBILITY: RP-1 (KEROSENE)") + + # Design a kerosene engine for comparison + rp1_inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="RP1", + thrust=kilonewtons(500), + chamber_pressure=megapascals(25), + mixture_ratio=2.7, + name="High-Pc-Kerolox", + ) + rp1_perf, rp1_geom = design_engine(rp1_inputs) + + print(" Checking RP-1 cooling for LOX/RP-1 engine...") + print() + + rp1_cooling = check_cooling_feasibility( + inputs=rp1_inputs, + performance=rp1_perf, + geometry=rp1_geom, + coolant="RP1", + coolant_inlet_temp=kelvin(300), # Room temperature + max_wall_temp=kelvin(920), + ) + + if rp1_cooling.feasible: + print(" ✓ FEASIBLE with RP-1 cooling!") + else: + print(" ✗ NOT FEASIBLE with RP-1 cooling") + + print() + print(f" Max wall temperature: {rp1_cooling.max_wall_temp.to('K').value:.0f} K") + print(f" Coolant outlet temp: {rp1_cooling.coolant_outlet_temp.to('K').value:.0f} K") + print(f" Flow margin: {rp1_cooling.flow_margin:.2f}x") + + # ========================================================================= + # Chamber Pressure Impact Study + # ========================================================================= + + print_header("CHAMBER PRESSURE vs. COOLING FEASIBILITY") + + print(" How does Pc affect cooling feasibility?") + print() + print(f" {'Pc (MPa)':<12} {'Throat q (MW/m²)':<20} {'Coolable?':<12}") + print(" " + "-" * 44) + + for pc_mpa in [5, 10, 15, 20, 25, 30]: + test_inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(500), + chamber_pressure=megapascals(pc_mpa), + mixture_ratio=3.2, + name=f"Test-{pc_mpa}MPa", + ) + test_perf, test_geom = design_engine(test_inputs) + + q_throat = estimate_heat_flux(test_inputs, test_perf, test_geom, location="throat") + q_mw = q_throat.to("W/m^2").value / 1e6 + + cooling = check_cooling_feasibility( + test_inputs, test_perf, test_geom, + coolant="CH4", + coolant_inlet_temp=kelvin(110), + max_wall_temp=kelvin(920), + ) + + status = "✓ Yes" if cooling.feasible else "✗ No" + print(f" {pc_mpa:<12} {q_mw:<20.1f} {status:<12}") + + # ========================================================================= + # Save Results + # ========================================================================= + + print_header("SAVING RESULTS") + + with OutputContext("thermal_analysis", include_timestamp=True) as ctx: + ctx.save_summary({ + "engine": { + "name": inputs.name, + "thrust_kN": inputs.thrust.to("kN").value, + "chamber_pressure_MPa": inputs.chamber_pressure.to("MPa").value, + "chamber_temp_K": inputs.chamber_temp.to("K").value, + }, + "heat_flux": { + "max_MW_m2": max_q, + "max_location": max_location, + }, + "ch4_cooling": { + "feasible": ch4_cooling.feasible, + "max_wall_temp_K": ch4_cooling.max_wall_temp.value, + "flow_margin": ch4_cooling.flow_margin, + }, + "rp1_cooling": { + "feasible": rp1_cooling.feasible, + "max_wall_temp_K": rp1_cooling.max_wall_temp.value, + "flow_margin": rp1_cooling.flow_margin, + }, + }) + + print() + print(f" Results saved to: {ctx.output_dir}") + + print() + print("╔" + "═" * 68 + "╗") + print("║" + " " * 24 + "ANALYSIS COMPLETE" + " " * 27 + "║") + print("╚" + "═" * 68 + "╝") + print() + + +if __name__ == "__main__": + main() diff --git a/rocket/examples/trade_study.py b/rocket/examples/trade_study.py new file mode 100644 index 0000000..4f7d591 --- /dev/null +++ b/rocket/examples/trade_study.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python +"""Parametric trade study example for Rocket. + +This example demonstrates how to run systematic trade studies to explore +the design space and make informed engineering decisions: + +1. Chamber pressure sweeps (most impactful parameter) +2. Multi-parameter grid studies +3. Constraint-based filtering +4. Polars DataFrame export +5. Mixture ratio studies (requires CEA recalculation) + +These tools let you answer questions like: +- "How does Isp change with chamber pressure?" +- "Which designs satisfy my throat size requirement?" +- "What's the tradeoff between performance and size?" +""" + +from rocket import ( + EngineInputs, + OutputContext, + ParametricStudy, + Range, + design_engine, +) +from rocket.units import kilonewtons, megapascals + + +def main() -> None: + """Run the parametric trade study example.""" + print("=" * 70) + print("Rocket - Parametric Trade Study Example") + print("=" * 70) + print() + + # ========================================================================= + # Baseline Engine Design + # ========================================================================= + + print("Baseline Engine: LOX/CH4 Methalox") + print("-" * 70) + + baseline = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(200), + chamber_pressure=megapascals(10), + mixture_ratio=3.2, + name="Methalox-Baseline", + ) + + perf, geom = design_engine(baseline) + print(f" Isp (vac): {perf.isp_vac.value:.1f} s") + print(f" Thrust: {baseline.thrust.to('kN').value:.0f} kN") + print() + + # ========================================================================= + # Study 1: Mixture Ratio Trade (with CEA recalculation) + # ========================================================================= + + print("=" * 70) + print("Study 1: Mixture Ratio Sweep (with CEA thermochemistry)") + print("=" * 70) + print() + print("Question: What mixture ratio maximizes Isp for LOX/CH4?") + print() + print("Note: Each point recalculates combustion properties via NASA CEA") + print() + + mixture_ratios = [2.4, 2.6, 2.8, 3.0, 3.2, 3.4, 3.6, 3.8, 4.0] + mr_results = [] + + print(f" {'MR':<8} {'Tc (K)':<10} {'Isp (vac)':<12} {'Isp (SL)':<12}") + print(" " + "-" * 42) + + for mr in mixture_ratios: + inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(200), + chamber_pressure=megapascals(10), + mixture_ratio=mr, + ) + perf, geom = design_engine(inputs) + mr_results.append({ + "mr": mr, + "Tc": inputs.chamber_temp.to("K").value, + "isp_vac": perf.isp_vac.value, + "isp_sl": perf.isp.value, + }) + print(f" {mr:<8.1f} {inputs.chamber_temp.to('K').value:<10.0f} {perf.isp_vac.value:<12.1f} {perf.isp.value:<12.1f}") + + # Find optimal MR + best = max(mr_results, key=lambda x: x["isp_vac"]) + print() + print(f" → Optimal MR for max Isp: {best['mr']:.1f} (Isp = {best['isp_vac']:.1f} s)") + print(f" At this MR: Tc = {best['Tc']:.0f} K") + print() + + # ========================================================================= + # Study 2: Chamber Pressure Sweep with Range + # ========================================================================= + + print("=" * 70) + print("Study 2: Chamber Pressure Trade") + print("=" * 70) + print() + print("Question: How does performance scale with chamber pressure?") + print() + + # Using Range for continuous sweeps with units + pc_study = ParametricStudy( + compute=design_engine, + base=baseline, + vary={"chamber_pressure": Range(5, 25, n=9, unit="MPa")}, + ) + + pc_results = pc_study.run(progress=True) + + print() + print("Results:") + print(f" {'Pc (MPa)':<10} {'Isp (vac)':<12} {'Throat (cm)':<12} {'c* (m/s)':<10}") + print(" " + "-" * 44) + + df = pc_results.to_dataframe() + for row in df.iter_rows(named=True): + throat_cm = row["throat_diameter"] * 100 + # chamber_pressure is already in MPa (unit specified in Range) + print(f" {row['chamber_pressure']:<10.0f} {row['isp_vac']:<12.1f} {throat_cm:<12.1f} {row['cstar']:<10.0f}") + + # Compute actual insights from data + print() + pc_low = df["chamber_pressure"].min() + pc_high = df["chamber_pressure"].max() + isp_at_low = df.filter(df["chamber_pressure"] == pc_low)["isp_vac"][0] + isp_at_high = df.filter(df["chamber_pressure"] == pc_high)["isp_vac"][0] + dt_at_low = df.filter(df["chamber_pressure"] == pc_low)["throat_diameter"][0] * 100 + dt_at_high = df.filter(df["chamber_pressure"] == pc_high)["throat_diameter"][0] * 100 + + isp_change = isp_at_high - isp_at_low + dt_change = dt_at_high - dt_at_low + + print(f" From {pc_low:.0f} to {pc_high:.0f} MPa:") + print(f" Isp changed by {isp_change:+.1f} s ({100*isp_change/isp_at_low:+.1f}%)") + print(f" Throat diameter changed by {dt_change:+.1f} cm ({100*dt_change/dt_at_low:+.1f}%)") + print() + + # ========================================================================= + # Study 3: Multi-Parameter Grid with Constraints + # ========================================================================= + + print("=" * 70) + print("Study 3: Multi-Parameter Design Space Exploration") + print("=" * 70) + print() + print("Sweeping: Chamber Pressure (5-20 MPa) × Contraction Ratio (3-6)") + print("Constraint: Throat diameter > 8 cm (manufacturability)") + print() + + def throat_constraint(result: tuple) -> bool: + """Filter out designs with throat diameter < 8 cm.""" + _, geometry = result + return geometry.throat_diameter.to("m").value * 100 > 8.0 + + grid_study = ParametricStudy( + compute=design_engine, + base=baseline, + vary={ + "chamber_pressure": Range(5, 20, n=6, unit="MPa"), + "contraction_ratio": [3.0, 4.0, 5.0, 6.0], + }, + constraints=[throat_constraint], + ) + + grid_results = grid_study.run(progress=True) + + print() + n_total = len(grid_results.inputs) + n_feasible = grid_results.constraints_passed.sum() + print(f" Total designs evaluated: {n_total}") + print(f" Feasible designs: {n_feasible} ({100*n_feasible/n_total:.0f}%)") + print() + + # Export to Polars DataFrame for further analysis + df = grid_results.to_dataframe() + + # Filter to feasible designs and show best by Isp + feasible_df = df.filter(df["feasible"]) + best_designs = feasible_df.sort("isp_vac", descending=True).head(5) + + print("Top 5 Feasible Designs by Vacuum Isp:") + print(f" {'Pc (MPa)':<10} {'CR':<8} {'Isp (vac)':<12} {'Dt (cm)':<10}") + print(" " + "-" * 40) + + for row in best_designs.iter_rows(named=True): + # chamber_pressure is already in MPa (unit specified in Range) + dt_cm = row["throat_diameter"] * 100 + print(f" {row['chamber_pressure']:<10.0f} {row['contraction_ratio']:<8.1f} {row['isp_vac']:<12.1f} {dt_cm:<10.1f}") + + print() + + # ========================================================================= + # Save All Results + # ========================================================================= + + print("=" * 70) + print("Saving Results") + print("=" * 70) + print() + + with OutputContext("trade_study_results", include_timestamp=True) as ctx: + # Export DataFrames to CSV + ctx.log("Exporting chamber pressure study...") + pc_results.to_csv(ctx.path("chamber_pressure_sweep.csv")) + + ctx.log("Exporting grid study...") + grid_results.to_csv(ctx.path("grid_study.csv")) + + # Save summary + ctx.save_summary({ + "studies": { + "mixture_ratio_sweep": { + "parameter": "mixture_ratio", + "range": [2.4, 4.0], + "optimal_mr": best["mr"], + "optimal_isp_vac": best["isp_vac"], + "note": "Each point recalculates CEA thermochemistry", + }, + "chamber_pressure_sweep": { + "parameter": "chamber_pressure", + "range_MPa": [5, 25], + "n_points": 9, + }, + "grid_study": { + "parameters": ["chamber_pressure", "contraction_ratio"], + "total_designs": n_total, + "feasible_designs": int(n_feasible), + "constraint": "throat_diameter > 8 cm", + }, + } + }) + + print() + print(f" All results saved to: {ctx.output_dir}") + + print() + print("=" * 70) + print("Trade Study Complete!") + print("=" * 70) + + +if __name__ == "__main__": + main() + diff --git a/rocket/examples/uncertainty_analysis.py b/rocket/examples/uncertainty_analysis.py new file mode 100644 index 0000000..0264d3f --- /dev/null +++ b/rocket/examples/uncertainty_analysis.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python +"""Uncertainty analysis example for Rocket. + +This example demonstrates Monte Carlo uncertainty quantification to understand +how input uncertainties propagate through engine design calculations: + +1. Define probability distributions for uncertain inputs +2. Run Monte Carlo sampling +3. Analyze output statistics and confidence intervals +4. Identify which inputs drive the most uncertainty + +This answers questions like: +- "If my mixture ratio varies ±5%, how much does Isp vary?" +- "What's the 95% confidence interval on my thrust coefficient?" +- "Which input uncertainty should I focus on reducing?" +""" + +from rocket import ( + EngineInputs, + Normal, + OutputContext, + UncertaintyAnalysis, + Uniform, + design_engine, +) +from rocket.units import kilonewtons, megapascals + + +def main() -> None: + """Run the uncertainty analysis example.""" + print("=" * 70) + print("Rocket - Uncertainty Analysis Example") + print("=" * 70) + print() + + # ========================================================================= + # Define Nominal Design Point + # ========================================================================= + + print("Nominal Engine Design: LOX/RP-1 Kerolox") + print("-" * 70) + + nominal = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="RP1", + thrust=kilonewtons(100), + chamber_pressure=megapascals(7), + mixture_ratio=2.7, + name="Kerolox-100", + ) + + perf, geom = design_engine(nominal) + print(f" Nominal Isp (vac): {perf.isp_vac.value:.1f} s") + print(f" Nominal Isp (SL): {perf.isp.value:.1f} s") + print(f" Nominal Thrust Coeff: {perf.thrust_coeff:.3f}") + print(f" Nominal Throat Dia: {geom.throat_diameter.to('m').value*100:.2f} cm") + print() + + # ========================================================================= + # Study 1: Single Source of Uncertainty (Mixture Ratio) + # ========================================================================= + + print("=" * 70) + print("Study 1: Mixture Ratio Uncertainty") + print("=" * 70) + print() + print("The mixture ratio is controlled by the propellant valves.") + print("Assume MR = 2.7 ± 0.1 (Normal distribution, σ = 0.1)") + print() + + mr_uncertainty = UncertaintyAnalysis( + compute=design_engine, + base=nominal, + distributions={ + "mixture_ratio": Normal(mean=2.7, std=0.1), + }, + seed=42, # For reproducibility + ) + + mr_results = mr_uncertainty.run(n_samples=1000, progress=True) + + print() + print("Monte Carlo Results (N=1000):") + print() + + # Get statistics for key metrics + stats = mr_results.statistics() + + print(f" {'Metric':<20} {'Mean':<12} {'Std Dev':<12} {'95% CI':<20}") + print(" " + "-" * 64) + + for metric in ["isp_vac", "isp", "thrust_coeff"]: + mean = stats[metric]["mean"] + std = stats[metric]["std"] + ci_low, ci_high = mr_results.confidence_interval(metric, 0.95) + print(f" {metric:<20} {mean:<12.2f} {std:<12.4f} [{ci_low:.2f}, {ci_high:.2f}]") + + print() + print(" Interpretation:") + print(f" - MR uncertainty of σ=0.1 causes Isp variation of σ≈{stats['isp_vac']['std']:.2f} s") + print(f" - 95% of the time, Isp(vac) will be in [{mr_results.confidence_interval('isp_vac', 0.95)[0]:.1f}, {mr_results.confidence_interval('isp_vac', 0.95)[1]:.1f}] s") + print() + + # ========================================================================= + # Study 2: Multiple Sources of Uncertainty + # ========================================================================= + + print("=" * 70) + print("Study 2: Multiple Uncertainty Sources") + print("=" * 70) + print() + print("Real engines have uncertainty in multiple inputs:") + print(" - Chamber pressure: Pc = 7 MPa ± 0.3 MPa (Normal)") + print(" - Mixture ratio: MR = 2.7 ± 0.1 (Normal)") + print(" - Gamma: γ = 1.24 ± 0.02 (Normal, combustion model uncertainty)") + print() + + multi_uncertainty = UncertaintyAnalysis( + compute=design_engine, + base=nominal, + distributions={ + "chamber_pressure": Normal(mean=7, std=0.3, unit="MPa"), + "mixture_ratio": Normal(mean=2.7, std=0.1), + "gamma": Normal(mean=nominal.gamma, std=0.02), + }, + seed=42, + ) + + multi_results = multi_uncertainty.run(n_samples=2000, progress=True) + + print() + print("Monte Carlo Results (N=2000):") + print() + + stats = multi_results.statistics() + + print(f" {'Metric':<20} {'Mean':<12} {'Std Dev':<12} {'Range (99%)':<20}") + print(" " + "-" * 64) + + for metric in ["isp_vac", "isp", "thrust_coeff", "cstar", "expansion_ratio"]: + mean = stats[metric]["mean"] + std = stats[metric]["std"] + ci_low, ci_high = multi_results.confidence_interval(metric, 0.99) + print(f" {metric:<20} {mean:<12.2f} {std:<12.4f} [{ci_low:.2f}, {ci_high:.2f}]") + + print() + + # ========================================================================= + # Study 3: Uniform Distribution (Manufacturing Tolerances) + # ========================================================================= + + print("=" * 70) + print("Study 3: Manufacturing Tolerance Analysis") + print("=" * 70) + print() + print("Manufacturing tolerances are often uniformly distributed.") + print(" - Contraction ratio: CR = 4.0 ± 0.2 (Uniform)") + print(" - L* (characteristic length): L* = 1.0 ± 0.05 m (Uniform)") + print() + + mfg_uncertainty = UncertaintyAnalysis( + compute=design_engine, + base=nominal, + distributions={ + "contraction_ratio": Uniform(low=3.8, high=4.2), + "lstar": Uniform(low=0.95, high=1.05, unit="m"), + }, + seed=42, + ) + + mfg_results = mfg_uncertainty.run(n_samples=1000, progress=True) + + print() + print("Impact of Manufacturing Tolerances:") + print() + + stats = mfg_results.statistics() + + # These parameters mainly affect geometry, not performance + for metric in ["chamber_diameter", "chamber_length"]: + mean = stats[metric]["mean"] + std = stats[metric]["std"] + cv = 100 * std / mean # Coefficient of variation + print(f" {metric:<20}: mean={mean*100:.2f} cm, CV={cv:.2f}%") + + print() + print(" Note: Geometric tolerances have minimal impact on performance") + print(" but affect fit/interface dimensions.") + print() + + # ========================================================================= + # Export Results + # ========================================================================= + + print("=" * 70) + print("Saving Results") + print("=" * 70) + print() + + with OutputContext("uncertainty_analysis", include_timestamp=True) as ctx: + # Export raw Monte Carlo samples + ctx.log("Exporting MR uncertainty samples...") + mr_results.to_csv(ctx.path("mr_uncertainty_samples.csv")) + + ctx.log("Exporting multi-source uncertainty samples...") + multi_results.to_csv(ctx.path("multi_uncertainty_samples.csv")) + + ctx.log("Exporting manufacturing tolerance samples...") + mfg_results.to_csv(ctx.path("mfg_tolerance_samples.csv")) + + # Save statistical summary + ctx.save_summary({ + "mr_uncertainty": { + "inputs": {"mixture_ratio": {"distribution": "Normal", "mean": 2.7, "std": 0.1}}, + "n_samples": 1000, + "isp_vac": { + "mean": float(mr_results.statistics()["isp_vac"]["mean"]), + "std": float(mr_results.statistics()["isp_vac"]["std"]), + "ci_95": list(mr_results.confidence_interval("isp_vac", 0.95)), + }, + }, + "multi_uncertainty": { + "inputs": { + "chamber_pressure": {"distribution": "Normal", "mean_MPa": 7, "std_MPa": 0.3}, + "mixture_ratio": {"distribution": "Normal", "mean": 2.7, "std": 0.1}, + "gamma": {"distribution": "Normal", "mean": nominal.gamma, "std": 0.02}, + }, + "n_samples": 2000, + }, + }) + + print() + print(f" All results saved to: {ctx.output_dir}") + + print() + print("=" * 70) + print("Uncertainty Analysis Complete!") + print("=" * 70) + print() + + +if __name__ == "__main__": + main() + diff --git a/rocket/isentropic.py b/rocket/isentropic.py new file mode 100644 index 0000000..7840e28 --- /dev/null +++ b/rocket/isentropic.py @@ -0,0 +1,633 @@ +"""Isentropic flow equations for rocket engine analysis. + +This module contains the core thermodynamic calculations for rocket engine +performance analysis. All functions are pure (no side effects) and +numba-accelerated for performance. + +The equations are based on isentropic flow relations for ideal gases, +which form the foundation of rocket propulsion analysis. + +References: + - Sutton & Biblarz, "Rocket Propulsion Elements", 9th Ed. + - Huzel & Huang, "Modern Engineering for Design of Liquid-Propellant Rocket Engines" + - Hill & Peterson, "Mechanics and Thermodynamics of Propulsion", 2nd Ed. +""" + +import math + +import numba +import numpy as np +from numpy.typing import NDArray + +# ============================================================================= +# Constants +# ============================================================================= + +# Standard gravity acceleration +G0_SI: float = 9.80665 # m/s^2 +G0_IMP: float = 32.174 # ft/s^2 + +# Universal gas constant +R_UNIVERSAL_SI: float = 8314.46 # J/(kmol·K) +R_UNIVERSAL_IMP: float = 1545.35 # ft·lbf/(lbmol·R) + + +# ============================================================================= +# Core Isentropic Flow Functions (Numba Accelerated) +# ============================================================================= + + +@numba.njit(cache=True) +def specific_gas_constant(molecular_weight: float) -> float: + """Calculate specific gas constant from molecular weight. + + Args: + molecular_weight: Molecular weight of the gas [kg/kmol] + + Returns: + Specific gas constant R [J/(kg·K)] + """ + return R_UNIVERSAL_SI / molecular_weight + + +@numba.njit(cache=True) +def characteristic_velocity(gamma: float, R: float, Tc: float) -> float: + """Calculate characteristic velocity (c*). + + c* is a measure of the energy available from the combustion process, + independent of nozzle performance. + + Args: + gamma: Ratio of specific heats (Cp/Cv) [-] + R: Specific gas constant [J/(kg·K)] + Tc: Chamber (stagnation) temperature [K] + + Returns: + Characteristic velocity [m/s] + """ + # c* = sqrt(gamma * R * Tc) / (gamma * sqrt((2/(gamma+1))^((gamma+1)/(gamma-1)))) + term1 = math.sqrt(gamma * R * Tc) + term2 = gamma * math.sqrt((2.0 / (gamma + 1.0)) ** ((gamma + 1.0) / (gamma - 1.0))) + return term1 / term2 + + +@numba.njit(cache=True) +def thrust_coefficient( + gamma: float, pe_pc: float, pa_pc: float, expansion_ratio: float +) -> float: + """Calculate thrust coefficient (Cf). + + Cf characterizes the nozzle's ability to convert thermal energy + into directed kinetic energy. + + Args: + gamma: Ratio of specific heats [-] + pe_pc: Exit pressure / chamber pressure ratio [-] + pa_pc: Ambient pressure / chamber pressure ratio [-] + expansion_ratio: Nozzle exit area / throat area (Ae/At) [-] + + Returns: + Thrust coefficient [-] + """ + # Momentum thrust term + gm1 = gamma - 1.0 + gp1 = gamma + 1.0 + exponent = gm1 / gamma + + term1 = 2.0 * gamma**2 / gm1 + term2 = (2.0 / gp1) ** (gp1 / gm1) + term3 = 1.0 - pe_pc**exponent + + Cf_momentum = math.sqrt(term1 * term2 * term3) + + # Pressure thrust term + Cf_pressure = (pe_pc - pa_pc) * expansion_ratio + + return Cf_momentum + Cf_pressure + + +@numba.njit(cache=True) +def thrust_coefficient_vacuum(gamma: float, pe_pc: float, expansion_ratio: float) -> float: + """Calculate vacuum thrust coefficient (Cf_vac). + + Args: + gamma: Ratio of specific heats [-] + pe_pc: Exit pressure / chamber pressure ratio [-] + expansion_ratio: Nozzle exit area / throat area (Ae/At) [-] + + Returns: + Vacuum thrust coefficient [-] + """ + return thrust_coefficient(gamma, pe_pc, 0.0, expansion_ratio) + + +@numba.njit(cache=True) +def specific_impulse(cstar: float, Cf: float, g0: float = G0_SI) -> float: + """Calculate specific impulse (Isp). + + Isp is the key performance metric for rocket engines, representing + the thrust produced per unit weight flow rate of propellant. + + Args: + cstar: Characteristic velocity [m/s] + Cf: Thrust coefficient [-] + g0: Standard gravity [m/s^2], default 9.80665 + + Returns: + Specific impulse [s] + """ + return cstar * Cf / g0 + + +@numba.njit(cache=True) +def exhaust_velocity(gamma: float, R: float, Tc: float, pe_pc: float) -> float: + """Calculate exhaust velocity (ue). + + This is the velocity of the exhaust gases at the nozzle exit + for isentropic expansion. + + Args: + gamma: Ratio of specific heats [-] + R: Specific gas constant [J/(kg·K)] + Tc: Chamber temperature [K] + pe_pc: Exit pressure / chamber pressure ratio [-] + + Returns: + Exhaust velocity [m/s] + """ + gm1 = gamma - 1.0 + exponent = gm1 / gamma + + term1 = 2.0 * gamma * R * Tc / gm1 + term2 = 1.0 - pe_pc**exponent + + return math.sqrt(term1 * term2) + + +@numba.njit(cache=True) +def mass_flow_rate(thrust: float, Isp: float, g0: float = G0_SI) -> float: + """Calculate total mass flow rate from thrust and Isp. + + Args: + thrust: Engine thrust [N] + Isp: Specific impulse [s] + g0: Standard gravity [m/s^2] + + Returns: + Mass flow rate [kg/s] + """ + return thrust / (Isp * g0) + + +@numba.njit(cache=True) +def mass_flow_rate_from_throat( + pc: float, At: float, gamma: float, R: float, Tc: float +) -> float: + """Calculate mass flow rate from throat conditions. + + Uses the choked flow condition at the throat. + + Args: + pc: Chamber pressure [Pa] + At: Throat area [m^2] + gamma: Ratio of specific heats [-] + R: Specific gas constant [J/(kg·K)] + Tc: Chamber temperature [K] + + Returns: + Mass flow rate [kg/s] + """ + gp1 = gamma + 1.0 + gm1 = gamma - 1.0 + + term1 = pc * At + term2 = gamma / (R * Tc) + term3 = (2.0 / gp1) ** (gp1 / gm1) + + return term1 * math.sqrt(term2 * term3) + + +@numba.njit(cache=True) +def throat_area(mdot: float, cstar: float, pc: float) -> float: + """Calculate required throat area. + + Args: + mdot: Mass flow rate [kg/s] + cstar: Characteristic velocity [m/s] + pc: Chamber pressure [Pa] + + Returns: + Throat area [m^2] + """ + return mdot * cstar / pc + + +@numba.njit(cache=True) +def area_from_diameter(diameter: float) -> float: + """Calculate circular area from diameter. + + Args: + diameter: Diameter [m] + + Returns: + Area [m^2] + """ + return math.pi * (diameter / 2.0) ** 2 + + +@numba.njit(cache=True) +def diameter_from_area(area: float) -> float: + """Calculate diameter from circular area. + + Args: + area: Area [m^2] + + Returns: + Diameter [m] + """ + return 2.0 * math.sqrt(area / math.pi) + + +# ============================================================================= +# Mach Number Relations +# ============================================================================= + + +@numba.njit(cache=True) +def mach_from_pressure_ratio(pc_p: float, gamma: float) -> float: + """Calculate Mach number from stagnation-to-static pressure ratio. + + Args: + pc_p: Chamber (stagnation) pressure / local static pressure [-] + gamma: Ratio of specific heats [-] + + Returns: + Mach number [-] + """ + gm1 = gamma - 1.0 + exponent = gm1 / gamma + + return math.sqrt((2.0 / gm1) * (pc_p**exponent - 1.0)) + + +@numba.njit(cache=True) +def pressure_ratio_from_mach(M: float, gamma: float) -> float: + """Calculate stagnation-to-static pressure ratio from Mach number. + + Args: + M: Mach number [-] + gamma: Ratio of specific heats [-] + + Returns: + pc/p ratio [-] + """ + gm1 = gamma - 1.0 + exponent = gamma / gm1 + + return (1.0 + gm1 / 2.0 * M**2) ** exponent + + +@numba.njit(cache=True) +def temperature_ratio_from_mach(M: float, gamma: float) -> float: + """Calculate stagnation-to-static temperature ratio from Mach number. + + Args: + M: Mach number [-] + gamma: Ratio of specific heats [-] + + Returns: + Tc/T ratio [-] + """ + return 1.0 + (gamma - 1.0) / 2.0 * M**2 + + +@numba.njit(cache=True) +def area_ratio_from_mach(M: float, gamma: float) -> float: + """Calculate area ratio (A/A*) from Mach number. + + A* is the critical (sonic) area, i.e., the throat area for choked flow. + + Args: + M: Mach number [-] + gamma: Ratio of specific heats [-] + + Returns: + Area ratio A/A* [-] + """ + if M <= 0.0: + return float("inf") + + gm1 = gamma - 1.0 + gp1 = gamma + 1.0 + exponent = gp1 / (2.0 * gm1) + + term1 = 1.0 / M + term2 = (2.0 / gp1) * (1.0 + gm1 / 2.0 * M**2) + + return term1 * term2**exponent + + +@numba.njit(cache=True) +def mach_from_area_ratio_supersonic(area_ratio: float, gamma: float) -> float: + """Calculate supersonic Mach number from area ratio using Newton-Raphson. + + For a given A/A* > 1, there are two solutions: subsonic and supersonic. + This function returns the supersonic solution (M > 1). + + Args: + area_ratio: Area ratio A/A* [-], must be >= 1 + gamma: Ratio of specific heats [-] + + Returns: + Supersonic Mach number [-] + """ + if area_ratio < 1.0: + return 1.0 # At throat + + # Initial guess for supersonic flow + M = 2.0 + area_ratio / 5.0 + + # Newton-Raphson iteration + for _ in range(50): + f = area_ratio_from_mach(M, gamma) - area_ratio + + # Numerical derivative + dM = 1e-8 + df = (area_ratio_from_mach(M + dM, gamma) - area_ratio_from_mach(M - dM, gamma)) / ( + 2.0 * dM + ) + + if abs(df) < 1e-12: + break + + M_new = M - f / df + + if M_new < 1.0: + M_new = 1.0 + 0.1 + + if abs(M_new - M) < 1e-10: + break + + M = M_new + + return M + + +@numba.njit(cache=True) +def mach_from_area_ratio_subsonic(area_ratio: float, gamma: float) -> float: + """Calculate subsonic Mach number from area ratio using Newton-Raphson. + + For a given A/A* > 1, there are two solutions: subsonic and supersonic. + This function returns the subsonic solution (M < 1). + + Args: + area_ratio: Area ratio A/A* [-], must be >= 1 + gamma: Ratio of specific heats [-] + + Returns: + Subsonic Mach number [-] + """ + if area_ratio < 1.0: + return 1.0 + + # Initial guess for subsonic flow + M = 0.5 + + # Newton-Raphson iteration + for _ in range(50): + f = area_ratio_from_mach(M, gamma) - area_ratio + + # Numerical derivative + dM = 1e-8 + df = (area_ratio_from_mach(M + dM, gamma) - area_ratio_from_mach(M - dM, gamma)) / ( + 2.0 * dM + ) + + if abs(df) < 1e-12: + break + + M_new = M - f / df + + if M_new > 1.0: + M_new = 0.99 + if M_new < 0.0: + M_new = 0.01 + + if abs(M_new - M) < 1e-10: + break + + M = M_new + + return M + + +# ============================================================================= +# Throat and Exit Conditions +# ============================================================================= + + +@numba.njit(cache=True) +def throat_temperature(Tc: float, gamma: float) -> float: + """Calculate throat (critical) temperature. + + Args: + Tc: Chamber temperature [K] + gamma: Ratio of specific heats [-] + + Returns: + Throat temperature [K] + """ + return Tc / (1.0 + (gamma - 1.0) / 2.0) + + +@numba.njit(cache=True) +def throat_pressure(pc: float, gamma: float) -> float: + """Calculate throat (critical) pressure. + + Args: + pc: Chamber pressure [Pa] + gamma: Ratio of specific heats [-] + + Returns: + Throat pressure [Pa] + """ + exponent = gamma / (gamma - 1.0) + return pc * (2.0 / (gamma + 1.0)) ** exponent + + +@numba.njit(cache=True) +def expansion_ratio_from_pressure_ratio(pc_pe: float, gamma: float) -> float: + """Calculate nozzle expansion ratio from chamber-to-exit pressure ratio. + + Args: + pc_pe: Chamber pressure / exit pressure [-] + gamma: Ratio of specific heats [-] + + Returns: + Expansion ratio (Ae/At) [-] + """ + # First get exit Mach number + Me = mach_from_pressure_ratio(pc_pe, gamma) + # Then get area ratio + return area_ratio_from_mach(Me, gamma) + + +@numba.njit(cache=True) +def exit_pressure_from_expansion_ratio( + expansion_ratio: float, pc: float, gamma: float +) -> float: + """Calculate exit pressure from expansion ratio. + + Args: + expansion_ratio: Nozzle expansion ratio (Ae/At) [-] + pc: Chamber pressure [Pa] + gamma: Ratio of specific heats [-] + + Returns: + Exit pressure [Pa] + """ + # Get exit Mach number (supersonic solution) + Me = mach_from_area_ratio_supersonic(expansion_ratio, gamma) + # Get pressure ratio + pc_pe = pressure_ratio_from_mach(Me, gamma) + return pc / pc_pe + + +# ============================================================================= +# Chamber Geometry +# ============================================================================= + + +@numba.njit(cache=True) +def chamber_volume(lstar: float, At: float) -> float: + """Calculate chamber volume from L* and throat area. + + L* (characteristic length) is defined as the chamber volume divided + by the throat area: L* = Vc / At + + Args: + lstar: Characteristic length [m] + At: Throat area [m^2] + + Returns: + Chamber volume [m^3] + """ + return lstar * At + + +@numba.njit(cache=True) +def cylindrical_chamber_length( + Vc: float, Ac: float, Rc: float, Rt: float, contraction_angle: float +) -> float: + """Calculate length of cylindrical section of chamber. + + Accounts for the converging section geometry. + + Args: + Vc: Chamber volume [m^3] + Ac: Chamber cross-sectional area [m^2] + Rc: Chamber radius [m] + Rt: Throat radius [m] + contraction_angle: Convergence half-angle [radians] + + Returns: + Cylindrical section length [m] + """ + # Volume of converging cone section + cone_length = (Rc - Rt) / math.tan(contraction_angle) + # Approximate cylindrical length (subtract converging section contribution) + return Vc / Ac - 0.5 * cone_length + + +@numba.njit(cache=True) +def conical_nozzle_length(Rt: float, Re: float, half_angle: float) -> float: + """Calculate length of a conical nozzle. + + Args: + Rt: Throat radius [m] + Re: Exit radius [m] + half_angle: Nozzle half-angle [radians] + + Returns: + Nozzle length [m] + """ + return (Re - Rt) / math.tan(half_angle) + + +@numba.njit(cache=True) +def bell_nozzle_length( + Rt: float, Re: float, bell_fraction: float = 0.8, reference_angle: float = 0.2618 +) -> float: + """Calculate length of a bell (parabolic) nozzle. + + Bell nozzles are typically specified as a percentage of the length + of a 15-degree conical nozzle with the same expansion ratio. + + Args: + Rt: Throat radius [m] + Re: Exit radius [m] + bell_fraction: Length as fraction of 15° cone (e.g., 0.8 for 80% bell) + reference_angle: Reference cone half-angle [radians], default 15° = 0.2618 + + Returns: + Nozzle length [m] + """ + conical_length = conical_nozzle_length(Rt, Re, reference_angle) + return conical_length * bell_fraction + + +# ============================================================================= +# Vectorized Functions for Parametric Studies +# ============================================================================= + + +@numba.njit(cache=True, parallel=True) +def thrust_coefficient_sweep( + gamma: float, + pe_pc: float, + pa_pc_array: NDArray[np.float64], + expansion_ratio: float, +) -> NDArray[np.float64]: + """Calculate thrust coefficient for array of ambient pressures. + + Useful for altitude performance analysis. + + Args: + gamma: Ratio of specific heats [-] + pe_pc: Exit pressure / chamber pressure ratio [-] + pa_pc_array: Array of ambient pressure / chamber pressure ratios [-] + expansion_ratio: Nozzle expansion ratio [-] + + Returns: + Array of thrust coefficients [-] + """ + n = len(pa_pc_array) + result = np.empty(n, dtype=np.float64) + + for i in numba.prange(n): + result[i] = thrust_coefficient(gamma, pe_pc, pa_pc_array[i], expansion_ratio) + + return result + + +@numba.njit(cache=True) +def area_ratio_sweep( + mach_array: NDArray[np.float64], gamma: float +) -> NDArray[np.float64]: + """Calculate area ratios for array of Mach numbers. + + Args: + mach_array: Array of Mach numbers [-] + gamma: Ratio of specific heats [-] + + Returns: + Array of area ratios [-] + """ + n = len(mach_array) + result = np.empty(n, dtype=np.float64) + + for i in range(n): + result[i] = area_ratio_from_mach(mach_array[i], gamma) + + return result + diff --git a/rocket/nozzle.py b/rocket/nozzle.py new file mode 100644 index 0000000..8df18bc --- /dev/null +++ b/rocket/nozzle.py @@ -0,0 +1,427 @@ +"""Nozzle contour generation for rocket engines. + +This module provides functions to generate nozzle contours for various +nozzle types including: +- Conical nozzles (simple 15° half-angle) +- Rao parabolic bell nozzles (optimized for performance) + +The contours can be exported to CSV for CAD import. + +References: + - Rao, G.V.R., "Exhaust Nozzle Contour for Optimum Thrust", + Jet Propulsion, Vol. 28, No. 6, 1958 + - Huzel & Huang, "Modern Engineering for Design of Liquid-Propellant + Rocket Engines", Chapter 4 +""" + +import math +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +from beartype import beartype +from numpy.typing import NDArray + +from rocket.engine import EngineGeometry, EngineInputs +from rocket.units import Quantity + +# ============================================================================= +# Nozzle Contour Data Structure +# ============================================================================= + + +@beartype +@dataclass(frozen=True) +class NozzleContour: + """Nozzle contour defined by axial and radial coordinates. + + The contour represents the inner wall of the nozzle from the chamber + through the throat to the exit. Coordinates are in meters. + + Attributes: + x: Axial positions [m], with x=0 at throat + y: Radial positions [m] (radius, not diameter) + contour_type: Type of contour ("rao_bell", "conical", etc.) + """ + + x: NDArray[np.float64] + y: NDArray[np.float64] + contour_type: str + + def __post_init__(self) -> None: + """Validate contour data.""" + if len(self.x) != len(self.y): + raise ValueError( + f"x and y arrays must have same length, got {len(self.x)} and {len(self.y)}" + ) + if len(self.x) < 2: + raise ValueError("Contour must have at least 2 points") + + def to_csv(self, path: str | Path, include_header: bool = True) -> None: + """Export contour to CSV file for CAD import. + + Args: + path: Output file path + include_header: Whether to include column headers + """ + path = Path(path) + with path.open("w") as f: + if include_header: + f.write("x_m,y_m,x_mm,y_mm\n") + for xi, yi in zip(self.x, self.y, strict=True): + f.write(f"{xi:.8f},{yi:.8f},{xi * 1000:.6f},{yi * 1000:.6f}\n") + + def to_arrays_mm(self) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + """Return contour coordinates in millimeters. + + Returns: + Tuple of (x_mm, y_mm) arrays + """ + return self.x * 1000, self.y * 1000 + + @property + def length(self) -> float: + """Total axial length of the contour [m].""" + return float(self.x[-1] - self.x[0]) + + @property + def throat_radius(self) -> float: + """Radius at throat (minimum y value) [m].""" + return float(np.min(self.y)) + + @property + def exit_radius(self) -> float: + """Radius at exit [m].""" + return float(self.y[-1]) + + @property + def inlet_radius(self) -> float: + """Radius at inlet [m].""" + return float(self.y[0]) + + +# ============================================================================= +# Rao Bell Nozzle Contour +# ============================================================================= + + +@beartype +def rao_bell_contour( + throat_radius: Quantity, + exit_radius: Quantity, + expansion_ratio: float, + bell_fraction: float = 0.8, + num_points: int = 100, +) -> NozzleContour: + """Generate a Rao parabolic bell nozzle contour. + + The Rao bell nozzle uses a parabolic approximation to the ideal + thrust-optimized contour. It consists of: + 1. A circular arc leaving the throat (radius = 0.382 * Rt) + 2. A parabolic section to the exit + + The bell_fraction parameter specifies the length as a fraction of + an equivalent 15° conical nozzle. + + Args: + throat_radius: Throat radius [length] + exit_radius: Exit radius [length] + expansion_ratio: Area ratio Ae/At [-] + bell_fraction: Length as fraction of 15° cone (typically 0.8) + num_points: Number of points in the contour + + Returns: + NozzleContour with the bell nozzle shape + """ + # Convert to SI + Rt = throat_radius.to("m").value + Re = exit_radius.to("m").value + + # Rao parameters (from empirical correlations) + # Initial angle leaving throat depends on expansion ratio + # Final angle at exit also depends on expansion ratio + # These are approximations from Rao's paper and Huzel & Huang + + # Throat circular arc radius + Rn = 0.382 * Rt # Radius of curvature leaving throat + + # Reference conical nozzle length (15° half-angle) + Lc_15 = (Re - Rt) / math.tan(math.radians(15)) + + # Bell nozzle length + Ln = Lc_15 * bell_fraction + + # Initial and final angles (empirical fits from Rao curves) + # These depend on expansion ratio and bell fraction + theta_n = _rao_initial_angle(expansion_ratio, bell_fraction) + theta_e = _rao_exit_angle(expansion_ratio, bell_fraction) + + # Convert to radians + theta_n_rad = math.radians(theta_n) + theta_e_rad = math.radians(theta_e) + + # Generate contour points + + # Point N: End of throat circular arc + # x_N is relative to throat center + x_N = Rn * math.sin(theta_n_rad) + y_N = Rt + Rn * (1 - math.cos(theta_n_rad)) + + # Point E: Exit + x_E = Ln + y_E = Re + + # Generate throat arc (from throat to point N) + n_arc = num_points // 4 + theta_arc = np.linspace(0, theta_n_rad, n_arc) + x_arc = Rn * np.sin(theta_arc) + y_arc = Rt + Rn * (1 - np.cos(theta_arc)) + + # Generate parabolic section (from N to E) + # Using quadratic Bezier curve approximation + n_parabola = num_points - n_arc + + # Control point for quadratic Bezier + # The tangent at N has slope tan(theta_n) + # The tangent at E has slope tan(theta_e) + # Find intersection of these tangents + + m_N = math.tan(theta_n_rad) + m_E = math.tan(theta_e_rad) + + # Line from N: y - y_N = m_N * (x - x_N) + # Line from E: y - y_E = m_E * (x - x_E) + # Solve for intersection (control point Q) + + if abs(m_N - m_E) > 1e-10: + x_Q = (y_E - y_N + m_N * x_N - m_E * x_E) / (m_N - m_E) + y_Q = y_N + m_N * (x_Q - x_N) + else: + # Parallel tangents (shouldn't happen for reasonable parameters) + x_Q = (x_N + x_E) / 2 + y_Q = (y_N + y_E) / 2 + + # Generate Bezier curve points + t = np.linspace(0, 1, n_parabola) + x_parabola = (1 - t) ** 2 * x_N + 2 * (1 - t) * t * x_Q + t**2 * x_E + y_parabola = (1 - t) ** 2 * y_N + 2 * (1 - t) * t * y_Q + t**2 * y_E + + # Combine arc and parabola (skip first point of parabola to avoid duplicate) + x = np.concatenate([x_arc, x_parabola[1:]]) + y = np.concatenate([y_arc, y_parabola[1:]]) + + return NozzleContour(x=x, y=y, contour_type="rao_bell") + + +def _rao_initial_angle(expansion_ratio: float, bell_fraction: float) -> float: + """Calculate initial expansion angle for Rao bell nozzle. + + Empirical correlation based on Rao's curves. + + Args: + expansion_ratio: Area ratio Ae/At + bell_fraction: Bell length fraction (0.6-1.0) + + Returns: + Initial angle in degrees + """ + # Empirical fit (approximation of Rao curves) + # For 80% bell: ~30-35° for low eps, ~20-25° for high eps + eps = expansion_ratio + + if bell_fraction <= 0.6: + theta = 38 - 2.5 * math.log10(eps) + elif bell_fraction <= 0.8: + theta = 33 - 3.5 * math.log10(eps) + else: + theta = 28 - 4.0 * math.log10(eps) + + # Clamp to reasonable values + return max(15.0, min(45.0, theta)) + + +def _rao_exit_angle(expansion_ratio: float, bell_fraction: float) -> float: + """Calculate exit angle for Rao bell nozzle. + + Empirical correlation based on Rao's curves. + + Args: + expansion_ratio: Area ratio Ae/At + bell_fraction: Bell length fraction (0.6-1.0) + + Returns: + Exit angle in degrees + """ + # Empirical fit + # Exit angle is typically 6-12° for most practical nozzles + eps = expansion_ratio + + if bell_fraction <= 0.6: + theta = 14 - 1.5 * math.log10(eps) + elif bell_fraction <= 0.8: + theta = 11 - 2.0 * math.log10(eps) + else: + theta = 8 - 2.5 * math.log10(eps) + + # Clamp to reasonable values + return max(4.0, min(15.0, theta)) + + +# ============================================================================= +# Conical Nozzle Contour +# ============================================================================= + + +@beartype +def conical_contour( + throat_radius: Quantity, + exit_radius: Quantity, + half_angle: float = 15.0, + num_points: int = 100, +) -> NozzleContour: + """Generate a conical nozzle contour. + + A simple conical nozzle with constant divergence angle. + The standard half-angle is 15°. + + Args: + throat_radius: Throat radius [length] + exit_radius: Exit radius [length] + half_angle: Nozzle half-angle in degrees (default 15°) + num_points: Number of points in the contour + + Returns: + NozzleContour with the conical shape + """ + Rt = throat_radius.to("m").value + Re = exit_radius.to("m").value + + # Nozzle length + Ln = (Re - Rt) / math.tan(math.radians(half_angle)) + + # Generate linear contour + x = np.linspace(0, Ln, num_points) + y = Rt + x * math.tan(math.radians(half_angle)) + + return NozzleContour(x=x, y=y, contour_type="conical") + + +# ============================================================================= +# Full Chamber Contour (Chamber + Convergent + Nozzle) +# ============================================================================= + + +@beartype +def full_chamber_contour( + inputs: EngineInputs, + geometry: EngineGeometry, + nozzle_contour: NozzleContour, + num_chamber_points: int = 50, + num_convergent_points: int = 30, +) -> NozzleContour: + """Generate complete chamber contour including chamber and convergent section. + + Combines: + 1. Cylindrical chamber section + 2. Convergent section (circular arc transition + conical) + 3. Throat region with circular arc + 4. Divergent nozzle section + + Args: + inputs: Engine inputs (for contraction angle) + geometry: Computed geometry + nozzle_contour: Pre-computed divergent nozzle contour + num_chamber_points: Points for cylindrical section + num_convergent_points: Points for convergent section + + Returns: + Complete chamber contour from inlet to exit + """ + # Extract dimensions + Rc = geometry.chamber_diameter.to("m").value / 2 + Rt = geometry.throat_diameter.to("m").value / 2 + Lcyl = geometry.chamber_length.to("m").value + contraction_angle = math.radians(inputs.contraction_angle) + + # Upstream radius of curvature (typically 1.5 * Rt for smooth transition) + R1 = 1.5 * Rt + + # Convergent section geometry + # The convergent has a circular arc transition from chamber to conical section + + # Calculate convergent section + # Point where circular arc meets conical section + theta_c = contraction_angle + x_tan = R1 * math.sin(theta_c) # axial distance from throat to tangent point + y_tan = Rt + R1 * (1 - math.cos(theta_c)) # radius at tangent point + + # Length of conical section (from tangent point to chamber) + L_cone = (Rc - y_tan) / math.tan(theta_c) if Rc > y_tan else 0 + + # Total convergent length + L_conv = x_tan + L_cone + + # Generate chamber section (negative x, before throat) + x_chamber = np.linspace(-(Lcyl + L_conv), -L_conv, num_chamber_points) + y_chamber = np.full_like(x_chamber, Rc) + + # Generate convergent conical section + if L_cone > 0: + n_cone = num_convergent_points // 2 + x_cone = np.linspace(-L_conv, -(x_tan), n_cone) + y_cone = Rc - (x_cone + L_conv) * math.tan(theta_c) + else: + x_cone = np.array([]) + y_cone = np.array([]) + + # Generate convergent circular arc (transition to throat) + # Arc center is at (0, Rt + R1), tangent to throat at bottom + # Arc goes from tangent point with cone (angle = theta_c) to throat (angle = 0) + n_arc = num_convergent_points - len(x_cone) + theta_range = np.linspace(theta_c, 0, n_arc) + x_arc = -R1 * np.sin(theta_range) # Negative (upstream of throat) + y_arc = Rt + R1 * (1 - np.cos(theta_range)) # From y_tan down to Rt + + # Shift nozzle contour (it starts at x=0 at throat) + x_nozzle = nozzle_contour.x + y_nozzle = nozzle_contour.y + + # Combine all sections + x_all = np.concatenate([x_chamber, x_cone, x_arc[:-1], x_nozzle]) + y_all = np.concatenate([y_chamber, y_cone, y_arc[:-1], y_nozzle]) + + return NozzleContour(x=x_all, y=y_all, contour_type=f"full_{nozzle_contour.contour_type}") + + +# ============================================================================= +# Convenience Functions +# ============================================================================= + + +@beartype +def generate_nozzle_from_geometry( + geometry: EngineGeometry, + bell_fraction: float = 0.8, + num_points: int = 100, +) -> NozzleContour: + """Generate a Rao bell nozzle contour from engine geometry. + + Convenience function that extracts the necessary parameters from + EngineGeometry. + + Args: + geometry: Computed engine geometry + bell_fraction: Bell length fraction (default 0.8) + num_points: Number of contour points + + Returns: + NozzleContour for the divergent section + """ + return rao_bell_contour( + throat_radius=geometry.throat_diameter / 2, + exit_radius=geometry.exit_diameter / 2, + expansion_ratio=geometry.expansion_ratio, + bell_fraction=bell_fraction, + num_points=num_points, + ) + diff --git a/rocket/output.py b/rocket/output.py new file mode 100644 index 0000000..4ed768e --- /dev/null +++ b/rocket/output.py @@ -0,0 +1,271 @@ +"""Output management for Rocket. + +This module provides utilities for organizing outputs from rocket design +analyses into structured directories with consistent naming. + +Example: + >>> from rocket.output import OutputContext + >>> with OutputContext("my_engine_study") as ctx: + ... fig.savefig(ctx.path("engine_dashboard.png")) + ... contour.to_csv(ctx.path("nozzle_contour.csv")) + ... ctx.save_summary({"isp": 300, "thrust": 50000}) +""" + +import json +import shutil +from datetime import datetime +from pathlib import Path +from typing import Any + +from beartype import beartype + + +@beartype +class OutputContext: + """Context manager for organizing analysis outputs. + + Creates a structured output directory with: + - Timestamp-based naming for version control + - Subdirectories for different output types + - Automatic metadata logging + - Summary JSON export + + Directory structure: + {base_dir}/{name}_{timestamp}/ + ├── plots/ # Visualization outputs + ├── data/ # CSV, contour exports + ├── reports/ # Text summaries + └── metadata.json # Run information + + Attributes: + name: Study/analysis name + output_dir: Path to the output directory + timestamp: Creation timestamp + """ + + def __init__( + self, + name: str, + base_dir: str | Path | None = None, + include_timestamp: bool = True, + create_subdirs: bool = True, + ) -> None: + """Initialize output context. + + Args: + name: Name for this analysis/study (used in directory name) + base_dir: Base directory for outputs. Defaults to ./outputs/ + include_timestamp: Whether to include timestamp in directory name + create_subdirs: Whether to create plots/, data/, reports/ subdirs + """ + self.name = name + self.timestamp = datetime.now() + self._include_timestamp = include_timestamp + self._create_subdirs = create_subdirs + self._metadata: dict[str, Any] = { + "name": name, + "created": self.timestamp.isoformat(), + "files": [], + } + + # Determine base directory + if base_dir is None: + base_dir = Path.cwd() / "outputs" + self.base_dir = Path(base_dir) + + # Create output directory name + if include_timestamp: + timestamp_str = self.timestamp.strftime("%Y%m%d_%H%M%S") + dir_name = f"{name}_{timestamp_str}" + else: + dir_name = name + + self.output_dir = self.base_dir / dir_name + self._entered = False + + def __enter__(self) -> "OutputContext": + """Enter the context and create directories.""" + self._entered = True + + # Create main output directory + self.output_dir.mkdir(parents=True, exist_ok=True) + + # Create subdirectories + if self._create_subdirs: + (self.output_dir / "plots").mkdir(exist_ok=True) + (self.output_dir / "data").mkdir(exist_ok=True) + (self.output_dir / "reports").mkdir(exist_ok=True) + + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Exit context and write metadata.""" + self._metadata["completed"] = datetime.now().isoformat() + self._metadata["success"] = exc_type is None + + # Write metadata + metadata_path = self.output_dir / "metadata.json" + with open(metadata_path, "w") as f: + json.dump(self._metadata, f, indent=2, default=str) + + self._entered = False + + def path(self, filename: str, subdir: str | None = None) -> Path: + """Get full path for an output file. + + Automatically routes files to appropriate subdirectories based on extension. + + Args: + filename: Name of the output file + subdir: Optional subdirectory override. If None, auto-routes: + - .png, .pdf, .svg → plots/ + - .csv, .json → data/ + - .txt, .md → reports/ + + Returns: + Full path to the output file + """ + if not self._entered: + raise RuntimeError("OutputContext must be used as a context manager") + + # Auto-route based on extension if subdir not specified + if subdir is None and self._create_subdirs: + ext = Path(filename).suffix.lower() + if ext in {".png", ".pdf", ".svg", ".jpg", ".jpeg"}: + subdir = "plots" + elif ext in {".csv", ".json", ".npy", ".npz"}: + subdir = "data" + elif ext in {".txt", ".md", ".rst", ".log"}: + subdir = "reports" + + if subdir: + full_path = self.output_dir / subdir / filename + else: + full_path = self.output_dir / filename + + # Track file for metadata + self._metadata["files"].append(str(full_path.relative_to(self.output_dir))) + + return full_path + + def plots_dir(self) -> Path: + """Get path to plots subdirectory.""" + return self.output_dir / "plots" + + def data_dir(self) -> Path: + """Get path to data subdirectory.""" + return self.output_dir / "data" + + def reports_dir(self) -> Path: + """Get path to reports subdirectory.""" + return self.output_dir / "reports" + + def save_summary(self, summary: dict[str, Any], filename: str = "summary.json") -> Path: + """Save a summary dictionary as JSON. + + Args: + summary: Dictionary of summary data + filename: Output filename + + Returns: + Path to saved file + """ + path = self.path(filename, subdir="data") + with open(path, "w") as f: + json.dump(summary, f, indent=2, default=str) + return path + + def save_text(self, text: str, filename: str = "report.txt") -> Path: + """Save text to a report file. + + Args: + text: Text content to save + filename: Output filename + + Returns: + Path to saved file + """ + path = self.path(filename, subdir="reports") + with open(path, "w") as f: + f.write(text) + return path + + def add_metadata(self, key: str, value: Any) -> None: + """Add custom metadata. + + Args: + key: Metadata key + value: Metadata value (must be JSON-serializable) + """ + self._metadata[key] = value + + def log(self, message: str) -> None: + """Log a message to both console and log file. + + Args: + message: Message to log + """ + timestamp = datetime.now().strftime("%H:%M:%S") + formatted = f"[{timestamp}] {message}" + print(formatted) + + log_path = self.output_dir / "run.log" + with open(log_path, "a") as f: + f.write(formatted + "\n") + + +@beartype +def get_default_output_dir() -> Path: + """Get the default output directory. + + Returns ./outputs/ in the current working directory. + + Returns: + Path to default output directory + """ + return Path.cwd() / "outputs" + + +@beartype +def list_outputs(base_dir: str | Path | None = None) -> list[Path]: + """List all output directories. + + Args: + base_dir: Base directory to search. Defaults to ./outputs/ + + Returns: + List of output directory paths, sorted by modification time (newest first) + """ + if base_dir is None: + base_dir = get_default_output_dir() + base_dir = Path(base_dir) + + if not base_dir.exists(): + return [] + + outputs = [d for d in base_dir.iterdir() if d.is_dir()] + return sorted(outputs, key=lambda p: p.stat().st_mtime, reverse=True) + + +@beartype +def clean_outputs(base_dir: str | Path | None = None, keep_latest: int = 5) -> int: + """Clean old output directories, keeping the N most recent. + + Args: + base_dir: Base directory to clean. Defaults to ./outputs/ + keep_latest: Number of recent outputs to keep + + Returns: + Number of directories removed + """ + outputs = list_outputs(base_dir) + + if len(outputs) <= keep_latest: + return 0 + + to_remove = outputs[keep_latest:] + for output_dir in to_remove: + shutil.rmtree(output_dir) + + return len(to_remove) + diff --git a/rocket/plotting.py b/rocket/plotting.py new file mode 100644 index 0000000..baffb9d --- /dev/null +++ b/rocket/plotting.py @@ -0,0 +1,1023 @@ +"""Visualization module for OpenRocketEngine. + +Provides plotting functions for: +- Engine cross-section views +- Performance curves (Isp vs altitude, thrust vs altitude) +- Nozzle contour visualization +- Trade study plots +- Cycle comparison charts + +All plots use matplotlib with a consistent, professional style. +""" + +import matplotlib.pyplot as plt +import numpy as np +from beartype import beartype +from matplotlib.figure import Figure +from matplotlib.patches import PathPatch +from matplotlib.path import Path as MplPath +from numpy.typing import NDArray + +from rocket.engine import ( + EngineGeometry, + EngineInputs, + EnginePerformance, + isp_at_altitude, + thrust_at_altitude, +) +from rocket.isentropic import ( + area_ratio_from_mach, + mach_from_pressure_ratio, + thrust_coefficient, +) +from rocket.nozzle import NozzleContour +from rocket.units import pascals + +# ============================================================================= +# Plot Style Configuration +# ============================================================================= + +# Professional color palette +COLORS = { + "primary": "#2E86AB", # Steel blue + "secondary": "#A23B72", # Berry + "accent": "#F18F01", # Orange + "chamber": "#454545", # Dark gray for chamber walls + "fill": "#E8E8E8", # Light gray for fill + "grid": "#CCCCCC", # Grid lines + "text": "#333333", # Text color +} + +# Default figure size +DEFAULT_FIGSIZE = (12, 6) + + +def _setup_style() -> None: + """Configure matplotlib style for consistent appearance.""" + plt.rcParams.update( + { + "font.family": "sans-serif", + "font.sans-serif": ["Helvetica", "Arial", "DejaVu Sans"], + "font.size": 11, + "axes.titlesize": 14, + "axes.labelsize": 12, + "axes.linewidth": 1.2, + "axes.edgecolor": COLORS["text"], + "axes.labelcolor": COLORS["text"], + "xtick.labelsize": 10, + "ytick.labelsize": 10, + "xtick.color": COLORS["text"], + "ytick.color": COLORS["text"], + "legend.fontsize": 10, + "figure.titlesize": 16, + "grid.alpha": 0.5, + "grid.linewidth": 0.8, + } + ) + + +# ============================================================================= +# Engine Cross-Section Plot +# ============================================================================= + + +@beartype +def plot_engine_cross_section( + geometry: EngineGeometry, + contour: NozzleContour, + inputs: EngineInputs | None = None, + show_dimensions: bool = True, + show_centerline: bool = True, + figsize: tuple[float, float] = DEFAULT_FIGSIZE, + title: str | None = None, +) -> Figure: + """Plot a 2D cross-section of the engine chamber and nozzle. + + Creates a symmetric cross-section view showing: + - Chamber wall profile (top and bottom halves) + - Throat location + - Key dimensions (optional) + - Centerline (optional) + + Args: + geometry: Computed engine geometry + contour: Nozzle contour (can be just nozzle or full chamber) + inputs: Engine inputs (for title and additional info) + show_dimensions: Whether to annotate key dimensions + show_centerline: Whether to show the centerline + figsize: Figure size (width, height) in inches + title: Optional custom title + + Returns: + matplotlib Figure object + """ + _setup_style() + + fig, ax = plt.subplots(figsize=figsize) + + # Get contour data + x = contour.x + y = contour.y + + # Create symmetric contour (top and bottom) + x_full = np.concatenate([x, x[::-1]]) + y_full = np.concatenate([y, -y[::-1]]) + + # Create filled polygon for chamber wall + vertices = np.column_stack([x_full, y_full]) + codes = [MplPath.MOVETO] + [MplPath.LINETO] * (len(vertices) - 2) + [MplPath.CLOSEPOLY] + path = MplPath(vertices, codes) + patch = PathPatch( + path, + facecolor=COLORS["fill"], + edgecolor=COLORS["chamber"], + linewidth=2, + ) + ax.add_patch(patch) + + # Plot contour lines explicitly for clarity + ax.plot(x, y, color=COLORS["chamber"], linewidth=2, label="Chamber wall") + ax.plot(x, -y, color=COLORS["chamber"], linewidth=2) + + # Centerline + if show_centerline: + ax.axhline(y=0, color=COLORS["primary"], linestyle="--", linewidth=1, alpha=0.7) + ax.text( + x[-1] * 0.98, + 0, + "CL", + ha="right", + va="bottom", + fontsize=9, + color=COLORS["primary"], + ) + + # Mark throat location (minimum radius point) + throat_idx = np.argmin(y) + throat_x = x[throat_idx] + throat_y = y[throat_idx] + + ax.axvline(x=throat_x, color=COLORS["secondary"], linestyle=":", linewidth=1.5, alpha=0.7) + ax.plot(throat_x, throat_y, "o", color=COLORS["secondary"], markersize=6) + ax.plot(throat_x, -throat_y, "o", color=COLORS["secondary"], markersize=6) + + # Dimension annotations + if show_dimensions: + _add_dimension_annotations(ax, geometry, contour, x, y) + + # Set axis properties + ax.set_aspect("equal") + ax.set_xlabel("Axial Position (m)") + ax.set_ylabel("Radial Position (m)") + + # Add margin + x_margin = (x[-1] - x[0]) * 0.1 + y_max = max(y) * 1.3 + ax.set_xlim(x[0] - x_margin, x[-1] + x_margin) + ax.set_ylim(-y_max, y_max) + + # Grid + ax.grid(True, alpha=0.3, linestyle="-", linewidth=0.5) + + # Title + if title: + ax.set_title(title) + elif inputs and inputs.name: + ax.set_title(f"Engine Cross-Section: {inputs.name}") + else: + ax.set_title("Engine Cross-Section") + + fig.tight_layout() + return fig + + +def _add_dimension_annotations( + ax: plt.Axes, + geometry: EngineGeometry, + contour: NozzleContour, + x: NDArray[np.float64], + y: NDArray[np.float64], +) -> None: + """Add dimension annotations to the cross-section plot.""" + # Throat diameter + throat_idx = np.argmin(y) + throat_x = x[throat_idx] + throat_r = y[throat_idx] + + # Exit diameter + exit_r = y[-1] + exit_x = x[-1] + + # Annotation style + arrowprops = dict(arrowstyle="<->", color=COLORS["accent"], lw=1.5) + text_offset = 0.02 * (x[-1] - x[0]) + + # Throat diameter annotation + Dt_mm = geometry.throat_diameter.to("m").value * 1000 + ax.annotate( + "", + xy=(throat_x, throat_r), + xytext=(throat_x, -throat_r), + arrowprops=arrowprops, + ) + ax.text( + throat_x + text_offset, + 0, + f"Dt={Dt_mm:.1f}mm", + fontsize=9, + va="center", + color=COLORS["accent"], + ) + + # Exit diameter annotation + De_mm = geometry.exit_diameter.to("m").value * 1000 + ax.annotate( + "", + xy=(exit_x, exit_r), + xytext=(exit_x, -exit_r), + arrowprops=arrowprops, + ) + ax.text( + exit_x - text_offset, + exit_r * 0.5, + f"De={De_mm:.1f}mm", + fontsize=9, + va="center", + ha="right", + color=COLORS["accent"], + ) + + # Expansion ratio annotation + eps = geometry.expansion_ratio + ax.text( + exit_x - text_offset, + -exit_r * 0.5, + f"ε={eps:.1f}", + fontsize=9, + va="center", + ha="right", + color=COLORS["text"], + ) + + +# ============================================================================= +# Nozzle Contour Plot +# ============================================================================= + + +@beartype +def plot_nozzle_contour( + contour: NozzleContour, + figsize: tuple[float, float] = (10, 6), + title: str | None = None, + units: str = "mm", +) -> Figure: + """Plot a nozzle contour profile. + + Shows just the nozzle contour (single line, not symmetric view). + Useful for verifying contour generation and CAD export. + + Args: + contour: Nozzle contour to plot + figsize: Figure size + title: Optional title + units: Display units ("m" or "mm") + + Returns: + matplotlib Figure + """ + _setup_style() + + fig, ax = plt.subplots(figsize=figsize) + + if units == "mm": + x = contour.x * 1000 + y = contour.y * 1000 + xlabel = "Axial Position (mm)" + ylabel = "Radius (mm)" + else: + x = contour.x + y = contour.y + xlabel = "Axial Position (m)" + ylabel = "Radius (m)" + + ax.plot(x, y, color=COLORS["primary"], linewidth=2, label="Contour") + ax.fill_between(x, 0, y, alpha=0.2, color=COLORS["primary"]) + + # Mark throat + throat_idx = np.argmin(y) + ax.axvline(x=x[throat_idx], color=COLORS["secondary"], linestyle=":", alpha=0.7) + ax.plot(x[throat_idx], y[throat_idx], "o", color=COLORS["secondary"], markersize=8) + ax.text( + x[throat_idx], + y[throat_idx] * 1.1, + "Throat", + ha="center", + fontsize=10, + color=COLORS["secondary"], + ) + + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + ax.set_title(title or f"Nozzle Contour ({contour.contour_type})") + ax.grid(True, alpha=0.3) + ax.set_ylim(bottom=0) + + fig.tight_layout() + return fig + + +# ============================================================================= +# Performance vs Altitude +# ============================================================================= + + +@beartype +def plot_performance_vs_altitude( + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + max_altitude_km: float | int = 100.0, + num_points: int = 100, + figsize: tuple[float, float] = DEFAULT_FIGSIZE, +) -> Figure: + """Plot thrust and Isp vs altitude. + + Shows how engine performance changes with altitude due to + decreasing ambient pressure. + + Args: + inputs: Engine inputs + performance: Computed performance + geometry: Computed geometry + max_altitude_km: Maximum altitude to plot (km) + num_points: Number of altitude points + figsize: Figure size + + Returns: + matplotlib Figure with two subplots + """ + _setup_style() + + # Generate altitude array + altitudes_km = np.linspace(0, max_altitude_km, num_points) + + # Simple exponential atmosphere model + # P = P0 * exp(-h/H) where H ≈ 8.5 km + P0 = 101325 # Pa + H = 8500 # m + pressures_Pa = P0 * np.exp(-altitudes_km * 1000 / H) + + # Calculate thrust and Isp at each altitude + thrust_vals = np.zeros(num_points) + isp_vals = np.zeros(num_points) + + for i, pa in enumerate(pressures_Pa): + pa_qty = pascals(pa) + thrust_vals[i] = thrust_at_altitude(inputs, performance, geometry, pa_qty).to("kN").value + isp_vals[i] = isp_at_altitude(inputs, performance, pa_qty).value + + # Create figure with two subplots + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize) + + # Thrust plot + ax1.plot(altitudes_km, thrust_vals, color=COLORS["primary"], linewidth=2) + ax1.axhline( + y=inputs.thrust.to("kN").value, + color=COLORS["secondary"], + linestyle="--", + alpha=0.7, + label="Design thrust (SL)", + ) + ax1.set_xlabel("Altitude (km)") + ax1.set_ylabel("Thrust (kN)") + ax1.set_title("Thrust vs Altitude") + ax1.grid(True, alpha=0.3) + ax1.legend() + ax1.set_xlim(0, max_altitude_km) + + # Isp plot + ax2.plot(altitudes_km, isp_vals, color=COLORS["accent"], linewidth=2) + ax2.axhline( + y=performance.isp.value, + color=COLORS["secondary"], + linestyle="--", + alpha=0.7, + label="Isp (SL)", + ) + ax2.axhline( + y=performance.isp_vac.value, + color=COLORS["primary"], + linestyle=":", + alpha=0.7, + label="Isp (Vac)", + ) + ax2.set_xlabel("Altitude (km)") + ax2.set_ylabel("Specific Impulse (s)") + ax2.set_title("Isp vs Altitude") + ax2.grid(True, alpha=0.3) + ax2.legend() + ax2.set_xlim(0, max_altitude_km) + + # Overall title + name = inputs.name or "Engine" + fig.suptitle(f"Altitude Performance: {name}", fontsize=14, y=1.02) + + fig.tight_layout() + return fig + + +# ============================================================================= +# Trade Study Plots +# ============================================================================= + + +@beartype +def plot_isp_vs_expansion_ratio( + gamma: float | int = 1.2, + pc_pe_range: tuple[float, float] = (10, 200), + num_points: int = 100, + figsize: tuple[float, float] = (10, 6), +) -> Figure: + """Plot theoretical Isp vs expansion ratio for different pressure ratios. + + Useful for understanding nozzle design trade-offs. + + Args: + gamma: Ratio of specific heats + pc_pe_range: Range of chamber-to-exit pressure ratios + num_points: Number of points + figsize: Figure size + + Returns: + matplotlib Figure + """ + _setup_style() + + fig, ax = plt.subplots(figsize=figsize) + + # Different pressure ratios to plot + pc_pe_values = [20, 50, 100, 150, 200] + + for pc_pe in pc_pe_values: + # Calculate exit Mach + Me = mach_from_pressure_ratio(pc_pe, gamma) + eps = area_ratio_from_mach(Me, gamma) + + # Calculate Cf for perfectly expanded nozzle (pa = pe) + pe_pc = 1.0 / pc_pe + Cf = thrust_coefficient(gamma, pe_pc, pe_pc, eps) + + # Plot point + ax.scatter([eps], [Cf], s=100, zorder=5) + ax.annotate( + f"pc/pe={pc_pe}", + xy=(eps, Cf), + xytext=(10, 5), + textcoords="offset points", + fontsize=9, + ) + + # Generate curve for range of expansion ratios + eps_range = np.linspace(2, 100, 200) + Cf_optimal = np.zeros_like(eps_range) + + for i, eps in enumerate(eps_range): + # Find pressure ratio that gives this expansion ratio + Me = 2.0 # Initial guess + for _ in range(50): + eps_calc = area_ratio_from_mach(Me, gamma) + if abs(eps_calc - eps) < 0.01: + break + Me += (eps - eps_calc) * 0.1 + + # Cf for this expansion ratio (optimally expanded) + pc_pe = (1 + (gamma - 1) / 2 * Me**2) ** (gamma / (gamma - 1)) + pe_pc = 1.0 / pc_pe + Cf_optimal[i] = thrust_coefficient(gamma, pe_pc, pe_pc, eps) + + ax.plot(eps_range, Cf_optimal, color=COLORS["primary"], linewidth=2, label="Optimal Cf") + + ax.set_xlabel("Expansion Ratio (Ae/At)") + ax.set_ylabel("Thrust Coefficient (Cf)") + ax.set_title(f"Thrust Coefficient vs Expansion Ratio (γ={gamma})") + ax.grid(True, alpha=0.3) + ax.legend() + + fig.tight_layout() + return fig + + +# ============================================================================= +# Summary Dashboard +# ============================================================================= + + +@beartype +def plot_engine_dashboard( + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + contour: NozzleContour, + figsize: tuple[float, float] = (16, 10), +) -> Figure: + """Create a comprehensive dashboard with engine summary. + + Includes: + - Engine cross-section + - Performance vs altitude + - Key parameters table + + Args: + inputs: Engine inputs + performance: Computed performance + geometry: Computed geometry + contour: Nozzle contour + figsize: Figure size + + Returns: + matplotlib Figure + """ + _setup_style() + + fig = plt.figure(figsize=figsize) + + # Create grid layout + gs = fig.add_gridspec(2, 3, height_ratios=[1.2, 1], width_ratios=[1.5, 1, 1]) + + # Cross-section (spans two columns) + ax_cross = fig.add_subplot(gs[0, :2]) + + # Get contour data + x = contour.x + y = contour.y + + # Plot symmetric contour + ax_cross.fill_between(x, y, -y, color=COLORS["fill"], alpha=0.8) + ax_cross.plot(x, y, color=COLORS["chamber"], linewidth=2) + ax_cross.plot(x, -y, color=COLORS["chamber"], linewidth=2) + ax_cross.axhline(y=0, color=COLORS["primary"], linestyle="--", linewidth=1, alpha=0.5) + + # Mark throat + throat_idx = np.argmin(y) + ax_cross.axvline(x=x[throat_idx], color=COLORS["secondary"], linestyle=":", alpha=0.7) + + ax_cross.set_aspect("equal") + ax_cross.set_xlabel("Axial Position (m)") + ax_cross.set_ylabel("Radial Position (m)") + ax_cross.set_title("Engine Cross-Section") + ax_cross.grid(True, alpha=0.3) + + # Parameters table (right side of top row) + ax_params = fig.add_subplot(gs[0, 2]) + ax_params.axis("off") + + # Create parameter text + name = inputs.name or "Unnamed Engine" + params_text = f""" + {name} + ───────────────────── + PERFORMANCE + Thrust (SL): {inputs.thrust.to('kN').value:.2f} kN + Isp (SL): {performance.isp.value:.1f} s + Isp (Vac): {performance.isp_vac.value:.1f} s + C*: {performance.cstar.value:.0f} m/s + + MASS FLOW + Total: {performance.mdot.value:.3f} kg/s + O/F Ratio: {inputs.mixture_ratio:.2f} + + GEOMETRY + Dt: {geometry.throat_diameter.to('m').value*1000:.1f} mm + De: {geometry.exit_diameter.to('m').value*1000:.1f} mm + ε (Ae/At): {geometry.expansion_ratio:.1f} + + CONDITIONS + Pc: {inputs.chamber_pressure.to('MPa').value:.2f} MPa + Tc: {inputs.chamber_temp.to('K').value:.0f} K + γ: {inputs.gamma:.3f} + """ + + ax_params.text( + 0.1, + 0.95, + params_text, + transform=ax_params.transAxes, + fontsize=10, + fontfamily="monospace", + verticalalignment="top", + bbox=dict(boxstyle="round", facecolor="white", edgecolor=COLORS["grid"]), + ) + + # Altitude performance plots (bottom row) + altitudes_km = np.linspace(0, 80, 50) + P0 = 101325 + H = 8500 + pressures_Pa = P0 * np.exp(-altitudes_km * 1000 / H) + + thrust_vals = np.zeros(len(altitudes_km)) + isp_vals = np.zeros(len(altitudes_km)) + + for i, pa in enumerate(pressures_Pa): + pa_qty = pascals(pa) + thrust_vals[i] = thrust_at_altitude(inputs, performance, geometry, pa_qty).to("kN").value + isp_vals[i] = isp_at_altitude(inputs, performance, pa_qty).value + + # Thrust vs altitude + ax_thrust = fig.add_subplot(gs[1, 0]) + ax_thrust.plot(altitudes_km, thrust_vals, color=COLORS["primary"], linewidth=2) + ax_thrust.set_xlabel("Altitude (km)") + ax_thrust.set_ylabel("Thrust (kN)") + ax_thrust.set_title("Thrust vs Altitude") + ax_thrust.grid(True, alpha=0.3) + + # Isp vs altitude + ax_isp = fig.add_subplot(gs[1, 1]) + ax_isp.plot(altitudes_km, isp_vals, color=COLORS["accent"], linewidth=2) + ax_isp.axhline(y=performance.isp_vac.value, color=COLORS["secondary"], linestyle="--", alpha=0.7) + ax_isp.set_xlabel("Altitude (km)") + ax_isp.set_ylabel("Isp (s)") + ax_isp.set_title("Specific Impulse vs Altitude") + ax_isp.grid(True, alpha=0.3) + + # Nozzle contour detail + ax_nozzle = fig.add_subplot(gs[1, 2]) + x_mm = contour.x * 1000 + y_mm = contour.y * 1000 + ax_nozzle.plot(x_mm, y_mm, color=COLORS["primary"], linewidth=2) + ax_nozzle.fill_between(x_mm, 0, y_mm, alpha=0.2, color=COLORS["primary"]) + ax_nozzle.set_xlabel("x (mm)") + ax_nozzle.set_ylabel("r (mm)") + ax_nozzle.set_title(f"Nozzle Contour ({contour.contour_type})") + ax_nozzle.grid(True, alpha=0.3) + ax_nozzle.set_ylim(bottom=0) + + fig.suptitle(f"Engine Design Summary: {name}", fontsize=16, fontweight="bold", y=0.98) + fig.tight_layout(rect=[0, 0, 1, 0.96]) + + return fig + + +# ============================================================================= +# Mass Breakdown Plot +# ============================================================================= + + +@beartype +def plot_mass_breakdown( + masses: dict[str, float], + title: str = "Mass Breakdown", + figsize: tuple[float, float] = (10, 8), +) -> Figure: + """Create a mass breakdown pie chart with bar chart. + + Args: + masses: Dictionary mapping component names to masses (kg) + title: Plot title + figsize: Figure size + + Returns: + matplotlib Figure + """ + _setup_style() + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize) + + labels = list(masses.keys()) + values = list(masses.values()) + total = sum(values) + + # Color palette + colors = plt.cm.Set2(np.linspace(0, 1, len(labels))) + + # Pie chart + ax1.pie( + values, + labels=labels, + autopct=lambda pct: f"{pct:.1f}%\n({pct*total/100:.0f} kg)", + colors=colors, + startangle=90, + pctdistance=0.75, + ) + ax1.set_title("Distribution") + + # Bar chart + bars = ax2.barh(labels, values, color=colors) + ax2.set_xlabel("Mass (kg)") + ax2.set_title("Component Masses") + + # Add value labels on bars + for bar, val in zip(bars, values, strict=True): + ax2.text( + val + total * 0.01, + bar.get_y() + bar.get_height() / 2, + f"{val:.0f} kg", + va="center", + fontsize=9, + ) + + ax2.set_xlim(0, max(values) * 1.2) + + fig.suptitle(f"{title} (Total: {total:,.0f} kg)", fontsize=14, fontweight="bold") + fig.tight_layout() + + return fig + + +# ============================================================================= +# Cycle Comparison Plots +# ============================================================================= + + +@beartype +def plot_cycle_comparison_bars( + cycle_data: list[dict], + metrics: list[str] | None = None, + figsize: tuple[float, float] = (14, 8), + title: str = "Engine Cycle Comparison", +) -> Figure: + """Create multi-metric bar chart comparing engine cycles. + + Args: + cycle_data: List of dicts with keys 'name' and metric values. + Example: [ + {'name': 'Pressure-Fed', 'net_isp': 320, 'efficiency': 1.0, ...}, + {'name': 'Gas Generator', 'net_isp': 310, 'efficiency': 0.95, ...}, + ] + metrics: List of metrics to plot. Defaults to common cycle metrics. + figsize: Figure size + title: Plot title + + Returns: + matplotlib Figure + """ + _setup_style() + + if metrics is None: + metrics = ["net_isp", "efficiency", "tank_pressure_MPa", "pump_power_kW"] + + # Filter to only metrics that exist in the data + available_metrics = [] + for m in metrics: + if all(m in d for d in cycle_data): + available_metrics.append(m) + + if not available_metrics: + raise ValueError("No valid metrics found in cycle_data") + + n_cycles = len(cycle_data) + n_metrics = len(available_metrics) + + # Metric display names and units + metric_info = { + "net_isp": ("Net Isp", "s"), + "efficiency": ("Cycle Efficiency", "%"), + "tank_pressure_MPa": ("Tank Pressure", "MPa"), + "pump_power_kW": ("Pump Power", "kW"), + "turbine_power_kW": ("Turbine Power", "kW"), + } + + fig, axes = plt.subplots(1, n_metrics, figsize=figsize) + if n_metrics == 1: + axes = [axes] + + cycle_names = [d["name"] for d in cycle_data] + colors = [COLORS["primary"], COLORS["secondary"], COLORS["accent"], "#48A9A6", "#4B3F72"] + + for ax, metric in zip(axes, available_metrics, strict=True): + values = [d.get(metric, 0) for d in cycle_data] + + # Scale efficiency to percentage + if metric == "efficiency": + values = [v * 100 for v in values] + + bars = ax.bar(cycle_names, values, color=colors[:n_cycles], edgecolor="white", linewidth=1.5) + + # Add value labels on bars + for bar, val in zip(bars, values, strict=True): + height = bar.get_height() + ax.annotate( + f"{val:.1f}", + xy=(bar.get_x() + bar.get_width() / 2, height), + xytext=(0, 3), + textcoords="offset points", + ha="center", + va="bottom", + fontsize=10, + fontweight="bold", + ) + + # Axis formatting + display_name, unit = metric_info.get(metric, (metric, "")) + ax.set_ylabel(f"{display_name} ({unit})" if unit else display_name) + ax.set_title(display_name, fontsize=12, fontweight="bold") + ax.tick_params(axis="x", rotation=15) + ax.set_ylim(0, max(values) * 1.2 if max(values) > 0 else 1) + ax.grid(axis="y", alpha=0.3) + + fig.suptitle(title, fontsize=16, fontweight="bold", y=1.02) + fig.tight_layout() + + return fig + + +@beartype +def plot_cycle_radar( + cycle_data: list[dict], + metrics: list[str] | None = None, + figsize: tuple[float, float] = (10, 10), + title: str = "Cycle Comparison Radar", +) -> Figure: + """Create radar/spider chart comparing cycles across normalized dimensions. + + All metrics are normalized to 0-1 scale for comparison. + + Args: + cycle_data: List of dicts with 'name' and metric values + metrics: Metrics to include in radar. Defaults to standard set. + figsize: Figure size + title: Plot title + + Returns: + matplotlib Figure + """ + _setup_style() + + if metrics is None: + metrics = ["net_isp", "efficiency", "simplicity", "tank_mass_factor"] + + # Filter to available metrics + available_metrics = [] + for m in metrics: + if all(m in d for d in cycle_data): + available_metrics.append(m) + + if len(available_metrics) < 3: + raise ValueError("Need at least 3 metrics for radar chart") + + n_metrics = len(available_metrics) + + # Metric display names + metric_names = { + "net_isp": "Performance\n(Isp)", + "efficiency": "Efficiency", + "simplicity": "Simplicity", + "tank_mass_factor": "Low Tank\nPressure", + "reliability": "Reliability", + "trl": "Maturity\n(TRL)", + } + + # Normalize all metrics to 0-1 scale + normalized_data = [] + for d in cycle_data: + norm_d = {"name": d["name"]} + for m in available_metrics: + values = [cd[m] for cd in cycle_data] + min_val, max_val = min(values), max(values) + if max_val > min_val: + norm_d[m] = (d[m] - min_val) / (max_val - min_val) + else: + norm_d[m] = 1.0 + normalized_data.append(norm_d) + + # Setup radar chart + angles = np.linspace(0, 2 * np.pi, n_metrics, endpoint=False).tolist() + angles += angles[:1] # Complete the loop + + fig, ax = plt.subplots(figsize=figsize, subplot_kw=dict(polar=True)) + + colors = [COLORS["primary"], COLORS["secondary"], COLORS["accent"], "#48A9A6", "#4B3F72"] + + for i, d in enumerate(normalized_data): + values = [d[m] for m in available_metrics] + values += values[:1] # Complete the loop + + ax.plot(angles, values, "o-", linewidth=2, color=colors[i % len(colors)], label=d["name"]) + ax.fill(angles, values, alpha=0.25, color=colors[i % len(colors)]) + + # Set labels + labels = [metric_names.get(m, m) for m in available_metrics] + ax.set_xticks(angles[:-1]) + ax.set_xticklabels(labels, fontsize=11) + + # Radial limits + ax.set_ylim(0, 1.1) + ax.set_yticks([0.25, 0.5, 0.75, 1.0]) + ax.set_yticklabels(["25%", "50%", "75%", "100%"], fontsize=8, alpha=0.7) + + ax.legend(loc="upper right", bbox_to_anchor=(1.3, 1.0), fontsize=11) + ax.set_title(title, fontsize=14, fontweight="bold", y=1.08) + + fig.tight_layout() + + return fig + + +@beartype +def plot_cycle_tradeoff( + cycle_data: list[dict], + x_metric: str = "net_isp", + y_metric: str = "efficiency", + size_metric: str | None = None, + figsize: tuple[float, float] = (10, 8), + title: str = "Cycle Trade Space", +) -> Figure: + """Create scatter plot showing cycle trade-offs. + + Plot cycles on 2D trade space with optional bubble size for third dimension. + + Args: + cycle_data: List of dicts with 'name' and metric values + x_metric: Metric for x-axis + y_metric: Metric for y-axis + size_metric: Optional metric for bubble size + figsize: Figure size + title: Plot title + + Returns: + matplotlib Figure + """ + _setup_style() + + # Metric display info + metric_info = { + "net_isp": ("Net Isp", "s"), + "efficiency": ("Cycle Efficiency", ""), + "tank_pressure_MPa": ("Tank Pressure", "MPa"), + "pump_power_kW": ("Pump Power", "kW"), + "simplicity": ("Simplicity Score", ""), + "complexity": ("Complexity Score", ""), + } + + fig, ax = plt.subplots(figsize=figsize) + + x_vals = [d[x_metric] for d in cycle_data] + y_vals = [d[y_metric] for d in cycle_data] + + # Scale efficiency to percentage for display + if y_metric == "efficiency": + y_vals = [v * 100 for v in y_vals] + + colors = [COLORS["primary"], COLORS["secondary"], COLORS["accent"], "#48A9A6", "#4B3F72"] + + if size_metric and all(size_metric in d for d in cycle_data): + sizes = [d[size_metric] for d in cycle_data] + # Normalize sizes for display + max_size = max(sizes) if max(sizes) > 0 else 1 + normalized_sizes = [500 + 1500 * (s / max_size) for s in sizes] + else: + normalized_sizes = [800] * len(cycle_data) + + for i, (x, y, s, d) in enumerate(zip(x_vals, y_vals, normalized_sizes, cycle_data, strict=True)): + ax.scatter( + x, y, s=s, c=colors[i % len(colors)], alpha=0.7, edgecolors="white", linewidth=2, zorder=5 + ) + # Label + ax.annotate( + d["name"], + xy=(x, y), + xytext=(10, 10), + textcoords="offset points", + fontsize=11, + fontweight="bold", + arrowprops=dict(arrowstyle="-", color="gray", alpha=0.5), + ) + + # Axis formatting + x_name, x_unit = metric_info.get(x_metric, (x_metric, "")) + y_name, y_unit = metric_info.get(y_metric, (y_metric, "")) + + ax.set_xlabel(f"{x_name} ({x_unit})" if x_unit else x_name, fontsize=12) + ax.set_ylabel(f"{y_name} ({y_unit})" if y_unit else y_name, fontsize=12) + + # Add quadrant annotations + x_mid = (max(x_vals) + min(x_vals)) / 2 + y_mid = (max(y_vals) + min(y_vals)) / 2 + + ax.axvline(x=x_mid, color="gray", linestyle="--", alpha=0.3) + ax.axhline(y=y_mid, color="gray", linestyle="--", alpha=0.3) + + # Expand limits slightly + x_range = max(x_vals) - min(x_vals) + y_range = max(y_vals) - min(y_vals) + ax.set_xlim(min(x_vals) - 0.1 * x_range, max(x_vals) + 0.15 * x_range) + ax.set_ylim(min(y_vals) - 0.1 * y_range, max(y_vals) + 0.15 * y_range) + + ax.grid(True, alpha=0.3) + ax.set_title(title, fontsize=14, fontweight="bold") + + # Add size legend if applicable + if size_metric and all(size_metric in d for d in cycle_data): + size_name = metric_info.get(size_metric, (size_metric, ""))[0] + ax.text( + 0.02, + 0.02, + f"Bubble size: {size_name}", + transform=ax.transAxes, + fontsize=9, + alpha=0.7, + ) + + fig.tight_layout() + + return fig diff --git a/rocket/propellants.py b/rocket/propellants.py new file mode 100644 index 0000000..362dd46 --- /dev/null +++ b/rocket/propellants.py @@ -0,0 +1,475 @@ +"""Propellant thermochemistry module for OpenRocketEngine. + +This module provides combustion thermochemistry calculations using NASA CEA +via RocketCEA. It computes chamber temperature, molecular weight, gamma, +and other properties needed for rocket engine performance analysis. + +Example: + >>> from rocket.propellants import get_combustion_properties + >>> props = get_combustion_properties( + ... oxidizer="LOX", + ... fuel="RP1", + ... mixture_ratio=2.7, + ... chamber_pressure_pa=7e6, + ... ) + >>> print(f"Tc = {props.chamber_temp_k:.0f} K") +""" + +from dataclasses import dataclass +from typing import Literal + +from beartype import beartype +from rocketcea.cea_obj import CEA_Obj + +# ============================================================================= +# Data Structures +# ============================================================================= + + +@beartype +@dataclass(frozen=True, slots=True) +class CombustionProperties: + """Thermochemical properties from combustion analysis. + + These properties are needed to compute rocket engine performance + using isentropic flow equations. + + Attributes: + chamber_temp_k: Adiabatic flame temperature in chamber [K] + molecular_weight: Mean molecular weight of combustion products [kg/kmol] + gamma: Ratio of specific heats (Cp/Cv) [-] + specific_heat_cp: Specific heat at constant pressure [J/(kg·K)] + characteristic_velocity: Theoretical c* [m/s] + oxidizer: Oxidizer name + fuel: Fuel name + mixture_ratio: Oxidizer-to-fuel mass ratio [-] + chamber_pressure_pa: Chamber pressure [Pa] + source: Data source ("rocketcea" or "database") + """ + + chamber_temp_k: float | int + molecular_weight: float | int + gamma: float | int + specific_heat_cp: float | int + characteristic_velocity: float | int + oxidizer: str + fuel: str + mixture_ratio: float | int + chamber_pressure_pa: float | int + source: str + + +# ============================================================================= +# Propellant Name Mapping +# ============================================================================= + +# Map common names to RocketCEA names +OXIDIZER_NAMES: dict[str, str] = { + "LOX": "LOX", + "LO2": "LOX", + "O2": "LOX", + "OXYGEN": "LOX", + "N2O4": "N2O4", + "NTO": "N2O4", + "N2O": "N2O", + "NITROUS": "N2O", + "NITROUSOXIDE": "N2O", + "H2O2": "H2O2", + "HTP": "H2O2", + "PEROXIDE": "H2O2", + "MON25": "MON25", + "MON3": "MON3", + "IRFNA": "IRFNA", + "RFNA": "IRFNA", + "CLF5": "CLF5", + "F2": "F2", + "FLUORINE": "F2", +} + +FUEL_NAMES: dict[str, str] = { + "LH2": "LH2", + "H2": "LH2", + "HYDROGEN": "LH2", + "RP1": "RP1", + "RP-1": "RP1", + "KEROSENE": "RP1", + "JET-A": "Jet-A", + "JETA": "Jet-A", + "CH4": "CH4", + "METHANE": "CH4", + "LCH4": "CH4", + "C2H5OH": "Ethanol", + "ETHANOL": "Ethanol", + "C3H8O": "IPA", + "IPA": "IPA", + "ISOPROPANOL": "IPA", + "MMH": "MMH", + "UDMH": "UDMH", + "N2H4": "N2H4", + "HYDRAZINE": "N2H4", + "A50": "A-50", + "A-50": "A-50", + "AEROZINE50": "A-50", +} + + +def _normalize_propellant_name(name: str, is_oxidizer: bool) -> str: + """Normalize propellant name to RocketCEA format.""" + normalized = name.upper().replace(" ", "").replace("-", "") + lookup = OXIDIZER_NAMES if is_oxidizer else FUEL_NAMES + + if normalized in lookup: + return lookup[normalized] + + # Try original name (RocketCEA might accept it) + return name + + +# ============================================================================= +# Fallback Database (When CEA Not Available) +# ============================================================================= + +# Tabulated data for common propellant combinations at typical conditions +# Format: (oxidizer, fuel): {MR: (Tc_K, MW, gamma, cstar_m/s)} +# Data at approximately 1000 psia (6.9 MPa) chamber pressure +# Sources: Sutton & Biblarz, various NASA reports + +_PROPELLANT_DATABASE: dict[tuple[str, str], dict[float, tuple[float, float, float, float]]] = { + ("LOX", "LH2"): { + 4.0: (3015, 12.0, 1.20, 2290), + 5.0: (3250, 13.5, 1.18, 2360), + 6.0: (3400, 14.8, 1.16, 2390), + 7.0: (3470, 16.0, 1.15, 2380), + 8.0: (3450, 17.0, 1.14, 2340), + }, + ("LOX", "RP1"): { + 2.0: (3450, 21.5, 1.21, 1750), + 2.3: (3550, 22.5, 1.19, 1780), + 2.5: (3600, 23.0, 1.18, 1790), + 2.7: (3620, 23.3, 1.17, 1800), + 3.0: (3580, 24.0, 1.16, 1780), + }, + ("LOX", "CH4"): { + 2.5: (3400, 19.5, 1.19, 1820), + 3.0: (3530, 20.5, 1.17, 1850), + 3.2: (3560, 21.0, 1.16, 1860), + 3.5: (3570, 21.5, 1.15, 1850), + 4.0: (3520, 22.5, 1.14, 1820), + }, + ("LOX", "Ethanol"): { + 1.0: (2800, 20.0, 1.24, 1650), + 1.3: (3100, 21.0, 1.22, 1720), + 1.5: (3250, 21.5, 1.20, 1750), + 1.8: (3350, 22.0, 1.19, 1760), + 2.0: (3380, 22.5, 1.18, 1750), + }, + ("N2O4", "MMH"): { + 1.5: (3000, 21.0, 1.24, 1680), + 1.8: (3150, 21.5, 1.22, 1720), + 2.0: (3220, 22.0, 1.21, 1730), + 2.2: (3260, 22.5, 1.20, 1730), + 2.5: (3250, 23.0, 1.19, 1710), + }, + ("N2O4", "UDMH"): { + 1.8: (3050, 21.5, 1.23, 1690), + 2.0: (3150, 22.0, 1.22, 1710), + 2.2: (3200, 22.5, 1.21, 1720), + 2.5: (3220, 23.0, 1.20, 1710), + 2.8: (3180, 23.5, 1.19, 1690), + }, + ("N2O4", "A-50"): { + 1.5: (3000, 21.0, 1.24, 1680), + 1.8: (3120, 21.5, 1.22, 1710), + 2.0: (3180, 22.0, 1.21, 1720), + 2.2: (3210, 22.5, 1.20, 1720), + 2.6: (3180, 23.0, 1.19, 1700), + }, + ("N2O", "Ethanol"): { + 3.0: (2800, 24.0, 1.22, 1550), + 4.0: (2950, 25.0, 1.20, 1580), + 5.0: (3000, 26.0, 1.19, 1570), + 6.0: (2980, 27.0, 1.18, 1540), + }, + ("H2O2", "RP1"): { + 6.0: (2700, 22.5, 1.21, 1580), + 7.0: (2750, 23.0, 1.20, 1590), + 7.5: (2760, 23.5, 1.19, 1580), + 8.0: (2750, 24.0, 1.19, 1570), + }, +} + + +def _interpolate_database( + oxidizer: str, fuel: str, mixture_ratio: float +) -> tuple[float, float, float, float] | None: + """Interpolate propellant database for given mixture ratio.""" + key = (oxidizer, fuel) + if key not in _PROPELLANT_DATABASE: + return None + + data = _PROPELLANT_DATABASE[key] + mrs = sorted(data.keys()) + + # Clamp to available range + if mixture_ratio <= mrs[0]: + return data[mrs[0]] + if mixture_ratio >= mrs[-1]: + return data[mrs[-1]] + + # Find bracketing values + for i in range(len(mrs) - 1): + if mrs[i] <= mixture_ratio <= mrs[i + 1]: + mr_low, mr_high = mrs[i], mrs[i + 1] + break + else: + return data[mrs[-1]] + + # Linear interpolation + t = (mixture_ratio - mr_low) / (mr_high - mr_low) + low = data[mr_low] + high = data[mr_high] + + return ( + low[0] + t * (high[0] - low[0]), # Tc + low[1] + t * (high[1] - low[1]), # MW + low[2] + t * (high[2] - low[2]), # gamma + low[3] + t * (high[3] - low[3]), # cstar + ) + + +# ============================================================================= +# RocketCEA Integration +# ============================================================================= + + +def _get_properties_from_cea( + oxidizer: str, + fuel: str, + mixture_ratio: float, + chamber_pressure_pa: float, +) -> CombustionProperties: + """Get combustion properties using RocketCEA.""" + + # Convert pressure to psia (RocketCEA default) + pc_psia = chamber_pressure_pa / 6894.76 + + # Normalize propellant names + ox_name = _normalize_propellant_name(oxidizer, is_oxidizer=True) + fuel_name = _normalize_propellant_name(fuel, is_oxidizer=False) + + # Create CEA object + cea = CEA_Obj(oxName=ox_name, fuelName=fuel_name) + + # Get chamber properties + # Note: RocketCEA returns (Mw, gamma) from get_Chamber_MolWt_gamma + Tc = cea.get_Tcomb(Pc=pc_psia, MR=mixture_ratio) # Chamber temp in R + Tc_K = Tc * 5 / 9 # Convert Rankine to Kelvin + + mw_gamma = cea.get_Chamber_MolWt_gamma(Pc=pc_psia, MR=mixture_ratio, eps=1.0) + MW = mw_gamma[0] # Molecular weight + gamma = mw_gamma[1] # Gamma + + # Get c* in ft/s, convert to m/s + cstar_fts = cea.get_Cstar(Pc=pc_psia, MR=mixture_ratio) + cstar_ms = cstar_fts * 0.3048 + + # Calculate Cp from gamma and MW + R_universal = 8314.46 # J/(kmol·K) + R_specific = R_universal / MW # J/(kg·K) + Cp = gamma * R_specific / (gamma - 1) + + return CombustionProperties( + chamber_temp_k=Tc_K, + molecular_weight=MW, + gamma=gamma, + specific_heat_cp=Cp, + characteristic_velocity=cstar_ms, + oxidizer=oxidizer, + fuel=fuel, + mixture_ratio=mixture_ratio, + chamber_pressure_pa=chamber_pressure_pa, + source="rocketcea", + ) + + +def _get_properties_from_database( + oxidizer: str, + fuel: str, + mixture_ratio: float, + chamber_pressure_pa: float, +) -> CombustionProperties: + """Get combustion properties from built-in database.""" + # Normalize names + ox_name = _normalize_propellant_name(oxidizer, is_oxidizer=True) + fuel_name = _normalize_propellant_name(fuel, is_oxidizer=False) + + result = _interpolate_database(ox_name, fuel_name, mixture_ratio) + + if result is None: + available = list(_PROPELLANT_DATABASE.keys()) + raise ValueError( + f"Propellant combination ({ox_name}, {fuel_name}) not in database. " + f"Available combinations: {available}. " + f"Install RocketCEA for arbitrary propellant combinations: pip install rocketcea" + ) + + Tc_K, MW, gamma, cstar = result + + # Calculate Cp + R_universal = 8314.46 + R_specific = R_universal / MW + Cp = gamma * R_specific / (gamma - 1) + + return CombustionProperties( + chamber_temp_k=Tc_K, + molecular_weight=MW, + gamma=gamma, + specific_heat_cp=Cp, + characteristic_velocity=cstar, + oxidizer=oxidizer, + fuel=fuel, + mixture_ratio=mixture_ratio, + chamber_pressure_pa=chamber_pressure_pa, + source="database", + ) + + +# ============================================================================= +# Public API +# ============================================================================= + + +@beartype +def get_combustion_properties( + oxidizer: str, + fuel: str, + mixture_ratio: float, + chamber_pressure_pa: float, + use_cea: bool = True, +) -> CombustionProperties: + """Get combustion thermochemistry properties for a propellant combination. + + This function returns the thermochemical properties needed for rocket engine + performance calculations. When RocketCEA is installed and use_cea=True, + it uses NASA CEA for accurate equilibrium calculations. Otherwise, it falls + back to a built-in database of common propellant combinations. + + Args: + oxidizer: Oxidizer name (e.g., "LOX", "N2O4", "N2O", "H2O2") + fuel: Fuel name (e.g., "RP1", "LH2", "CH4", "Ethanol", "MMH") + mixture_ratio: Oxidizer-to-fuel mass ratio (O/F) + chamber_pressure_pa: Chamber pressure in Pascals + use_cea: If True and RocketCEA is installed, use CEA. Otherwise use database. + + Returns: + CombustionProperties containing Tc, MW, gamma, Cp, c* + + Raises: + ValueError: If propellant combination is not available in database + and RocketCEA is not installed + + Example: + >>> props = get_combustion_properties( + ... oxidizer="LOX", + ... fuel="RP1", + ... mixture_ratio=2.7, + ... chamber_pressure_pa=7e6, + ... ) + >>> print(f"Tc = {props.chamber_temp_k:.0f} K, gamma = {props.gamma:.3f}") + """ + if use_cea: + return _get_properties_from_cea( + oxidizer, fuel, mixture_ratio, chamber_pressure_pa + ) + else: + return _get_properties_from_database( + oxidizer, fuel, mixture_ratio, chamber_pressure_pa + ) + + +@beartype +def is_cea_available() -> bool: + """Check if RocketCEA is installed and available. + + Returns: + Always True (RocketCEA is a required dependency) + """ + return True + + +@beartype +def list_database_propellants() -> list[tuple[str, str]]: + """List propellant combinations available in the built-in database. + + Returns: + List of (oxidizer, fuel) tuples available without RocketCEA + """ + return list(_PROPELLANT_DATABASE.keys()) + + +@beartype +def get_optimal_mixture_ratio( + oxidizer: str, + fuel: str, + chamber_pressure_pa: float, + expansion_ratio: float = 40.0, + metric: Literal["isp", "cstar", "density_isp"] = "isp", +) -> tuple[float, float]: + """Find the optimal mixture ratio for maximum performance. + + Searches for the mixture ratio that maximizes the specified metric. + Requires RocketCEA for accurate optimization. + + Args: + oxidizer: Oxidizer name + fuel: Fuel name + chamber_pressure_pa: Chamber pressure in Pascals + expansion_ratio: Nozzle expansion ratio for Isp calculation + metric: Optimization target: + - "isp": Maximize specific impulse + - "cstar": Maximize characteristic velocity + - "density_isp": Maximize density * Isp (important for volume-limited vehicles) + + Returns: + Tuple of (optimal_mixture_ratio, maximum_metric_value) + + Raises: + RuntimeError: If RocketCEA is not installed + """ + pc_psia = chamber_pressure_pa / 6894.76 + ox_name = _normalize_propellant_name(oxidizer, is_oxidizer=True) + fuel_name = _normalize_propellant_name(fuel, is_oxidizer=False) + + cea = CEA_Obj(oxName=ox_name, fuelName=fuel_name) + + # Search over mixture ratios + best_mr = 1.0 + best_value = 0.0 + + # Determine search range based on propellant type + if ox_name == "LOX" and fuel_name == "LH2": + mr_range = [x / 10 for x in range(30, 90, 2)] # 3.0 to 9.0 + elif ox_name == "LOX": + mr_range = [x / 10 for x in range(15, 40, 2)] # 1.5 to 4.0 + else: + mr_range = [x / 10 for x in range(10, 50, 2)] # 1.0 to 5.0 + + for mr in mr_range: + try: + if metric == "isp": + value = cea.get_Isp(Pc=pc_psia, MR=mr, eps=expansion_ratio) + elif metric == "cstar": + value = cea.get_Cstar(Pc=pc_psia, MR=mr) + elif metric == "density_isp": + isp = cea.get_Isp(Pc=pc_psia, MR=mr, eps=expansion_ratio) + # Approximate density Isp (would need propellant densities for accuracy) + value = isp # Simplified - use Isp as proxy + + if value > best_value: + best_value = value + best_mr = mr + except Exception: + continue + + return best_mr, best_value + diff --git a/rocket/results.py b/rocket/results.py new file mode 100644 index 0000000..19b344b --- /dev/null +++ b/rocket/results.py @@ -0,0 +1,659 @@ +"""Results visualization and Pareto front analysis for Rocket. + +This module provides plotting utilities for parametric study and uncertainty +analysis results. Integrates with the analysis module for seamless visualization. + +Example: + >>> from rocket.analysis import ParametricStudy, Range + >>> from rocket.results import plot_1d, plot_2d_contour, plot_pareto + >>> + >>> results = study.run() + >>> fig = plot_1d(results, "chamber_pressure", "isp_vac") + >>> fig = plot_pareto(results, "isp_vac", "thrust_to_weight", maximize=[True, True]) +""" + +from typing import Sequence + +import matplotlib.pyplot as plt +import numpy as np +from beartype import beartype +from matplotlib.figure import Figure +from numpy.typing import NDArray + +from rocket.analysis import StudyResults, UncertaintyResults + + +# ============================================================================= +# Plot Style Configuration +# ============================================================================= + +COLORS = { + "primary": "#2E86AB", + "secondary": "#A23B72", + "accent": "#F18F01", + "feasible": "#2E86AB", + "infeasible": "#CCCCCC", + "pareto": "#E63946", + "grid": "#CCCCCC", + "text": "#333333", +} + + +def _setup_style() -> None: + """Configure matplotlib style for consistent appearance.""" + plt.rcParams.update({ + "font.family": "sans-serif", + "font.sans-serif": ["Helvetica", "Arial", "DejaVu Sans"], + "font.size": 11, + "axes.titlesize": 14, + "axes.labelsize": 12, + "axes.linewidth": 1.2, + "xtick.labelsize": 10, + "ytick.labelsize": 10, + "legend.fontsize": 10, + "figure.titlesize": 16, + "grid.alpha": 0.5, + }) + + +# ============================================================================= +# 1D Parameter Sweep Plots +# ============================================================================= + + +@beartype +def plot_1d( + results: StudyResults, + x_param: str, + y_metric: str, + show_infeasible: bool = True, + figsize: tuple[float, float] = (10, 6), + title: str | None = None, + xlabel: str | None = None, + ylabel: str | None = None, +) -> Figure: + """Plot a 1D parameter sweep. + + Args: + results: StudyResults from ParametricStudy + x_param: Parameter name for x-axis + y_metric: Metric name for y-axis + show_infeasible: Whether to show infeasible points (grayed out) + figsize: Figure size (width, height) + title: Optional plot title + xlabel: Optional x-axis label + ylabel: Optional y-axis label + + Returns: + matplotlib Figure + """ + _setup_style() + + fig, ax = plt.subplots(figsize=figsize) + + x = results.get_parameter(x_param) + y = results.get_metric(y_metric) + + if results.constraints_passed is not None and show_infeasible: + # Plot infeasible points first (gray) + infeasible = ~results.constraints_passed + if np.any(infeasible): + ax.scatter( + x[infeasible], y[infeasible], + c=COLORS["infeasible"], s=50, alpha=0.5, + label="Infeasible", zorder=1, + ) + + # Plot feasible points + feasible = results.constraints_passed + if np.any(feasible): + ax.scatter( + x[feasible], y[feasible], + c=COLORS["primary"], s=80, + label="Feasible", zorder=2, + ) + ax.plot( + x[feasible], y[feasible], + c=COLORS["primary"], alpha=0.5, linewidth=1.5, zorder=1, + ) + else: + ax.scatter(x, y, c=COLORS["primary"], s=80) + ax.plot(x, y, c=COLORS["primary"], alpha=0.5, linewidth=1.5) + + # Mark optimum + if results.constraints_passed is not None: + feasible_mask = results.constraints_passed + if np.any(feasible_mask): + best_idx = np.argmax(y * feasible_mask.astype(float) - (~feasible_mask) * 1e10) + ax.scatter( + [x[best_idx]], [y[best_idx]], + c=COLORS["accent"], s=150, marker="*", + label=f"Best: {y[best_idx]:.3g}", zorder=3, + ) + + ax.set_xlabel(xlabel or x_param) + ax.set_ylabel(ylabel or y_metric) + ax.set_title(title or f"{y_metric} vs {x_param}") + ax.grid(True, alpha=0.3) + ax.legend() + + fig.tight_layout() + return fig + + +@beartype +def plot_1d_multi( + results: StudyResults, + x_param: str, + y_metrics: list[str], + figsize: tuple[float, float] = (12, 6), + title: str | None = None, + xlabel: str | None = None, + normalize: bool = False, +) -> Figure: + """Plot multiple metrics vs a single parameter. + + Args: + results: StudyResults from ParametricStudy + x_param: Parameter name for x-axis + y_metrics: List of metric names to plot + figsize: Figure size + title: Optional title + xlabel: Optional x-axis label + normalize: If True, normalize all metrics to [0, 1] + + Returns: + matplotlib Figure + """ + _setup_style() + + fig, ax = plt.subplots(figsize=figsize) + + x = results.get_parameter(x_param) + colors = plt.cm.tab10(np.linspace(0, 1, len(y_metrics))) + + for i, metric in enumerate(y_metrics): + y = results.get_metric(metric) + if normalize: + y = (y - np.nanmin(y)) / (np.nanmax(y) - np.nanmin(y) + 1e-10) + + ax.plot(x, y, color=colors[i], linewidth=2, label=metric, marker="o", markersize=4) + + ax.set_xlabel(xlabel or x_param) + ax.set_ylabel("Normalized Value" if normalize else "Metric Value") + ax.set_title(title or f"Metrics vs {x_param}") + ax.grid(True, alpha=0.3) + ax.legend(loc="best") + + fig.tight_layout() + return fig + + +# ============================================================================= +# 2D Contour Plots +# ============================================================================= + + +@beartype +def plot_2d_contour( + results: StudyResults, + x_param: str, + y_param: str, + z_metric: str, + figsize: tuple[float, float] = (10, 8), + title: str | None = None, + levels: int = 20, + show_points: bool = True, + show_infeasible: bool = True, +) -> Figure: + """Plot a 2D contour for two-parameter sweeps. + + Args: + results: StudyResults from ParametricStudy (must be 2D grid) + x_param: Parameter for x-axis + y_param: Parameter for y-axis + z_metric: Metric for contour values + figsize: Figure size + title: Optional title + levels: Number of contour levels + show_points: Show scatter points at evaluated locations + show_infeasible: Mark infeasible regions + + Returns: + matplotlib Figure + """ + _setup_style() + + x = results.get_parameter(x_param) + y = results.get_parameter(y_param) + z = results.get_metric(z_metric) + + # Get unique values to determine grid shape + x_unique = np.unique(x) + y_unique = np.unique(y) + + if len(x_unique) * len(y_unique) != len(x): + raise ValueError( + f"Data is not a complete 2D grid. Got {len(x)} points, " + f"expected {len(x_unique)} x {len(y_unique)} = {len(x_unique) * len(y_unique)}" + ) + + # Reshape to 2D grid + nx, ny = len(x_unique), len(y_unique) + X = x.reshape(ny, nx) + Y = y.reshape(ny, nx) + Z = z.reshape(ny, nx) + + fig, ax = plt.subplots(figsize=figsize) + + # Contour plot + contour = ax.contourf(X, Y, Z, levels=levels, cmap="viridis") + ax.contour(X, Y, Z, levels=levels, colors="white", alpha=0.3, linewidths=0.5) + + # Colorbar + cbar = fig.colorbar(contour, ax=ax, label=z_metric) + + # Show evaluated points + if show_points: + ax.scatter(x, y, c="white", s=10, alpha=0.5, edgecolors="black", linewidths=0.5) + + # Mark infeasible regions + if show_infeasible and results.constraints_passed is not None: + infeasible = ~results.constraints_passed + if np.any(infeasible): + ax.scatter( + x[infeasible], y[infeasible], + c="red", s=30, marker="x", alpha=0.7, + label="Infeasible", + ) + ax.legend() + + ax.set_xlabel(x_param) + ax.set_ylabel(y_param) + ax.set_title(title or f"{z_metric}") + + fig.tight_layout() + return fig + + +# ============================================================================= +# Pareto Front Analysis +# ============================================================================= + + +def _compute_pareto_front( + objectives: NDArray[np.float64], + maximize: Sequence[bool], +) -> NDArray[np.bool_]: + """Compute Pareto-optimal points. + + Args: + objectives: Array of shape (n_points, n_objectives) + maximize: List of booleans indicating whether to maximize each objective + + Returns: + Boolean array indicating Pareto-optimal points + """ + n_points = objectives.shape[0] + is_pareto = np.ones(n_points, dtype=bool) + + # Flip signs for maximization objectives + obj = objectives.copy() + for i, is_max in enumerate(maximize): + if is_max: + obj[:, i] = -obj[:, i] + + for i in range(n_points): + if not is_pareto[i]: + continue + + # Check if any other point dominates this one + for j in range(n_points): + if i == j or not is_pareto[j]: + continue + + # j dominates i if j is <= in all objectives and < in at least one + dominates = np.all(obj[j] <= obj[i]) and np.any(obj[j] < obj[i]) + if dominates: + is_pareto[i] = False + break + + return is_pareto + + +@beartype +def plot_pareto( + results: StudyResults, + x_metric: str, + y_metric: str, + maximize: tuple[bool, bool] = (True, True), + feasible_only: bool = True, + figsize: tuple[float, float] = (10, 8), + title: str | None = None, + show_dominated: bool = True, +) -> Figure: + """Plot Pareto front for two objectives. + + Args: + results: StudyResults from ParametricStudy + x_metric: First objective (x-axis) + y_metric: Second objective (y-axis) + maximize: Tuple of booleans for each objective (True = maximize) + feasible_only: Only consider feasible points + figsize: Figure size + title: Optional title + show_dominated: Show dominated points in gray + + Returns: + matplotlib Figure + """ + _setup_style() + + # Get metrics + x = results.get_metric(x_metric) + y = results.get_metric(y_metric) + + # Filter to feasible if requested + if feasible_only and results.constraints_passed is not None: + mask = results.constraints_passed + else: + mask = np.ones(len(x), dtype=bool) + + # Remove NaN values + valid = mask & ~np.isnan(x) & ~np.isnan(y) + x_valid = x[valid] + y_valid = y[valid] + + if len(x_valid) == 0: + raise ValueError("No valid data points for Pareto analysis") + + # Compute Pareto front + objectives = np.column_stack([x_valid, y_valid]) + is_pareto = _compute_pareto_front(objectives, maximize) + + fig, ax = plt.subplots(figsize=figsize) + + # Plot dominated points + if show_dominated: + dominated = ~is_pareto + ax.scatter( + x_valid[dominated], y_valid[dominated], + c=COLORS["infeasible"], s=50, alpha=0.5, + label="Dominated", + ) + + # Plot Pareto front points + ax.scatter( + x_valid[is_pareto], y_valid[is_pareto], + c=COLORS["pareto"], s=100, + label=f"Pareto Front ({np.sum(is_pareto)} points)", + zorder=3, + ) + + # Connect Pareto points with line (sorted) + pareto_x = x_valid[is_pareto] + pareto_y = y_valid[is_pareto] + sort_idx = np.argsort(pareto_x) + ax.plot( + pareto_x[sort_idx], pareto_y[sort_idx], + c=COLORS["pareto"], linewidth=2, alpha=0.7, + linestyle="--", + ) + + # Add direction arrows + x_dir = "→" if maximize[0] else "←" + y_dir = "↑" if maximize[1] else "↓" + ax.annotate( + f"Better {x_dir}", + xy=(0.95, 0.02), xycoords="axes fraction", + ha="right", fontsize=10, color=COLORS["text"], + ) + ax.annotate( + f"Better {y_dir}", + xy=(0.02, 0.95), xycoords="axes fraction", + ha="left", fontsize=10, color=COLORS["text"], rotation=90, + ) + + ax.set_xlabel(x_metric) + ax.set_ylabel(y_metric) + ax.set_title(title or f"Pareto Front: {x_metric} vs {y_metric}") + ax.grid(True, alpha=0.3) + ax.legend() + + fig.tight_layout() + return fig + + +@beartype +def get_pareto_points( + results: StudyResults, + metrics: list[str], + maximize: list[bool], + feasible_only: bool = True, +) -> tuple[list[int], NDArray[np.float64]]: + """Get indices and values of Pareto-optimal points. + + Args: + results: StudyResults from ParametricStudy + metrics: List of objective metric names + maximize: List of booleans for each metric + feasible_only: Only consider feasible points + + Returns: + Tuple of (indices in original results, objective values array) + """ + # Get metrics + obj_arrays = [results.get_metric(m) for m in metrics] + objectives = np.column_stack(obj_arrays) + + # Filter to feasible + if feasible_only and results.constraints_passed is not None: + mask = results.constraints_passed + else: + mask = np.ones(objectives.shape[0], dtype=bool) + + # Remove NaN + valid = mask & ~np.any(np.isnan(objectives), axis=1) + valid_indices = np.where(valid)[0] + objectives_valid = objectives[valid] + + # Compute Pareto front + is_pareto = _compute_pareto_front(objectives_valid, maximize) + + pareto_indices = valid_indices[is_pareto].tolist() + pareto_values = objectives_valid[is_pareto] + + return pareto_indices, pareto_values + + +# ============================================================================= +# Uncertainty Visualization +# ============================================================================= + + +@beartype +def plot_histogram( + results: UncertaintyResults, + metric: str, + bins: int = 50, + show_ci: bool = True, + ci_level: float = 0.95, + figsize: tuple[float, float] = (10, 6), + title: str | None = None, + feasible_only: bool = False, +) -> Figure: + """Plot histogram of a metric from uncertainty analysis. + + Args: + results: UncertaintyResults from UncertaintyAnalysis + metric: Metric name to plot + bins: Number of histogram bins + show_ci: Show confidence interval lines + ci_level: Confidence level for CI lines + figsize: Figure size + title: Optional title + feasible_only: Only include feasible samples + + Returns: + matplotlib Figure + """ + _setup_style() + + values = results.metrics[metric] + if feasible_only: + values = values[results.constraints_passed] + + # Remove NaN + values = values[~np.isnan(values)] + + fig, ax = plt.subplots(figsize=figsize) + + # Histogram + ax.hist(values, bins=bins, color=COLORS["primary"], alpha=0.7, edgecolor="white") + + # Statistics + mean = np.mean(values) + std = np.std(values) + + ax.axvline(mean, color=COLORS["accent"], linewidth=2, label=f"Mean: {mean:.4g}") + + if show_ci: + ci = results.confidence_interval(metric, ci_level, feasible_only) + ax.axvline(ci[0], color=COLORS["secondary"], linewidth=1.5, linestyle="--", + label=f"{ci_level*100:.0f}% CI: [{ci[0]:.4g}, {ci[1]:.4g}]") + ax.axvline(ci[1], color=COLORS["secondary"], linewidth=1.5, linestyle="--") + + ax.set_xlabel(metric) + ax.set_ylabel("Frequency") + ax.set_title(title or f"Distribution of {metric}") + ax.legend() + ax.grid(True, alpha=0.3, axis="y") + + # Add text box with statistics + stats_text = f"μ = {mean:.4g}\nσ = {std:.4g}\nn = {len(values)}" + ax.text( + 0.95, 0.95, stats_text, + transform=ax.transAxes, ha="right", va="top", + fontsize=10, fontfamily="monospace", + bbox=dict(boxstyle="round", facecolor="white", alpha=0.8), + ) + + fig.tight_layout() + return fig + + +@beartype +def plot_correlation( + results: UncertaintyResults, + x_param: str, + y_metric: str, + figsize: tuple[float, float] = (10, 6), + title: str | None = None, +) -> Figure: + """Plot correlation between input parameter and output metric. + + Args: + results: UncertaintyResults from UncertaintyAnalysis + x_param: Input parameter name (from samples) + y_metric: Output metric name + figsize: Figure size + title: Optional title + + Returns: + matplotlib Figure + """ + _setup_style() + + if x_param not in results.samples: + raise ValueError(f"Parameter '{x_param}' not in samples. Available: {list(results.samples.keys())}") + + x = results.samples[x_param] + y = results.metrics[y_metric] + + # Remove NaN pairs + valid = ~(np.isnan(x) | np.isnan(y)) + x = x[valid] + y = y[valid] + + fig, ax = plt.subplots(figsize=figsize) + + ax.scatter(x, y, c=COLORS["primary"], alpha=0.3, s=20) + + # Compute correlation + corr = np.corrcoef(x, y)[0, 1] + + # Add trend line + z = np.polyfit(x, y, 1) + p = np.poly1d(z) + x_line = np.linspace(x.min(), x.max(), 100) + ax.plot(x_line, p(x_line), c=COLORS["accent"], linewidth=2, + label=f"Trend (r = {corr:.3f})") + + ax.set_xlabel(x_param) + ax.set_ylabel(y_metric) + ax.set_title(title or f"Correlation: {x_param} vs {y_metric}") + ax.legend() + ax.grid(True, alpha=0.3) + + fig.tight_layout() + return fig + + +@beartype +def plot_tornado( + results: UncertaintyResults, + metric: str, + parameters: list[str] | None = None, + figsize: tuple[float, float] = (10, 6), + title: str | None = None, +) -> Figure: + """Plot tornado chart showing sensitivity of metric to input parameters. + + Shows which parameters have the strongest influence on the metric. + + Args: + results: UncertaintyResults from UncertaintyAnalysis + metric: Metric to analyze + parameters: List of parameters to include (None = all) + figsize: Figure size + title: Optional title + + Returns: + matplotlib Figure + """ + _setup_style() + + if parameters is None: + parameters = list(results.samples.keys()) + + y_values = results.metrics[metric] + correlations: dict[str, float] = {} + + for param in parameters: + x_values = results.samples[param] + valid = ~(np.isnan(x_values) | np.isnan(y_values)) + if np.sum(valid) > 2: + corr = np.corrcoef(x_values[valid], y_values[valid])[0, 1] + correlations[param] = corr + + # Sort by absolute correlation + sorted_params = sorted(correlations.keys(), key=lambda p: abs(correlations[p])) + sorted_corrs = [correlations[p] for p in sorted_params] + + fig, ax = plt.subplots(figsize=figsize) + + y_pos = np.arange(len(sorted_params)) + colors = [COLORS["primary"] if c >= 0 else COLORS["secondary"] for c in sorted_corrs] + + ax.barh(y_pos, sorted_corrs, color=colors, alpha=0.8, edgecolor="white") + ax.set_yticks(y_pos) + ax.set_yticklabels(sorted_params) + ax.set_xlabel("Correlation Coefficient") + ax.set_title(title or f"Sensitivity of {metric}") + ax.axvline(0, color="black", linewidth=0.5) + ax.set_xlim(-1, 1) + ax.grid(True, alpha=0.3, axis="x") + + fig.tight_layout() + return fig + diff --git a/rocket/system.py b/rocket/system.py new file mode 100644 index 0000000..ce3dc21 --- /dev/null +++ b/rocket/system.py @@ -0,0 +1,245 @@ +"""High-level system design API for Rocket. + +This module provides the main entry point for complete engine system design, +integrating: +- Engine performance and geometry +- Cycle analysis (pressure-fed, gas-generator, etc.) +- Thermal/cooling feasibility + +Example: + >>> from rocket import EngineInputs + >>> from rocket.system import design_engine_system + >>> from rocket.cycles import GasGeneratorCycle + >>> from rocket.units import kilonewtons, megapascals, kelvin + >>> + >>> inputs = EngineInputs.from_propellants( + ... oxidizer="LOX", + ... fuel="CH4", + ... thrust=kilonewtons(2000), + ... chamber_pressure=megapascals(30), + ... ) + >>> + >>> result = design_engine_system( + ... inputs=inputs, + ... cycle=GasGeneratorCycle(turbine_inlet_temp=kelvin(900)), + ... check_cooling=True, + ... ) + >>> + >>> print(f"Net Isp: {result.cycle.net_isp.value:.1f} s") + >>> print(f"Cooling feasible: {result.cooling.feasible}") +""" + +from dataclasses import dataclass +from typing import Any + +from beartype import beartype + +from rocket.engine import ( + EngineGeometry, + EngineInputs, + EnginePerformance, + compute_geometry, + compute_performance, +) +from rocket.cycles.base import CycleConfiguration, CyclePerformance, analyze_cycle +from rocket.thermal.regenerative import CoolingFeasibility, check_cooling_feasibility +from rocket.units import kelvin + + +@beartype +@dataclass(frozen=True) +class EngineSystemResult: + """Complete engine system design result. + + Contains all analysis results from engine performance through + cycle analysis and thermal assessment. + + Attributes: + inputs: Original engine inputs + performance: Ideal engine performance (before cycle losses) + geometry: Engine geometry + cycle: Cycle analysis results (if cycle provided) + cooling: Cooling feasibility results (if check_cooling=True) + feasible: Overall feasibility (cycle closes AND cooling ok) + warnings: Combined warnings from all analyses + """ + + inputs: EngineInputs + performance: EnginePerformance + geometry: EngineGeometry + cycle: CyclePerformance | None + cooling: CoolingFeasibility | None + feasible: bool + warnings: list[str] + + +@beartype +def design_engine_system( + inputs: EngineInputs, + cycle: CycleConfiguration | None = None, + check_cooling: bool = True, + coolant: str | None = None, + max_wall_temp: Any | None = None, +) -> EngineSystemResult: + """Design a complete engine system with cycle and thermal analysis. + + This is the main entry point for rocket engine system design. It: + 1. Computes ideal engine performance and geometry + 2. Analyzes the engine cycle (if specified) + 3. Checks cooling feasibility (if requested) + 4. Returns a comprehensive result with all analyses + + Args: + inputs: Engine input parameters (from EngineInputs.from_propellants or manual) + cycle: Engine cycle configuration (PressureFedCycle, GasGeneratorCycle, etc.) + If None, only basic performance is computed. + check_cooling: Whether to perform cooling feasibility analysis + coolant: Coolant name for cooling analysis. If None, uses fuel. + max_wall_temp: Maximum allowed wall temperature [K]. If None, uses defaults. + + Returns: + EngineSystemResult with all analysis results + + Example: + >>> # Basic design without cycle analysis + >>> result = design_engine_system(inputs) + >>> print(f"Isp: {result.performance.isp.value:.1f} s") + >>> + >>> # Full system design with gas generator cycle + >>> from rocket.cycles import GasGeneratorCycle + >>> result = design_engine_system( + ... inputs=inputs, + ... cycle=GasGeneratorCycle(turbine_inlet_temp=kelvin(900)), + ... check_cooling=True, + ... ) + """ + all_warnings: list[str] = [] + + # Step 1: Compute basic engine performance and geometry + performance = compute_performance(inputs) + geometry = compute_geometry(inputs, performance) + + # Step 2: Cycle analysis (if cycle provided) + cycle_result: CyclePerformance | None = None + if cycle is not None: + cycle_result = analyze_cycle(inputs, performance, geometry, cycle) + all_warnings.extend(cycle_result.warnings) + + # Step 3: Cooling feasibility (if requested) + cooling_result: CoolingFeasibility | None = None + if check_cooling: + # Determine coolant (default to fuel) + if coolant is None: + # Try to infer fuel from engine name + coolant = _infer_coolant(inputs) + + cooling_result = check_cooling_feasibility( + inputs=inputs, + performance=performance, + geometry=geometry, + coolant=coolant, + max_wall_temp=max_wall_temp, + ) + all_warnings.extend(cooling_result.warnings) + + # Determine overall feasibility + feasible = True + if cycle_result is not None and not cycle_result.feasible: + feasible = False + if cooling_result is not None and not cooling_result.feasible: + feasible = False + + return EngineSystemResult( + inputs=inputs, + performance=performance, + geometry=geometry, + cycle=cycle_result, + cooling=cooling_result, + feasible=feasible, + warnings=all_warnings, + ) + + +def _infer_coolant(inputs: EngineInputs) -> str: + """Infer coolant from engine inputs.""" + if inputs.name: + name_upper = inputs.name.upper() + if "CH4" in name_upper or "METHANE" in name_upper or "METHALOX" in name_upper: + return "CH4" + elif "LH2" in name_upper or "HYDROGEN" in name_upper or "HYDROLOX" in name_upper: + return "LH2" + elif "RP1" in name_upper or "KEROSENE" in name_upper or "KEROLOX" in name_upper: + return "RP1" + elif "ETHANOL" in name_upper: + return "Ethanol" + + # Default to RP-1 + return "RP1" + + +@beartype +def format_system_summary(result: EngineSystemResult) -> str: + """Format complete system design results as readable string. + + Args: + result: EngineSystemResult from design_engine_system() + + Returns: + Formatted multi-line string summary + """ + name = result.inputs.name or "Unnamed Engine" + status = "✓ FEASIBLE" if result.feasible else "✗ NOT FEASIBLE" + + lines = [ + "=" * 70, + f"ENGINE SYSTEM DESIGN: {name}", + f"Overall Status: {status}", + "=" * 70, + "", + "PERFORMANCE (Ideal):", + f" Thrust (SL): {result.inputs.thrust.to('kN').value:.1f} kN", + f" Isp (SL): {result.performance.isp.value:.1f} s", + f" Isp (Vac): {result.performance.isp_vac.value:.1f} s", + f" C*: {result.performance.cstar.value:.0f} m/s", + f" Mass Flow: {result.performance.mdot.value:.2f} kg/s", + "", + "GEOMETRY:", + f" Throat Dia: {result.geometry.throat_diameter.to('m').value*100:.1f} cm", + f" Exit Dia: {result.geometry.exit_diameter.to('m').value*100:.1f} cm", + f" Chamber Dia: {result.geometry.chamber_diameter.to('m').value*100:.1f} cm", + f" Expansion Ratio: {result.geometry.expansion_ratio:.1f}", + ] + + if result.cycle is not None: + lines.extend([ + "", + f"CYCLE ({result.cycle.cycle_type.name}):", + f" Net Isp: {result.cycle.net_isp.value:.1f} s", + f" Cycle Efficiency:{result.cycle.cycle_efficiency*100:.1f}%", + f" Turbine Power: {result.cycle.turbine_power.value/1000:.0f} kW", + f" GG/Turbine Flow: {result.cycle.turbine_mass_flow.value:.2f} kg/s", + f" Tank P (Ox): {result.cycle.tank_pressure_ox.to('bar').value:.1f} bar", + f" Tank P (Fuel): {result.cycle.tank_pressure_fuel.to('bar').value:.1f} bar", + ]) + + if result.cooling is not None: + lines.extend([ + "", + "COOLING:", + f" Throat Heat Flux:{result.cooling.throat_heat_flux.value/1e6:.1f} MW/m²", + f" Max Wall Temp: {result.cooling.max_wall_temp.value:.0f} K", + f" Allowed Temp: {result.cooling.max_allowed_temp.value:.0f} K", + f" Coolant ΔT: {result.cooling.coolant_temp_rise.value:.0f} K", + f" Flow Margin: {result.cooling.flow_margin:.2f}x", + f" Pressure Drop: {result.cooling.pressure_drop.to('bar').value:.1f} bar", + ]) + + if result.warnings: + lines.extend(["", "WARNINGS:"]) + for warning in result.warnings: + lines.append(f" ⚠ {warning}") + + lines.append("=" * 70) + + return "\n".join(lines) + diff --git a/rocket/tanks.py b/rocket/tanks.py new file mode 100644 index 0000000..9a241cc --- /dev/null +++ b/rocket/tanks.py @@ -0,0 +1,76 @@ +"""Tank sizing and propellant utilities for OpenRocketEngine. + +This module provides propellant density data and tank sizing utilities. +""" + +from beartype import beartype + +# Propellant densities at ~1 atm and typical storage temperatures [kg/m³] +PROPELLANT_DENSITIES = { + # Oxidizers + "LOX": 1141.0, # Liquid oxygen @ 90K + "N2O4": 1440.0, # Nitrogen tetroxide + "N2O": 1220.0, # Nitrous oxide @ 0°C + "H2O2": 1450.0, # High-test hydrogen peroxide (90%) + "IRFNA": 1550.0, # Inhibited red fuming nitric acid + # Fuels + "LH2": 70.8, # Liquid hydrogen @ 20K + "RP1": 810.0, # Kerosene (RP-1) + "CH4": 422.8, # Liquid methane @ 111K + "LCH4": 422.8, # Alias for liquid methane + "ETHANOL": 789.0, # Ethanol + "MMH": 880.0, # Monomethylhydrazine + "UDMH": 791.0, # Unsymmetrical dimethylhydrazine + "N2H4": 1021.0, # Hydrazine + "METHANOL": 792.0, # Methanol + "ISOPROPANOL": 786.0, # Isopropyl alcohol + "JET-A": 820.0, # Jet fuel +} + + +@beartype +def get_propellant_density(propellant: str) -> float: + """Get the density of a propellant in kg/m³. + + Args: + propellant: Propellant name (e.g., "LOX", "CH4", "RP1") + + Returns: + Density in kg/m³ + + Raises: + ValueError: If propellant is not found in database + """ + propellant_upper = propellant.upper() + + if propellant_upper in PROPELLANT_DENSITIES: + return PROPELLANT_DENSITIES[propellant_upper] + + # Try common aliases + aliases = { + "O2": "LOX", + "OXYGEN": "LOX", + "METHANE": "CH4", + "KEROSENE": "RP1", + "HYDROGEN": "LH2", + "H2": "LH2", + } + + if propellant_upper in aliases: + return PROPELLANT_DENSITIES[aliases[propellant_upper]] + + available = ", ".join(sorted(PROPELLANT_DENSITIES.keys())) + raise ValueError( + f"Unknown propellant: {propellant}. Available: {available}" + ) + + +@beartype +def list_propellants() -> list[str]: + """List all available propellants in the database. + + Returns: + List of propellant names + """ + return sorted(PROPELLANT_DENSITIES.keys()) + diff --git a/rocket/thermal/__init__.py b/rocket/thermal/__init__.py new file mode 100644 index 0000000..c7595c5 --- /dev/null +++ b/rocket/thermal/__init__.py @@ -0,0 +1,57 @@ +"""Thermal analysis module for Rocket. + +This module provides tools for analyzing thermal loads on rocket engine +components and evaluating cooling system feasibility. + +Key capabilities: +- Heat flux estimation using Bartz correlation +- Regenerative cooling feasibility screening +- Wall temperature prediction +- Coolant property database + +Example: + >>> from rocket import EngineInputs, design_engine + >>> from rocket.thermal import estimate_heat_flux, check_cooling_feasibility + >>> + >>> inputs = EngineInputs.from_propellants("LOX", "CH4", ...) + >>> performance, geometry = design_engine(inputs) + >>> + >>> q_throat = estimate_heat_flux(inputs, performance, geometry, location="throat") + >>> print(f"Throat heat flux: {q_throat.value/1e6:.1f} MW/m²") + >>> + >>> cooling = check_cooling_feasibility( + ... inputs, performance, geometry, + ... coolant="CH4", + ... max_wall_temp=kelvin(800), + ... ) + >>> print(f"Cooling feasible: {cooling.feasible}") +""" + +from rocket.thermal.heat_flux import ( + adiabatic_wall_temperature, + bartz_heat_flux, + estimate_heat_flux, + heat_flux_profile, + recovery_factor, +) +from rocket.thermal.regenerative import ( + CoolingFeasibility, + CoolantProperties, + check_cooling_feasibility, + get_coolant_properties, +) + +__all__ = [ + # Heat flux estimation + "adiabatic_wall_temperature", + "bartz_heat_flux", + "estimate_heat_flux", + "heat_flux_profile", + "recovery_factor", + # Regenerative cooling + "CoolingFeasibility", + "CoolantProperties", + "check_cooling_feasibility", + "get_coolant_properties", +] + diff --git a/rocket/thermal/heat_flux.py b/rocket/thermal/heat_flux.py new file mode 100644 index 0000000..7b244f5 --- /dev/null +++ b/rocket/thermal/heat_flux.py @@ -0,0 +1,306 @@ +"""Heat flux estimation for rocket engine combustion chambers and nozzles. + +This module implements the Bartz correlation and related methods for +estimating convective heat transfer in rocket engines. + +The Bartz correlation is the industry-standard method for preliminary +heat flux estimation, derived from turbulent pipe flow correlations +modified for rocket engine conditions. + +References: + - Bartz, D.R., "A Simple Equation for Rapid Estimation of Rocket + Nozzle Convective Heat Transfer Coefficients", Jet Propulsion, 1957 + - Huzel & Huang, "Modern Engineering for Design of Liquid-Propellant + Rocket Engines", Chapter 4 +""" + +import math + +import numpy as np +from beartype import beartype + +from rocket.engine import EngineGeometry, EngineInputs, EnginePerformance +from rocket.units import Quantity, kelvin + + +@beartype +def recovery_factor(prandtl: float, laminar: bool = False) -> float: + """Calculate the recovery factor for adiabatic wall temperature. + + The recovery factor accounts for the difference between the + stagnation temperature and the actual adiabatic wall temperature + due to boundary layer effects. + + Args: + prandtl: Prandtl number of the gas [-] + laminar: If True, use laminar correlation; else turbulent + + Returns: + Recovery factor r [-], typically 0.85-0.92 for turbulent flow + """ + if laminar: + return math.sqrt(prandtl) # r = Pr^0.5 for laminar + else: + return prandtl ** (1/3) # r = Pr^(1/3) for turbulent + + +@beartype +def adiabatic_wall_temperature( + stagnation_temp: Quantity, + mach: float, + gamma: float, + recovery_factor: float, +) -> Quantity: + """Calculate adiabatic wall temperature. + + The adiabatic wall temperature is the temperature the wall would + reach if there were no heat transfer (perfectly insulated wall). + It's used as the driving temperature difference for heat flux. + + T_aw = T_0 * [1 + r * (gamma-1)/2 * M^2] / [1 + (gamma-1)/2 * M^2] + + Args: + stagnation_temp: Chamber/stagnation temperature [K] + mach: Local Mach number [-] + gamma: Ratio of specific heats [-] + recovery_factor: Recovery factor r [-] + + Returns: + Adiabatic wall temperature [K] + """ + T0 = stagnation_temp.to("K").value + gm1 = gamma - 1 + + # Temperature ratio T/T0 from isentropic relations + T_ratio = 1 / (1 + gm1/2 * mach**2) + + # Static temperature + T_static = T0 * T_ratio + + # Adiabatic wall temperature + # T_aw = T_static + r * (T0 - T_static) + T_aw = T_static + recovery_factor * (T0 - T_static) + + return kelvin(T_aw) + + +@beartype +def bartz_heat_flux( + chamber_pressure: Quantity, + chamber_temp: Quantity, + throat_diameter: Quantity, + local_diameter: Quantity, + characteristic_velocity: Quantity, + gamma: float, + molecular_weight: float, + local_mach: float, + wall_temp: Quantity | None = None, +) -> Quantity: + """Calculate convective heat flux using the Bartz correlation. + + The Bartz equation estimates the convective heat transfer coefficient: + + h = (0.026/D_t^0.2) * (mu^0.2 * cp / Pr^0.6) * (p_c / c*)^0.8 * + (D_t/R_c)^0.1 * (A_t/A)^0.9 * sigma + + where sigma is a correction factor for property variations. + + Args: + chamber_pressure: Chamber pressure [Pa] + chamber_temp: Chamber temperature [K] + throat_diameter: Throat diameter [m] + local_diameter: Local diameter at evaluation point [m] + characteristic_velocity: c* [m/s] + gamma: Ratio of specific heats [-] + molecular_weight: Molecular weight [kg/kmol] + local_mach: Local Mach number [-] + wall_temp: Wall temperature [K]. If None, estimates at 600K. + + Returns: + Heat flux [W/m²] + """ + # Extract values in SI + pc = chamber_pressure.to("Pa").value + Tc = chamber_temp.to("K").value + Dt = throat_diameter.to("m").value + D = local_diameter.to("m").value + cstar = characteristic_velocity.to("m/s").value + MW = molecular_weight + + # Wall temperature (estimate if not provided) + Tw = wall_temp.to("K").value if wall_temp else 600.0 + + # Gas properties + R_specific = 8314.46 / MW # J/(kg·K) + cp = gamma * R_specific / (gamma - 1) # J/(kg·K) + + # Estimate viscosity using Sutherland's law + # Reference: air at 273K, mu0 = 1.71e-5 Pa·s + # For combustion products, use higher reference + mu_ref = 4e-5 # Pa·s at Tc_ref + Tc_ref = 3000 # K + S = 200 # Sutherland constant (approximate for combustion products) + + mu = mu_ref * (Tc / Tc_ref) ** 1.5 * (Tc_ref + S) / (Tc + S) + + # Prandtl number + # Pr = mu * cp / k, approximate k from cp and Pr ~ 0.7-0.9 + Pr = 0.8 # Typical for combustion products + + # Area ratio + area_ratio = (D / Dt) ** 2 + + # Sigma correction factor for property variation across boundary layer + # sigma = 1 / [(Tw/Tc * (1 + (gamma-1)/2 * M^2) + 0.5)^0.68 * + # (1 + (gamma-1)/2 * M^2)^0.12] + gm1 = gamma - 1 + temp_factor = Tw / Tc * (1 + gm1/2 * local_mach**2) + 0.5 + sigma = 1 / (temp_factor ** 0.68 * (1 + gm1/2 * local_mach**2) ** 0.12) + + # Bartz correlation for heat transfer coefficient + # h = (0.026 / Dt^0.2) * (mu^0.2 * cp / Pr^0.6) * (pc/cstar)^0.8 * + # (Dt/Dt)^0.1 * (At/A)^0.9 * sigma + h = (0.026 / Dt**0.2) * (mu**0.2 * cp / Pr**0.6) * \ + (pc / cstar)**0.8 * (1/area_ratio)**0.9 * sigma + + # Calculate adiabatic wall temperature + r = recovery_factor(Pr) + T_aw = Tc * (1 + r * gm1/2 * local_mach**2) / (1 + gm1/2 * local_mach**2) + + # Heat flux + q = h * (T_aw - Tw) + + return Quantity(q, "Pa", "pressure") # W/m² = Pa·m/s, using Pa as proxy + + +@beartype +def estimate_heat_flux( + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + location: str = "throat", + wall_temp: Quantity | None = None, +) -> Quantity: + """Estimate heat flux at a specific location in the engine. + + Provides a simplified interface to the Bartz correlation. + + Args: + inputs: Engine input parameters + performance: Computed engine performance + geometry: Computed engine geometry + location: Location to evaluate: "throat", "chamber", or "exit" + wall_temp: Wall temperature [K]. If None, estimates based on location. + + Returns: + Heat flux [W/m²] + """ + # Determine local conditions based on location + if location == "throat": + local_diameter = geometry.throat_diameter + local_mach = 1.0 + default_wall_temp = 700 # K, hot at throat + elif location == "chamber": + local_diameter = geometry.chamber_diameter + local_mach = 0.1 # Low Mach in chamber + default_wall_temp = 600 # K + elif location == "exit": + local_diameter = geometry.exit_diameter + local_mach = performance.exit_mach + default_wall_temp = 400 # K, cooler at exit + else: + raise ValueError(f"Unknown location: {location}. Use 'throat', 'chamber', or 'exit'") + + if wall_temp is None: + wall_temp = kelvin(default_wall_temp) + + return bartz_heat_flux( + chamber_pressure=inputs.chamber_pressure, + chamber_temp=inputs.chamber_temp, + throat_diameter=geometry.throat_diameter, + local_diameter=local_diameter, + characteristic_velocity=performance.cstar, + gamma=inputs.gamma, + molecular_weight=inputs.molecular_weight, + local_mach=local_mach, + wall_temp=wall_temp, + ) + + +@beartype +def heat_flux_profile( + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + n_points: int = 50, + wall_temp: Quantity | None = None, +) -> tuple[list[float], list[float]]: + """Calculate heat flux profile along the engine. + + Returns heat flux from chamber through throat to exit. + + Args: + inputs: Engine input parameters + performance: Computed engine performance + geometry: Computed engine geometry + n_points: Number of points in profile + wall_temp: Wall temperature (constant along length) + + Returns: + Tuple of (x_positions, heat_fluxes) where x is normalized (0=chamber, 1=exit) + """ + # Get key dimensions + Dc = geometry.chamber_diameter.to("m").value + Dt = geometry.throat_diameter.to("m").value + De = geometry.exit_diameter.to("m").value + + # Generate normalized positions + x_norm = np.linspace(0, 1, n_points) + + # Estimate local diameter and Mach number along engine + # Simplified: linear convergent, bell divergent + diameters = [] + machs = [] + + for x in x_norm: + if x < 0.3: + # Chamber region + D = Dc + M = 0.1 + x * 0.3 # Low, slowly increasing + elif x < 0.5: + # Convergent section + frac = (x - 0.3) / 0.2 + D = Dc - frac * (Dc - Dt) + M = 0.1 + frac * 0.9 + elif x < 0.55: + # Throat region + D = Dt + M = 1.0 + else: + # Divergent section + frac = (x - 0.55) / 0.45 + D = Dt + frac * (De - Dt) + # Simple approximation for supersonic Mach + M = 1.0 + frac * (performance.exit_mach - 1.0) + + diameters.append(D) + machs.append(max(0.1, M)) + + # Calculate heat flux at each point + heat_fluxes = [] + for D, M in zip(diameters, machs, strict=True): + q = bartz_heat_flux( + chamber_pressure=inputs.chamber_pressure, + chamber_temp=inputs.chamber_temp, + throat_diameter=geometry.throat_diameter, + local_diameter=Quantity(D, "m", "length"), + characteristic_velocity=performance.cstar, + gamma=inputs.gamma, + molecular_weight=inputs.molecular_weight, + local_mach=M, + wall_temp=wall_temp or kelvin(600), + ) + heat_fluxes.append(q.value) + + return list(x_norm), heat_fluxes + diff --git a/rocket/thermal/regenerative.py b/rocket/thermal/regenerative.py new file mode 100644 index 0000000..ac9e84e --- /dev/null +++ b/rocket/thermal/regenerative.py @@ -0,0 +1,452 @@ +"""Regenerative cooling feasibility analysis. + +Regenerative cooling uses the fuel (or oxidizer) as a coolant, flowing +through channels in the chamber/nozzle wall before injection. This is +the most common cooling method for high-performance rocket engines. + +This module provides screening-level analysis to determine if a given +engine design can be regeneratively cooled within material limits. + +Key considerations: +- Heat flux at the throat (highest) +- Coolant heat capacity and flow rate +- Wall material temperature limits +- Coolant-side pressure drop + +References: + - Huzel & Huang, Chapter 4 + - Sutton & Biblarz, Chapter 8 +""" + +import math +from dataclasses import dataclass + +from beartype import beartype + +from rocket.engine import EngineGeometry, EngineInputs, EnginePerformance +from rocket.thermal.heat_flux import estimate_heat_flux +from rocket.units import Quantity, kelvin, pascals + + +# ============================================================================= +# Coolant Properties Database +# ============================================================================= + + +@beartype +@dataclass(frozen=True, slots=True) +class CoolantProperties: + """Thermophysical properties of a coolant. + + Properties are approximate values at typical operating conditions. + For detailed design, use property tables or CoolProp. + + Attributes: + name: Coolant name + density: Liquid density [kg/m³] + specific_heat: Specific heat capacity [J/(kg·K)] + thermal_conductivity: Thermal conductivity [W/(m·K)] + viscosity: Dynamic viscosity [Pa·s] + boiling_point: Boiling point at 1 atm [K] + max_temp: Maximum recommended temperature before decomposition [K] + """ + + name: str + density: float + specific_heat: float + thermal_conductivity: float + viscosity: float + boiling_point: float + max_temp: float + + +# Coolant property database +# Values are approximate at typical inlet conditions +COOLANT_DATABASE: dict[str, CoolantProperties] = { + "RP1": CoolantProperties( + name="RP-1 (Kerosene)", + density=810.0, + specific_heat=2000.0, + thermal_conductivity=0.12, + viscosity=0.0015, + boiling_point=490.0, + max_temp=600.0, # Coking limit + ), + "CH4": CoolantProperties( + name="Liquid Methane", + density=422.0, + specific_heat=3500.0, + thermal_conductivity=0.19, + viscosity=0.00012, + boiling_point=111.0, + max_temp=500.0, # Before significant decomposition + ), + "LH2": CoolantProperties( + name="Liquid Hydrogen", + density=70.8, + specific_heat=14300.0, + thermal_conductivity=0.10, + viscosity=0.000013, + boiling_point=20.0, + max_temp=300.0, # Stays liquid/supercritical + ), + "Ethanol": CoolantProperties( + name="Ethanol", + density=789.0, + specific_heat=2440.0, + thermal_conductivity=0.17, + viscosity=0.0011, + boiling_point=351.0, + max_temp=500.0, + ), + "N2O4": CoolantProperties( + name="Nitrogen Tetroxide", + density=1450.0, + specific_heat=1560.0, + thermal_conductivity=0.12, + viscosity=0.0004, + boiling_point=294.0, + max_temp=400.0, + ), + "MMH": CoolantProperties( + name="Monomethylhydrazine", + density=878.0, + specific_heat=2920.0, + thermal_conductivity=0.22, + viscosity=0.0008, + boiling_point=360.0, + max_temp=450.0, + ), +} + + +@beartype +def get_coolant_properties(coolant: str) -> CoolantProperties: + """Get properties for a coolant. + + Args: + coolant: Coolant name (e.g., "RP1", "CH4", "LH2") + + Returns: + CoolantProperties for the coolant + + Raises: + ValueError: If coolant not found in database + """ + # Normalize name + name = coolant.upper().replace("-", "").replace(" ", "") + + for key, props in COOLANT_DATABASE.items(): + if key.upper() == name: + return props + + available = list(COOLANT_DATABASE.keys()) + raise ValueError(f"Unknown coolant '{coolant}'. Available: {available}") + + +# ============================================================================= +# Cooling Feasibility Analysis +# ============================================================================= + + +@beartype +@dataclass(frozen=True, slots=True) +class CoolingFeasibility: + """Results of regenerative cooling feasibility analysis. + + Attributes: + feasible: Whether cooling is feasible within constraints + max_wall_temp: Maximum predicted wall temperature [K] + max_allowed_temp: Maximum allowed wall temperature [K] + coolant_temp_rise: Temperature rise of coolant [K] + coolant_outlet_temp: Predicted coolant outlet temperature [K] + throat_heat_flux: Heat flux at throat [W/m²] + total_heat_load: Total heat load to coolant [W] + required_coolant_flow: Minimum required coolant flow [kg/s] + available_coolant_flow: Available coolant flow (fuel or ox) [kg/s] + flow_margin: Ratio of available/required flow [-] + pressure_drop: Estimated coolant-side pressure drop [Pa] + channel_velocity: Estimated coolant velocity in channels [m/s] + warnings: List of warnings or concerns + """ + + feasible: bool + max_wall_temp: Quantity + max_allowed_temp: Quantity + coolant_temp_rise: Quantity + coolant_outlet_temp: Quantity + throat_heat_flux: Quantity + total_heat_load: Quantity + required_coolant_flow: Quantity + available_coolant_flow: Quantity + flow_margin: float + pressure_drop: Quantity + channel_velocity: float + warnings: list[str] + + +@beartype +def check_cooling_feasibility( + inputs: EngineInputs, + performance: EnginePerformance, + geometry: EngineGeometry, + coolant: str, + coolant_inlet_temp: Quantity | None = None, + max_wall_temp: Quantity | None = None, + wall_material: str = "copper_alloy", + num_channels: int | None = None, + channel_aspect_ratio: float = 3.0, +) -> CoolingFeasibility: + """Check if regenerative cooling is feasible for this engine design. + + This is a screening-level analysis that estimates whether the engine + can be cooled within material limits using the available coolant flow. + + Args: + inputs: Engine input parameters + performance: Computed engine performance + geometry: Computed engine geometry + coolant: Coolant name (typically the fuel: "RP1", "CH4", "LH2") + coolant_inlet_temp: Coolant inlet temperature [K]. Defaults to storage temp. + max_wall_temp: Maximum allowed wall temperature [K]. Defaults based on material. + wall_material: Wall material for thermal limits + num_channels: Number of cooling channels. If None, estimated. + channel_aspect_ratio: Channel height/width ratio + + Returns: + CoolingFeasibility assessment + """ + warnings: list[str] = [] + + # Get coolant properties + try: + coolant_props = get_coolant_properties(coolant) + except ValueError: + # Use generic properties if unknown + coolant_props = CoolantProperties( + name=coolant, + density=800.0, + specific_heat=2000.0, + thermal_conductivity=0.15, + viscosity=0.001, + boiling_point=300.0, + max_temp=500.0, + ) + warnings.append(f"Unknown coolant '{coolant}', using generic properties") + + # Set default inlet temperature (storage temperature) + if coolant_inlet_temp is None: + # Cryogenic propellants near boiling point, storables at ~300K + if coolant.upper() in ["LH2", "CH4", "LOX"]: + T_inlet = coolant_props.boiling_point + 10 # Just above boiling + else: + T_inlet = 300.0 # Room temperature + else: + T_inlet = coolant_inlet_temp.to("K").value + + # Set default max wall temperature based on material + if max_wall_temp is None: + wall_temps = { + "copper_alloy": 800, # GRCop-84, NARloy-Z + "nickel_alloy": 1000, # Inconel 718 + "steel": 700, # Stainless steel + "niobium": 1500, # Refractory metal + } + T_wall_max = wall_temps.get(wall_material, 800) + else: + T_wall_max = max_wall_temp.to("K").value + + # Estimate heat flux at throat (worst case) + q_throat = estimate_heat_flux( + inputs, performance, geometry, + location="throat", + wall_temp=kelvin(T_wall_max * 0.9), # Assume wall near limit + ) + + # Also get chamber and exit heat fluxes for total heat load + q_chamber = estimate_heat_flux(inputs, performance, geometry, location="chamber") + q_exit = estimate_heat_flux(inputs, performance, geometry, location="exit") + + # Estimate total heat load + # Simplified: use average heat flux × total surface area + Dt = geometry.throat_diameter.to("m").value + Dc = geometry.chamber_diameter.to("m").value + De = geometry.exit_diameter.to("m").value + Lc = geometry.chamber_length.to("m").value + Ln = geometry.nozzle_length.to("m").value + + # Surface areas (approximate) + A_chamber = math.pi * Dc * Lc + A_convergent = math.pi * (Dc + Dt) / 2 * (Dc - Dt) / (2 * math.tan(math.radians(45))) * 0.5 + A_throat = math.pi * Dt * Dt * 0.1 # Small throat region + A_divergent = math.pi * (Dt + De) / 2 * Ln * 0.7 # Approximate bell surface + + # Average heat fluxes for each region (throat is highest) + q_avg_chamber = q_chamber.value * 0.3 # Lower than throat + q_avg_convergent = (q_chamber.value + q_throat.value) / 2 + q_avg_throat = q_throat.value + q_avg_divergent = (q_throat.value + q_exit.value) / 2 + + # Total heat load + Q_total = ( + q_avg_chamber * A_chamber + + q_avg_convergent * A_convergent + + q_avg_throat * A_throat + + q_avg_divergent * A_divergent + ) + + # Available coolant flow (assume fuel is coolant) + mdot_coolant_available = performance.mdot_fuel.to("kg/s").value + + # Required coolant flow to absorb heat without exceeding temperature limit + # Q = mdot * cp * delta_T + # delta_T_max = T_coolant_max - T_inlet + T_coolant_max = min(coolant_props.max_temp, T_wall_max - 100) # Stay below wall + delta_T_max = T_coolant_max - T_inlet + + if delta_T_max <= 0: + warnings.append("Coolant inlet temperature exceeds maximum allowable") + delta_T_max = 100 # Use minimum for calculation + + mdot_coolant_required = Q_total / (coolant_props.specific_heat * delta_T_max) + + # Flow margin + flow_margin = mdot_coolant_available / mdot_coolant_required if mdot_coolant_required > 0 else float('inf') + + # Actual temperature rise with available flow + if mdot_coolant_available > 0: + delta_T_actual = Q_total / (mdot_coolant_available * coolant_props.specific_heat) + else: + delta_T_actual = float('inf') + + T_coolant_out = T_inlet + delta_T_actual + + # Estimate wall temperature + # T_wall = T_coolant + Q/(h_coolant * A) + # For screening, use correlation: T_wall ~ T_coolant + q * (t_wall / k_wall + 1/h_coolant) + # Simplified: wall runs ~100-200K above coolant + T_wall_estimate = T_coolant_out + 150 # K above coolant + + # Pressure drop estimation + # Number of channels (estimate if not provided) + if num_channels is None: + # Roughly 1 channel per mm of circumference at throat + num_channels = max(20, int(math.pi * Dt * 1000)) + + # Channel dimensions (rough estimate) + channel_width = (math.pi * Dt) / num_channels * 0.6 # 60% channel, 40% rib + channel_height = channel_width * channel_aspect_ratio + channel_area = channel_width * channel_height + + # Coolant velocity + total_channel_area = num_channels * channel_area + v_coolant = mdot_coolant_available / (coolant_props.density * total_channel_area) + + # Pressure drop (Darcy-Weisbach approximation) + # ΔP = f * (L/D_h) * (ρ * v²/2) + D_h = 4 * channel_area / (2 * (channel_width + channel_height)) # Hydraulic diameter + Re = coolant_props.density * v_coolant * D_h / coolant_props.viscosity + f = 0.316 / Re**0.25 if Re > 2300 else 64 / max(Re, 100) # Friction factor + + L_total = Lc + Ln # Total cooled length + dp = f * (L_total / D_h) * (coolant_props.density * v_coolant**2 / 2) + + # Add losses for bends, manifolds + dp *= 1.5 + + # Feasibility assessment + feasible = True + + if T_wall_estimate > T_wall_max: + feasible = False + warnings.append( + f"Estimated wall temp {T_wall_estimate:.0f}K exceeds limit {T_wall_max:.0f}K" + ) + + if flow_margin < 1.0: + feasible = False + warnings.append( + f"Insufficient coolant flow: need {mdot_coolant_required:.2f} kg/s, " + f"have {mdot_coolant_available:.2f} kg/s" + ) + + if T_coolant_out > coolant_props.max_temp: + warnings.append( + f"Coolant outlet temp {T_coolant_out:.0f}K exceeds max {coolant_props.max_temp:.0f}K" + ) + + if v_coolant > 50: + warnings.append(f"High coolant velocity {v_coolant:.1f} m/s may cause erosion") + + if dp > 5e6: + warnings.append(f"High pressure drop {dp/1e6:.1f} MPa") + + # Heat flux at throat check + if q_throat.value > 50e6: # > 50 MW/m² + warnings.append( + f"Very high throat heat flux {q_throat.value/1e6:.1f} MW/m² - " + "film cooling may be needed" + ) + + return CoolingFeasibility( + feasible=feasible, + max_wall_temp=kelvin(T_wall_estimate), + max_allowed_temp=kelvin(T_wall_max), + coolant_temp_rise=kelvin(delta_T_actual), + coolant_outlet_temp=kelvin(T_coolant_out), + throat_heat_flux=q_throat, + total_heat_load=Quantity(Q_total, "N", "force"), # W, using N as proxy + required_coolant_flow=Quantity(mdot_coolant_required, "kg/s", "mass_flow"), + available_coolant_flow=Quantity(mdot_coolant_available, "kg/s", "mass_flow"), + flow_margin=flow_margin, + pressure_drop=pascals(dp), + channel_velocity=v_coolant, + warnings=warnings, + ) + + +@beartype +def format_cooling_summary(result: CoolingFeasibility) -> str: + """Format cooling feasibility results as readable string. + + Args: + result: CoolingFeasibility from check_cooling_feasibility() + + Returns: + Formatted multi-line string + """ + status = "✓ FEASIBLE" if result.feasible else "✗ NOT FEASIBLE" + + lines = [ + f"{'=' * 60}", + f"REGENERATIVE COOLING ANALYSIS", + f"Status: {status}", + f"{'=' * 60}", + "", + "THERMAL:", + f" Throat Heat Flux: {result.throat_heat_flux.value/1e6:.1f} MW/m²", + f" Total Heat Load: {result.total_heat_load.value/1e6:.2f} MW", + f" Max Wall Temp: {result.max_wall_temp.value:.0f} K", + f" Allowed Wall Temp: {result.max_allowed_temp.value:.0f} K", + "", + "COOLANT:", + f" Temperature Rise: {result.coolant_temp_rise.value:.0f} K", + f" Outlet Temperature: {result.coolant_outlet_temp.value:.0f} K", + f" Required Flow: {result.required_coolant_flow.value:.2f} kg/s", + f" Available Flow: {result.available_coolant_flow.value:.2f} kg/s", + f" Flow Margin: {result.flow_margin:.2f}x", + "", + "HYDRAULICS:", + f" Channel Velocity: {result.channel_velocity:.1f} m/s", + f" Pressure Drop: {result.pressure_drop.to('bar').value:.1f} bar", + ] + + if result.warnings: + lines.extend(["", "WARNINGS:"]) + for warning in result.warnings: + lines.append(f" ⚠ {warning}") + + lines.append(f"{'=' * 60}") + + return "\n".join(lines) + diff --git a/rocket/units.py b/rocket/units.py new file mode 100644 index 0000000..02af45d --- /dev/null +++ b/rocket/units.py @@ -0,0 +1,651 @@ +"""Units module for OpenRocketEngine. + +Provides a Quantity class for type-safe physical quantities with unit conversion. +All physical values in the library should use Quantity, never bare floats. + +Design principles: +- Explicit over implicit: all conversions require calling .to() +- Type safe: beartype checks at runtime +- Immutable: frozen dataclasses prevent accidental mutation +- No magic: clear, predictable behavior +""" + +import math +from dataclasses import dataclass + +from beartype import beartype + +# ============================================================================= +# Dimension and Unit Definitions +# ============================================================================= + +# Dimensions represent physical quantities (length, mass, time, etc.) +# Each dimension has a base SI unit + +DIMENSIONS = { + "length": "m", + "mass": "kg", + "time": "s", + "temperature": "K", + "force": "N", + "pressure": "Pa", + "velocity": "m/s", + "area": "m^2", + "volume": "m^3", + "mass_flow": "kg/s", + "density": "kg/m^3", + "specific_impulse": "s", + "dimensionless": "1", +} + +# Conversion factors TO base SI unit +# e.g., 1 ft = 0.3048 m, so CONVERSIONS["ft"] = 0.3048 +CONVERSIONS: dict[str, tuple[float, str]] = { + # Length + "m": (1.0, "length"), + "cm": (0.01, "length"), + "mm": (0.001, "length"), + "km": (1000.0, "length"), + "ft": (0.3048, "length"), + "in": (0.0254, "length"), + "inch": (0.0254, "length"), + "inches": (0.0254, "length"), + # Mass + "kg": (1.0, "mass"), + "g": (0.001, "mass"), + "lbm": (0.453592, "mass"), + "slug": (14.5939, "mass"), + # Time + "s": (1.0, "time"), + "ms": (0.001, "time"), + "min": (60.0, "time"), + "hr": (3600.0, "time"), + # Temperature (special - requires offset handling) + "K": (1.0, "temperature"), + "R": (5 / 9, "temperature"), # Rankine to Kelvin (multiply only, offset handled separately) + # Force + "N": (1.0, "force"), + "kN": (1000.0, "force"), + "MN": (1e6, "force"), + "lbf": (4.44822, "force"), + "kgf": (9.80665, "force"), + # Pressure + "Pa": (1.0, "pressure"), + "kPa": (1000.0, "pressure"), + "MPa": (1e6, "pressure"), + "bar": (1e5, "pressure"), + "atm": (101325.0, "pressure"), + "psi": (6894.76, "pressure"), + "psia": (6894.76, "pressure"), + # Velocity + "m/s": (1.0, "velocity"), + "km/s": (1000.0, "velocity"), + "ft/s": (0.3048, "velocity"), + # Area + "m^2": (1.0, "area"), + "cm^2": (1e-4, "area"), + "mm^2": (1e-6, "area"), + "ft^2": (0.092903, "area"), + "in^2": (0.00064516, "area"), + # Volume + "m^3": (1.0, "volume"), + "L": (0.001, "volume"), + "cm^3": (1e-6, "volume"), + "ft^3": (0.0283168, "volume"), + "in^3": (1.6387e-5, "volume"), + # Mass flow rate + "kg/s": (1.0, "mass_flow"), + "lbm/s": (0.453592, "mass_flow"), + # Density + "kg/m^3": (1.0, "density"), + "lbm/ft^3": (16.0185, "density"), + # Power + "W": (1.0, "power"), + "kW": (1000.0, "power"), + "MW": (1e6, "power"), + "hp": (745.7, "power"), # Mechanical horsepower + # Specific impulse (time dimension but special meaning) + # Note: Isp in seconds is the same in SI and Imperial + # Dimensionless + "1": (1.0, "dimensionless"), + "": (1.0, "dimensionless"), +} + + +def _get_dimension(unit: str) -> str: + """Get the dimension for a unit string.""" + if unit not in CONVERSIONS: + raise ValueError(f"Unknown unit: {unit!r}") + return CONVERSIONS[unit][1] + + +def _get_conversion_factor(unit: str) -> float: + """Get the conversion factor to SI base unit.""" + if unit not in CONVERSIONS: + raise ValueError(f"Unknown unit: {unit!r}") + return CONVERSIONS[unit][0] + + +def _convert(value: float, from_unit: str, to_unit: str) -> float: + """Convert a value between units of the same dimension.""" + from_dim = _get_dimension(from_unit) + to_dim = _get_dimension(to_unit) + + if from_dim != to_dim: + raise ValueError( + f"Cannot convert between different dimensions: {from_dim} and {to_dim}" + ) + + # Convert to SI base, then to target + si_value = value * _get_conversion_factor(from_unit) + return si_value / _get_conversion_factor(to_unit) + + +# ============================================================================= +# Quantity Class +# ============================================================================= + + +@beartype +@dataclass(frozen=True, slots=True) +class Quantity: + """A physical quantity with value, unit, and dimension. + + Quantities are immutable and support arithmetic operations that respect + dimensional analysis. + + Examples: + >>> thrust = Quantity(50000, "N", "force") + >>> thrust_lbf = thrust.to("lbf") + >>> print(thrust_lbf) + Quantity(11240.45 lbf) + + >>> length = meters(2.5) + >>> area = length * length # Returns Quantity with area dimension + """ + + value: float | int + unit: str + dimension: str + + def __post_init__(self) -> None: + """Validate that unit matches dimension.""" + if self.unit not in CONVERSIONS: + raise ValueError(f"Unknown unit: {self.unit!r}") + expected_dim = CONVERSIONS[self.unit][1] + if self.dimension != expected_dim: + raise ValueError( + f"Unit {self.unit!r} has dimension {expected_dim!r}, " + f"but {self.dimension!r} was specified" + ) + + def to(self, target_unit: str) -> "Quantity": + """Convert to a different unit of the same dimension. + + Args: + target_unit: The unit to convert to + + Returns: + A new Quantity with the converted value and new unit + + Raises: + ValueError: If target_unit is incompatible dimension + """ + new_value = _convert(self.value, self.unit, target_unit) + return Quantity(new_value, target_unit, self.dimension) + + def to_si(self) -> "Quantity": + """Convert to SI base unit for this dimension.""" + si_unit = DIMENSIONS[self.dimension] + return self.to(si_unit) + + @property + def si_value(self) -> float: + """Get the value in SI base units without creating new Quantity.""" + return self.value * _get_conversion_factor(self.unit) + + def __repr__(self) -> str: + return f"Quantity({self.value:.6g} {self.unit})" + + def __str__(self) -> str: + return f"{self.value:.6g} {self.unit}" + + # ------------------------------------------------------------------------- + # Arithmetic Operations + # ------------------------------------------------------------------------- + + def __add__(self, other: "Quantity") -> "Quantity": + """Add two quantities of the same dimension.""" + if not isinstance(other, Quantity): + raise TypeError(f"Cannot add Quantity and {type(other).__name__}") + if self.dimension != other.dimension: + raise ValueError( + f"Cannot add quantities with different dimensions: " + f"{self.dimension} and {other.dimension}" + ) + # Convert other to same unit as self, then add + other_converted = other.to(self.unit) + return Quantity(self.value + other_converted.value, self.unit, self.dimension) + + def __sub__(self, other: "Quantity") -> "Quantity": + """Subtract two quantities of the same dimension.""" + if not isinstance(other, Quantity): + raise TypeError(f"Cannot subtract Quantity and {type(other).__name__}") + if self.dimension != other.dimension: + raise ValueError( + f"Cannot subtract quantities with different dimensions: " + f"{self.dimension} and {other.dimension}" + ) + other_converted = other.to(self.unit) + return Quantity(self.value - other_converted.value, self.unit, self.dimension) + + def __mul__(self, other: "Quantity | float | int") -> "Quantity": + """Multiply by a scalar or another quantity.""" + if isinstance(other, (int, float)): + return Quantity(self.value * other, self.unit, self.dimension) + if isinstance(other, Quantity): + # Dimensional multiplication - result dimension depends on operands + new_dim, new_unit = _multiply_dimensions( + self.dimension, self.unit, other.dimension, other.unit + ) + new_value = self.si_value * other.si_value + # Convert back from SI to the derived unit + new_value = new_value / _get_conversion_factor(new_unit) + return Quantity(new_value, new_unit, new_dim) + raise TypeError(f"Cannot multiply Quantity by {type(other).__name__}") + + def __rmul__(self, other: float | int) -> "Quantity": + """Right multiply by scalar.""" + if isinstance(other, (int, float)): + return Quantity(self.value * other, self.unit, self.dimension) + raise TypeError(f"Cannot multiply {type(other).__name__} by Quantity") + + def __truediv__(self, other: "Quantity | float | int") -> "Quantity": + """Divide by a scalar or another quantity.""" + if isinstance(other, (int, float)): + return Quantity(self.value / other, self.unit, self.dimension) + if isinstance(other, Quantity): + new_dim, new_unit = _divide_dimensions( + self.dimension, self.unit, other.dimension, other.unit + ) + new_value = self.si_value / other.si_value + new_value = new_value / _get_conversion_factor(new_unit) + return Quantity(new_value, new_unit, new_dim) + raise TypeError(f"Cannot divide Quantity by {type(other).__name__}") + + def __rtruediv__(self, other: float | int) -> "Quantity": + """Right division (scalar / Quantity) - returns inverse dimension.""" + if isinstance(other, (int, float)): + # This would create an inverse dimension which we don't fully support + # For now, raise an error + raise TypeError( + "Division of scalar by Quantity not supported. " + "Use explicit inverse units instead." + ) + raise TypeError(f"Cannot divide {type(other).__name__} by Quantity") + + def __neg__(self) -> "Quantity": + """Negate the quantity.""" + return Quantity(-self.value, self.unit, self.dimension) + + def __pos__(self) -> "Quantity": + """Positive (returns copy).""" + return Quantity(self.value, self.unit, self.dimension) + + def __abs__(self) -> "Quantity": + """Absolute value.""" + return Quantity(abs(self.value), self.unit, self.dimension) + + # ------------------------------------------------------------------------- + # Comparison Operations + # ------------------------------------------------------------------------- + + def __eq__(self, other: object) -> bool: + """Check equality (compares SI values for same dimension).""" + if not isinstance(other, Quantity): + return NotImplemented + if self.dimension != other.dimension: + return False + # Compare in SI units to handle unit differences + return math.isclose(self.si_value, other.si_value, rel_tol=1e-9) + + def __lt__(self, other: "Quantity") -> bool: + if not isinstance(other, Quantity): + raise TypeError(f"Cannot compare Quantity with {type(other).__name__}") + if self.dimension != other.dimension: + raise ValueError("Cannot compare quantities with different dimensions") + return self.si_value < other.si_value + + def __le__(self, other: "Quantity") -> bool: + return self == other or self < other + + def __gt__(self, other: "Quantity") -> bool: + if not isinstance(other, Quantity): + raise TypeError(f"Cannot compare Quantity with {type(other).__name__}") + if self.dimension != other.dimension: + raise ValueError("Cannot compare quantities with different dimensions") + return self.si_value > other.si_value + + def __ge__(self, other: "Quantity") -> bool: + return self == other or self > other + + def __hash__(self) -> int: + """Hash based on SI value and dimension for consistency.""" + return hash((round(self.si_value, 9), self.dimension)) + + +# ============================================================================= +# Dimension Algebra +# ============================================================================= + +# Multiplication table for dimensions +_MULT_TABLE: dict[tuple[str, str], str] = { + ("length", "length"): "area", + ("area", "length"): "volume", + ("length", "area"): "volume", + ("velocity", "time"): "length", + ("mass", "velocity"): "force", # Approximation: momentum + ("force", "length"): "force", # Work/energy - simplified + ("pressure", "area"): "force", + ("mass_flow", "velocity"): "force", + ("mass_flow", "time"): "mass", + ("density", "volume"): "mass", + ("dimensionless", "length"): "length", + ("dimensionless", "mass"): "mass", + ("dimensionless", "force"): "force", + ("dimensionless", "pressure"): "pressure", + ("dimensionless", "velocity"): "velocity", + ("dimensionless", "area"): "area", + ("dimensionless", "volume"): "volume", + ("dimensionless", "time"): "time", + ("dimensionless", "temperature"): "temperature", + ("dimensionless", "mass_flow"): "mass_flow", + ("dimensionless", "density"): "density", + ("dimensionless", "dimensionless"): "dimensionless", +} + +# Division table for dimensions +_DIV_TABLE: dict[tuple[str, str], str] = { + ("area", "length"): "length", + ("volume", "length"): "area", + ("volume", "area"): "length", + ("length", "time"): "velocity", + ("velocity", "time"): "velocity", # acceleration - simplified + ("mass", "volume"): "density", + ("mass", "time"): "mass_flow", + ("force", "area"): "pressure", + ("force", "mass"): "velocity", # acceleration simplified + ("force", "pressure"): "area", + ("force", "velocity"): "mass_flow", + ("length", "length"): "dimensionless", + ("mass", "mass"): "dimensionless", + ("force", "force"): "dimensionless", + ("pressure", "pressure"): "dimensionless", + ("area", "area"): "dimensionless", + ("volume", "volume"): "dimensionless", + ("velocity", "velocity"): "dimensionless", + ("time", "time"): "dimensionless", + ("dimensionless", "dimensionless"): "dimensionless", +} + + +def _multiply_dimensions( + dim1: str, unit1: str, dim2: str, unit2: str +) -> tuple[str, str]: + """Determine result dimension and unit for multiplication.""" + # Check both orderings + key = (dim1, dim2) + if key in _MULT_TABLE: + result_dim = _MULT_TABLE[key] + elif (dim2, dim1) in _MULT_TABLE: + result_dim = _MULT_TABLE[(dim2, dim1)] + else: + raise ValueError( + f"Multiplication of {dim1} and {dim2} not supported. " + "Result dimension is ambiguous." + ) + + result_unit = DIMENSIONS[result_dim] + return result_dim, result_unit + + +def _divide_dimensions(dim1: str, unit1: str, dim2: str, unit2: str) -> tuple[str, str]: + """Determine result dimension and unit for division.""" + key = (dim1, dim2) + if key in _DIV_TABLE: + result_dim = _DIV_TABLE[key] + else: + raise ValueError( + f"Division of {dim1} by {dim2} not supported. " + "Result dimension is ambiguous." + ) + + result_unit = DIMENSIONS[result_dim] + return result_dim, result_unit + + +# ============================================================================= +# Factory Functions - Clear, Explicit Quantity Creation +# ============================================================================= + + +@beartype +def meters(value: float | int) -> Quantity: + """Create a length quantity in meters.""" + return Quantity(value, "m", "length") + + +@beartype +def centimeters(value: float | int) -> Quantity: + """Create a length quantity in centimeters.""" + return Quantity(value, "cm", "length") + + +@beartype +def millimeters(value: float | int) -> Quantity: + """Create a length quantity in millimeters.""" + return Quantity(value, "mm", "length") + + +@beartype +def feet(value: float | int) -> Quantity: + """Create a length quantity in feet.""" + return Quantity(value, "ft", "length") + + +@beartype +def inches(value: float | int) -> Quantity: + """Create a length quantity in inches.""" + return Quantity(value, "in", "length") + + +@beartype +def kilograms(value: float | int) -> Quantity: + """Create a mass quantity in kilograms.""" + return Quantity(value, "kg", "mass") + + +@beartype +def pounds_mass(value: float | int) -> Quantity: + """Create a mass quantity in pounds-mass.""" + return Quantity(value, "lbm", "mass") + + +@beartype +def seconds(value: float | int) -> Quantity: + """Create a time quantity in seconds.""" + return Quantity(value, "s", "time") + + +@beartype +def kelvin(value: float | int) -> Quantity: + """Create a temperature quantity in Kelvin.""" + return Quantity(value, "K", "temperature") + + +@beartype +def rankine(value: float | int) -> Quantity: + """Create a temperature quantity in Rankine.""" + return Quantity(value, "R", "temperature") + + +@beartype +def newtons(value: float | int) -> Quantity: + """Create a force quantity in Newtons.""" + return Quantity(value, "N", "force") + + +@beartype +def kilonewtons(value: float | int) -> Quantity: + """Create a force quantity in kilonewtons.""" + return Quantity(value, "kN", "force") + + +@beartype +def pounds_force(value: float | int) -> Quantity: + """Create a force quantity in pounds-force.""" + return Quantity(value, "lbf", "force") + + +@beartype +def pascals(value: float | int) -> Quantity: + """Create a pressure quantity in Pascals.""" + return Quantity(value, "Pa", "pressure") + + +@beartype +def kilopascals(value: float | int) -> Quantity: + """Create a pressure quantity in kilopascals.""" + return Quantity(value, "kPa", "pressure") + + +@beartype +def megapascals(value: float | int) -> Quantity: + """Create a pressure quantity in megapascals.""" + return Quantity(value, "MPa", "pressure") + + +@beartype +def bar(value: float | int) -> Quantity: + """Create a pressure quantity in bar.""" + return Quantity(value, "bar", "pressure") + + +@beartype +def atmospheres(value: float | int) -> Quantity: + """Create a pressure quantity in atmospheres.""" + return Quantity(value, "atm", "pressure") + + +@beartype +def psi(value: float | int) -> Quantity: + """Create a pressure quantity in psi.""" + return Quantity(value, "psi", "pressure") + + +@beartype +def meters_per_second(value: float | int) -> Quantity: + """Create a velocity quantity in m/s.""" + return Quantity(value, "m/s", "velocity") + + +@beartype +def feet_per_second(value: float | int) -> Quantity: + """Create a velocity quantity in ft/s.""" + return Quantity(value, "ft/s", "velocity") + + +@beartype +def square_meters(value: float | int) -> Quantity: + """Create an area quantity in m^2.""" + return Quantity(value, "m^2", "area") + + +@beartype +def square_centimeters(value: float | int) -> Quantity: + """Create an area quantity in cm^2.""" + return Quantity(value, "cm^2", "area") + + +@beartype +def square_inches(value: float | int) -> Quantity: + """Create an area quantity in in^2.""" + return Quantity(value, "in^2", "area") + + +@beartype +def cubic_meters(value: float | int) -> Quantity: + """Create a volume quantity in m^3.""" + return Quantity(value, "m^3", "volume") + + +@beartype +def liters(value: float | int) -> Quantity: + """Create a volume quantity in liters.""" + return Quantity(value, "L", "volume") + + +@beartype +def kg_per_second(value: float | int) -> Quantity: + """Create a mass flow rate quantity in kg/s.""" + return Quantity(value, "kg/s", "mass_flow") + + +@beartype +def lbm_per_second(value: float | int) -> Quantity: + """Create a mass flow rate quantity in lbm/s.""" + return Quantity(value, "lbm/s", "mass_flow") + + +@beartype +def kg_per_cubic_meter(value: float | int) -> Quantity: + """Create a density quantity in kg/m^3.""" + return Quantity(value, "kg/m^3", "density") + + +@beartype +def dimensionless(value: float | int) -> Quantity: + """Create a dimensionless quantity.""" + return Quantity(value, "1", "dimensionless") + + +@beartype +def watts(value: float | int) -> Quantity: + """Create a power quantity in Watts.""" + return Quantity(value, "W", "power") + + +@beartype +def kilowatts(value: float | int) -> Quantity: + """Create a power quantity in kilowatts.""" + return Quantity(value, "kW", "power") + + +@beartype +def megawatts(value: float | int) -> Quantity: + """Create a power quantity in megawatts.""" + return Quantity(value, "MW", "power") + + +@beartype +def horsepower(value: float | int) -> Quantity: + """Create a power quantity in horsepower.""" + return Quantity(value, "hp", "power") + + +# ============================================================================= +# Constants +# ============================================================================= + +# Standard gravity +G0_SI = meters_per_second(9.80665) +G0_IMP = feet_per_second(32.174) + +# Standard atmospheric pressure +ATM_SI = pascals(101325.0) +ATM_IMP = psi(14.696) + +# Universal gas constant +R_UNIVERSAL_SI = 8314.46 # J/(kmol·K) - stored as float, used in calculations +R_UNIVERSAL_IMP = 1545.35 # ft·lbf/(lbmol·R) + diff --git a/uv.lock b/uv.lock index 8f3d3cf..e2cd241 100644 --- a/uv.lock +++ b/uv.lock @@ -546,38 +546,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/ee/346fa473e666fe14c52fcdd19ec2424157290a032d4c41f98127bfb31ac7/numpy-2.3.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425", size = 12967213, upload-time = "2025-11-16T22:52:39.38Z" }, ] -[[package]] -name = "openrocketengine" -version = "0.2.0" -source = { editable = "." } -dependencies = [ - { name = "beartype" }, - { name = "matplotlib" }, - { name = "numba" }, - { name = "numpy" }, - { name = "rocketcea" }, -] - -[package.optional-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "beartype", specifier = ">=0.18" }, - { name = "matplotlib", specifier = ">=3.9" }, - { name = "numba", specifier = ">=0.60" }, - { name = "numpy", specifier = ">=2.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, - { name = "rocketcea", specifier = ">=1.2.1" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, -] -provides-extras = ["dev"] - [[package]] name = "packaging" version = "25.0" @@ -683,6 +651,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "polars" +version = "1.35.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/43/09d4738aa24394751cb7e5d1fc4b5ef461d796efcadd9d00c79578332063/polars-1.35.2.tar.gz", hash = "sha256:ae458b05ca6e7ca2c089342c70793f92f1103c502dc1b14b56f0a04f2cc1d205", size = 694895, upload-time = "2025-11-09T13:20:05.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/9a/24e4b890c7ee4358964aa92c4d1865df0e8831f7df6abaa3a39914521724/polars-1.35.2-py3-none-any.whl", hash = "sha256:5e8057c8289ac148c793478323b726faea933d9776bd6b8a554b0ab7c03db87e", size = 783597, upload-time = "2025-11-09T13:18:51.361Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.35.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/75/ac1256ace28c832a0997b20ba9d10a9d3739bd4d457c1eb1e7d196b6f88b/polars_runtime_32-1.35.2.tar.gz", hash = "sha256:6e6e35733ec52abe54b7d30d245e6586b027d433315d20edfb4a5d162c79fe90", size = 2694387, upload-time = "2025-11-09T13:20:07.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/de/a532b81e68e636483a5dd764d72e106215543f3ef49a142272b277ada8fe/polars_runtime_32-1.35.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e465d12a29e8df06ea78947e50bd361cdf77535cd904fd562666a8a9374e7e3a", size = 40524507, upload-time = "2025-11-09T13:18:55.727Z" }, + { url = "https://files.pythonhosted.org/packages/2d/0b/679751ea6aeaa7b3e33a70ba17f9c8150310792583f3ecf9bb1ce15fe15c/polars_runtime_32-1.35.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef2b029b78f64fb53f126654c0bfa654045c7546bd0de3009d08bd52d660e8cc", size = 36700154, upload-time = "2025-11-09T13:18:59.78Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c8/fd9f48dd6b89ae9cff53d896b51d08579ef9c739e46ea87a647b376c8ca2/polars_runtime_32-1.35.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85dda0994b5dff7f456bb2f4bbd22be9a9e5c5e28670e23fedb13601ec99a46d", size = 41317788, upload-time = "2025-11-09T13:19:03.949Z" }, + { url = "https://files.pythonhosted.org/packages/67/89/e09d9897a70b607e22a36c9eae85a5b829581108fd1e3d4292e5c0f52939/polars_runtime_32-1.35.2-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:3b9006902fc51b768ff747c0f74bd4ce04005ee8aeb290ce9c07ce1cbe1b58a9", size = 37850590, upload-time = "2025-11-09T13:19:08.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/40/96a808ca5cc8707894e196315227f04a0c82136b7fb25570bc51ea33b88d/polars_runtime_32-1.35.2-cp39-abi3-win_amd64.whl", hash = "sha256:ddc015fac39735592e2e7c834c02193ba4d257bb4c8c7478b9ebe440b0756b84", size = 41290019, upload-time = "2025-11-09T13:19:12.214Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d1/8d1b28d007da43c750367c8bf5cb0f22758c16b1104b2b73b9acadb2d17a/polars_runtime_32-1.35.2-cp39-abi3-win_arm64.whl", hash = "sha256:6861145aa321a44eda7cc6694fb7751cb7aa0f21026df51b5faa52e64f9dc39b", size = 36955684, upload-time = "2025-11-09T13:19:15.666Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -743,6 +737,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "rocket" +version = "0.2.0" +source = { editable = "." } +dependencies = [ + { name = "beartype" }, + { name = "matplotlib" }, + { name = "numba" }, + { name = "numpy" }, + { name = "polars" }, + { name = "rocketcea" }, + { name = "tqdm" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "beartype", specifier = ">=0.18" }, + { name = "matplotlib", specifier = ">=3.9" }, + { name = "numba", specifier = ">=0.60" }, + { name = "numpy", specifier = ">=2.0" }, + { name = "polars", specifier = ">=1.35.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, + { name = "rocketcea", specifier = ">=1.2.1" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, + { name = "tqdm", specifier = ">=4.66" }, +] +provides-extras = ["dev"] + [[package]] name = "rocketcea" version = "1.2.1" @@ -912,3 +942,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] From 3fa665e9a77724ddd771ddd62dd11b6ac0cbe8c1 Mon Sep 17 00:00:00 2001 From: Cameron Flannery Date: Wed, 26 Nov 2025 10:55:55 -0800 Subject: [PATCH 3/5] update tests and remove old code --- openrocketengine/__init__.py | 159 --- openrocketengine/analysis.py | 1184 ----------------- openrocketengine/cycles/__init__.py | 55 - openrocketengine/cycles/base.py | 354 ----- openrocketengine/cycles/gas_generator.py | 347 ----- openrocketengine/cycles/pressure_fed.py | 288 ---- openrocketengine/cycles/staged_combustion.py | 488 ------- openrocketengine/engine.py | 632 --------- openrocketengine/examples/__init__.py | 2 - openrocketengine/examples/basic_engine.py | 249 ---- openrocketengine/examples/cycle_comparison.py | 269 ---- openrocketengine/examples/optimization.py | 252 ---- .../examples/propellant_design.py | 201 --- openrocketengine/examples/thermal_analysis.py | 275 ---- openrocketengine/examples/trade_study.py | 254 ---- .../examples/uncertainty_analysis.py | 244 ---- openrocketengine/isentropic.py | 633 --------- openrocketengine/nozzle.py | 427 ------ openrocketengine/output.py | 271 ---- openrocketengine/plotting.py | 1023 -------------- openrocketengine/propellants.py | 475 ------- openrocketengine/results.py | 659 --------- openrocketengine/system.py | 245 ---- openrocketengine/tanks.py | 76 -- openrocketengine/thermal/__init__.py | 57 - openrocketengine/thermal/heat_flux.py | 306 ----- openrocketengine/thermal/regenerative.py | 452 ------- openrocketengine/units.py | 651 --------- tests/test_engine.py | 4 +- tests/test_isentropic.py | 2 +- tests/test_nozzle.py | 6 +- tests/test_propellants.py | 18 +- tests/test_units.py | 2 +- 33 files changed, 16 insertions(+), 10544 deletions(-) delete mode 100644 openrocketengine/__init__.py delete mode 100644 openrocketengine/analysis.py delete mode 100644 openrocketengine/cycles/__init__.py delete mode 100644 openrocketengine/cycles/base.py delete mode 100644 openrocketengine/cycles/gas_generator.py delete mode 100644 openrocketengine/cycles/pressure_fed.py delete mode 100644 openrocketengine/cycles/staged_combustion.py delete mode 100644 openrocketengine/engine.py delete mode 100644 openrocketengine/examples/__init__.py delete mode 100644 openrocketengine/examples/basic_engine.py delete mode 100644 openrocketengine/examples/cycle_comparison.py delete mode 100644 openrocketengine/examples/optimization.py delete mode 100644 openrocketengine/examples/propellant_design.py delete mode 100644 openrocketengine/examples/thermal_analysis.py delete mode 100644 openrocketengine/examples/trade_study.py delete mode 100644 openrocketengine/examples/uncertainty_analysis.py delete mode 100644 openrocketengine/isentropic.py delete mode 100644 openrocketengine/nozzle.py delete mode 100644 openrocketengine/output.py delete mode 100644 openrocketengine/plotting.py delete mode 100644 openrocketengine/propellants.py delete mode 100644 openrocketengine/results.py delete mode 100644 openrocketengine/system.py delete mode 100644 openrocketengine/tanks.py delete mode 100644 openrocketengine/thermal/__init__.py delete mode 100644 openrocketengine/thermal/heat_flux.py delete mode 100644 openrocketengine/thermal/regenerative.py delete mode 100644 openrocketengine/units.py diff --git a/openrocketengine/__init__.py b/openrocketengine/__init__.py deleted file mode 100644 index d4f9fc5..0000000 --- a/openrocketengine/__init__.py +++ /dev/null @@ -1,159 +0,0 @@ -"""OpenRocketEngine - Tools for liquid rocket engine design and analysis. - -This package provides a comprehensive toolkit for designing and analyzing -liquid propellant rocket engines using isentropic flow equations. - -Example: - >>> from openrocketengine import EngineInputs, design_engine - >>> from openrocketengine.units import newtons, megapascals, kelvin, meters, pascals - >>> - >>> inputs = EngineInputs( - ... thrust=newtons(5000), - ... chamber_pressure=megapascals(2.0), - ... chamber_temp=kelvin(3200), - ... exit_pressure=pascals(101325), - ... molecular_weight=22.0, - ... gamma=1.2, - ... lstar=meters(1.0), - ... mixture_ratio=2.0, - ... ) - >>> performance, geometry = design_engine(inputs) - >>> print(f"Isp: {performance.isp.value:.1f} s") -""" - -__version__ = "0.2.0" - -# Core engine design -from openrocketengine.engine import ( - EngineGeometry, - EngineInputs, - EnginePerformance, - compute_geometry, - compute_performance, - design_engine, - format_geometry_summary, - format_performance_summary, - isp_at_altitude, - thrust_at_altitude, -) - -# Nozzle contour generation -from openrocketengine.nozzle import ( - NozzleContour, - conical_contour, - full_chamber_contour, - generate_nozzle_from_geometry, - rao_bell_contour, -) - -# Visualization -from openrocketengine.plotting import ( - plot_cycle_comparison_bars, - plot_cycle_radar, - plot_cycle_tradeoff, - plot_engine_cross_section, - plot_engine_dashboard, - plot_mass_breakdown, - plot_nozzle_contour, - plot_performance_vs_altitude, -) - -# Propellants and thermochemistry -from openrocketengine.propellants import ( - CombustionProperties, - get_combustion_properties, - get_optimal_mixture_ratio, - is_cea_available, - list_database_propellants, -) - -# Output management -from openrocketengine.output import ( - OutputContext, - clean_outputs, - get_default_output_dir, - list_outputs, -) - -# Analysis framework -from openrocketengine.analysis import ( - Distribution, - LogNormal, - MultiObjectiveOptimizer, - Normal, - ParametricStudy, - ParetoResults, - Range, - StudyResults, - Triangular, - UncertaintyAnalysis, - UncertaintyResults, - Uniform, -) - -# System-level design -from openrocketengine.system import ( - EngineSystemResult, - design_engine_system, - format_system_summary, -) - -__all__ = [ - # Version - "__version__", - # Engine dataclasses - "EngineInputs", - "EnginePerformance", - "EngineGeometry", - # Engine computation - "compute_performance", - "compute_geometry", - "design_engine", - "thrust_at_altitude", - "isp_at_altitude", - "format_performance_summary", - "format_geometry_summary", - # Nozzle - "NozzleContour", - "rao_bell_contour", - "conical_contour", - "full_chamber_contour", - "generate_nozzle_from_geometry", - # Plotting - "plot_engine_cross_section", - "plot_nozzle_contour", - "plot_performance_vs_altitude", - "plot_engine_dashboard", - "plot_mass_breakdown", - "plot_cycle_comparison_bars", - "plot_cycle_radar", - "plot_cycle_tradeoff", - # Propellants - "CombustionProperties", - "get_combustion_properties", - "get_optimal_mixture_ratio", - "is_cea_available", - "list_database_propellants", - # Output management - "OutputContext", - "get_default_output_dir", - "list_outputs", - "clean_outputs", - # Analysis framework - "ParametricStudy", - "UncertaintyAnalysis", - "MultiObjectiveOptimizer", - "StudyResults", - "UncertaintyResults", - "ParetoResults", - "Range", - "Distribution", - "Normal", - "Uniform", - "Triangular", - "LogNormal", - # System-level design - "EngineSystemResult", - "design_engine_system", - "format_system_summary", -] diff --git a/openrocketengine/analysis.py b/openrocketengine/analysis.py deleted file mode 100644 index 214ddb1..0000000 --- a/openrocketengine/analysis.py +++ /dev/null @@ -1,1184 +0,0 @@ -"""Parametric analysis and uncertainty quantification for Rocket. - -This module provides general-purpose tools for trade studies, sensitivity -analysis, and uncertainty quantification. The design is introspection-based -to avoid brittleness when dataclass fields change. - -Key Design Principles: -- Works with ANY frozen dataclass + computation function -- Uses dataclass introspection to validate parameters (no hardcoding) -- Automatically discovers output metrics from return types -- Unit-aware parameter ranges - -Example: - >>> from openrocketengine import EngineInputs, design_engine - >>> from openrocketengine.analysis import ParametricStudy, Range - >>> - >>> study = ParametricStudy( - ... compute=design_engine, - ... base=inputs, - ... vary={"chamber_pressure": Range(5, 15, n=11, unit="MPa")}, - ... ) - >>> results = study.run() - >>> results.plot("chamber_pressure", "isp_vac") -""" - -import dataclasses -import itertools -from collections.abc import Callable, Sequence -from dataclasses import dataclass, fields, is_dataclass, replace -from pathlib import Path -from typing import Any, Generic, TypeVar - -import numpy as np -import polars as pl -from beartype import beartype -from numpy.typing import NDArray -from tqdm import tqdm - -from openrocketengine.units import CONVERSIONS, Quantity - -# Type variables for generic analysis -T_Input = TypeVar("T_Input") # Input dataclass type -T_Output = TypeVar("T_Output") # Output type (can be dataclass or tuple) - - -# ============================================================================= -# Parameter Range Specifications -# ============================================================================= - - -@beartype -@dataclass(frozen=True, slots=True) -class Range: - """Specification for a parameter range in a parametric study. - - Supports both dimensionless parameters and Quantity fields with units. - - Examples: - >>> Range(5, 15, n=11, unit="MPa") # 5-15 MPa in 11 steps - >>> Range(2.0, 3.5, n=5) # Dimensionless parameter - >>> Range(values=[2.5, 2.7, 3.0, 3.2]) # Explicit values - """ - - start: float | int | None = None - stop: float | int | None = None - n: int = 10 - unit: str | None = None - values: Sequence[float | int] | None = None - - def __post_init__(self) -> None: - """Validate range specification.""" - if self.values is not None: - if self.start is not None or self.stop is not None: - raise ValueError("Cannot specify both values and start/stop") - else: - if self.start is None or self.stop is None: - raise ValueError("Must specify either values or start/stop") - - def generate(self) -> NDArray[np.float64]: - """Generate array of parameter values.""" - if self.values is not None: - return np.array(self.values, dtype=np.float64) - return np.linspace(self.start, self.stop, self.n) - - def to_quantities(self, dimension: str) -> list[Quantity]: - """Convert range values to Quantity objects. - - Args: - dimension: The dimension of the quantity (e.g., "pressure") - - Returns: - List of Quantity objects - """ - values = self.generate() - if self.unit is None: - raise ValueError(f"Unit required to convert to Quantity for dimension {dimension}") - - return [Quantity(float(v), self.unit, dimension) for v in values] - - -@beartype -@dataclass(frozen=True, slots=True) -class Distribution: - """Base class for probability distributions in uncertainty analysis.""" - - pass - - -@beartype -@dataclass(frozen=True, slots=True) -class Normal(Distribution): - """Normal (Gaussian) distribution. - - Args: - mean: Distribution mean - std: Standard deviation - unit: Optional unit for Quantity fields - """ - - mean: float | int - std: float | int - unit: str | None = None - - def sample(self, n: int, rng: np.random.Generator) -> NDArray[np.float64]: - """Generate n samples from the distribution.""" - return rng.normal(self.mean, self.std, n) - - -@beartype -@dataclass(frozen=True, slots=True) -class Uniform(Distribution): - """Uniform distribution. - - Args: - low: Lower bound - high: Upper bound - unit: Optional unit for Quantity fields - """ - - low: float | int - high: float | int - unit: str | None = None - - def sample(self, n: int, rng: np.random.Generator) -> NDArray[np.float64]: - """Generate n samples from the distribution.""" - return rng.uniform(self.low, self.high, n) - - -@beartype -@dataclass(frozen=True, slots=True) -class Triangular(Distribution): - """Triangular distribution. - - Args: - low: Lower bound - mode: Most likely value - high: Upper bound - unit: Optional unit for Quantity fields - """ - - low: float | int - mode: float | int - high: float | int - unit: str | None = None - - def sample(self, n: int, rng: np.random.Generator) -> NDArray[np.float64]: - """Generate n samples from the distribution.""" - return rng.triangular(self.low, self.mode, self.high, n) - - -@beartype -@dataclass(frozen=True, slots=True) -class LogNormal(Distribution): - """Log-normal distribution. - - Args: - mean: Mean of the underlying normal distribution - sigma: Standard deviation of the underlying normal distribution - unit: Optional unit for Quantity fields - """ - - mean: float | int - sigma: float | int - unit: str | None = None - - def sample(self, n: int, rng: np.random.Generator) -> NDArray[np.float64]: - """Generate n samples from the distribution.""" - return rng.lognormal(self.mean, self.sigma, n) - - -# ============================================================================= -# Introspection Utilities -# ============================================================================= - - -def _get_dataclass_fields(obj: Any) -> dict[str, dataclasses.Field]: - """Get all fields from a dataclass, including nested ones.""" - if not is_dataclass(obj): - raise TypeError(f"Expected dataclass, got {type(obj).__name__}") - return {f.name: f for f in fields(obj)} - - -def _get_field_info(base: Any, field_name: str) -> tuple[Any, str | None]: - """Get the current value and dimension (if Quantity) of a field. - - Args: - base: The base dataclass instance - field_name: Name of the field to inspect - - Returns: - Tuple of (current_value, dimension_or_none) - """ - if not hasattr(base, field_name): - raise ValueError(f"Field '{field_name}' not found in {type(base).__name__}") - - value = getattr(base, field_name) - - if isinstance(value, Quantity): - return value, value.dimension - return value, None - - -def _create_modified_input( - base: T_Input, - field_name: str, - value: float | Quantity, - original_dimension: str | None, -) -> T_Input: - """Create a modified copy of the input with one field changed. - - Handles both Quantity and plain numeric fields. - """ - current_value = getattr(base, field_name) - - if isinstance(current_value, Quantity): - # Field is a Quantity - ensure we create a proper Quantity - if isinstance(value, Quantity): - new_value = value - else: - # Value is numeric, need unit from original - new_value = Quantity(float(value), current_value.unit, current_value.dimension) - else: - # Field is a plain numeric type - new_value = value - - return replace(base, **{field_name: new_value}) - - -def _extract_metrics(result: Any, prefix: str = "") -> dict[str, float]: - """Recursively extract all numeric values from a result. - - Handles dataclasses, tuples, and nested structures. - Returns flat dict with keys for nested values. - - For tuples of dataclasses (common pattern like (Performance, Geometry)), - fields are extracted without prefixes to keep names clean. - """ - metrics: dict[str, float] = {} - - if isinstance(result, tuple): - # Handle tuple of results (e.g., (performance, geometry)) - # Extract fields directly without prefixes for cleaner column names - for item in result: - metrics.update(_extract_metrics(item, prefix)) - - elif is_dataclass(result) and not isinstance(result, type): - # Handle dataclass - for field in fields(result): - field_value = getattr(result, field.name) - field_key = f"{prefix}{field.name}" if prefix else field.name - - if isinstance(field_value, Quantity): - metrics[field_key] = float(field_value.value) - elif isinstance(field_value, (int, float)): - metrics[field_key] = float(field_value) - elif is_dataclass(field_value): - metrics.update(_extract_metrics(field_value, f"{field_key}.")) - - return metrics - - -# ============================================================================= -# Study Results -# ============================================================================= - - -@beartype -@dataclass -class StudyResults: - """Results from a parametric study or uncertainty analysis. - - Contains all input combinations, computed outputs, and extracted metrics. - Provides methods for plotting, filtering, and export. - - Attributes: - inputs: List of input parameter combinations - outputs: List of computed results - metrics: Dict mapping metric names to arrays of values - parameters: Dict mapping parameter names to arrays of values - constraints_passed: Boolean array indicating which runs passed constraints - """ - - inputs: list[Any] - outputs: list[Any] - metrics: dict[str, NDArray[np.float64]] - parameters: dict[str, NDArray[np.float64]] - constraints_passed: NDArray[np.bool_] | None = None - - @property - def n_runs(self) -> int: - """Number of runs in the study.""" - return len(self.inputs) - - @property - def n_feasible(self) -> int: - """Number of runs that passed all constraints.""" - if self.constraints_passed is None: - return self.n_runs - return int(np.sum(self.constraints_passed)) - - def get_metric(self, name: str, feasible_only: bool = False) -> NDArray[np.float64]: - """Get values for a specific metric. - - Args: - name: Metric name (e.g., "isp", "throat_diameter") - feasible_only: If True, only return values where constraints passed - - Returns: - Array of metric values - """ - if name not in self.metrics: - available = list(self.metrics.keys()) - raise ValueError(f"Unknown metric '{name}'. Available: {available}") - - values = self.metrics[name] - if feasible_only and self.constraints_passed is not None: - return values[self.constraints_passed] - return values - - def get_parameter(self, name: str, feasible_only: bool = False) -> NDArray[np.float64]: - """Get values for a specific input parameter. - - Args: - name: Parameter name (e.g., "chamber_pressure") - feasible_only: If True, only return values where constraints passed - - Returns: - Array of parameter values - """ - if name not in self.parameters: - available = list(self.parameters.keys()) - raise ValueError(f"Unknown parameter '{name}'. Available: {available}") - - values = self.parameters[name] - if feasible_only and self.constraints_passed is not None: - return values[self.constraints_passed] - return values - - def get_best( - self, - metric: str, - maximize: bool = True, - feasible_only: bool = True, - ) -> tuple[Any, Any, float]: - """Get the best run according to a metric. - - Args: - metric: Metric to optimize - maximize: If True, find maximum; if False, find minimum - feasible_only: Only consider runs that passed constraints - - Returns: - Tuple of (best_input, best_output, best_metric_value) - """ - values = self.get_metric(metric, feasible_only=False) - mask = self.constraints_passed if feasible_only and self.constraints_passed is not None else np.ones(len(values), dtype=bool) - - if not np.any(mask): - raise ValueError("No feasible solutions found") - - masked_values = np.where(mask, values, -np.inf if maximize else np.inf) - best_idx = int(np.argmax(masked_values) if maximize else np.argmin(masked_values)) - - return self.inputs[best_idx], self.outputs[best_idx], float(values[best_idx]) - - def to_dataframe(self) -> pl.DataFrame: - """Export results to a Polars DataFrame. - - Returns: - Polars DataFrame with parameters and metrics - """ - data = {**self.parameters, **self.metrics} - if self.constraints_passed is not None: - data["feasible"] = self.constraints_passed - return pl.DataFrame(data) - - def to_csv(self, path: str | Path) -> None: - """Export results to CSV file. - - Args: - path: Output file path - """ - df = self.to_dataframe() - df.write_csv(path) - - def list_metrics(self) -> list[str]: - """List all available metric names.""" - return list(self.metrics.keys()) - - def list_parameters(self) -> list[str]: - """List all varied parameter names.""" - return list(self.parameters.keys()) - - -# ============================================================================= -# Parametric Study -# ============================================================================= - - -@beartype -class ParametricStudy(Generic[T_Input, T_Output]): - """General-purpose parametric study framework. - - Runs a computation over a grid of parameter variations, automatically - discovering valid parameters through dataclass introspection. - - This design is non-brittle: - - Adding new fields to input dataclasses automatically makes them available - - No hardcoded parameter names - - Works with any frozen dataclass + computation function - - Example: - >>> study = ParametricStudy( - ... compute=design_engine, - ... base=inputs, - ... vary={ - ... "chamber_pressure": Range(5, 15, n=11, unit="MPa"), - ... "mixture_ratio": Range(2.5, 3.5, n=5), - ... }, - ... ) - >>> results = study.run() - """ - - def __init__( - self, - compute: Callable[[T_Input], T_Output], - base: T_Input, - vary: dict[str, Range | Sequence[Any]], - constraints: list[Callable[[T_Output], bool]] | None = None, - ) -> None: - """Initialize parametric study. - - Args: - compute: Function that takes input and returns output - base: Base input dataclass with default values - vary: Dict mapping field names to Range specifications or plain sequences. - Use Range for unit-aware sweeps, or a plain list for discrete values. - constraints: Optional list of constraint functions. - Each takes output and returns True if feasible. - """ - self.compute = compute - self.base = base - self.vary = vary - self.constraints = constraints or [] - - # Validate that all varied parameters exist in the base dataclass - self._validate_parameters() - - def _validate_parameters(self) -> None: - """Validate that all varied parameters exist and have compatible types.""" - valid_fields = _get_dataclass_fields(self.base) - - for param_name, param_spec in self.vary.items(): - if param_name not in valid_fields: - raise ValueError( - f"Parameter '{param_name}' not found in {type(self.base).__name__}. " - f"Valid fields: {list(valid_fields.keys())}" - ) - - # Check unit compatibility for Quantity fields (only if Range is used) - current_value = getattr(self.base, param_name) - if isinstance(current_value, Quantity) and isinstance(param_spec, Range): - if param_spec.unit is None: - raise ValueError( - f"Parameter '{param_name}' is a Quantity, but no unit specified in Range. " - f"Current unit: {current_value.unit}" - ) - # Verify unit is valid and has compatible dimension - if param_spec.unit not in CONVERSIONS: - raise ValueError(f"Unknown unit '{param_spec.unit}' for parameter '{param_name}'") - - range_dim = CONVERSIONS[param_spec.unit][1] - if range_dim != current_value.dimension: - raise ValueError( - f"Unit '{param_spec.unit}' has dimension '{range_dim}', " - f"but field '{param_name}' has dimension '{current_value.dimension}'" - ) - - def _generate_grid(self) -> list[dict[str, float | Quantity]]: - """Generate all parameter combinations.""" - # Generate values for each parameter - param_values: dict[str, list[Any]] = {} - - for param_name, param_spec in self.vary.items(): - current_value = getattr(self.base, param_name) - - if isinstance(param_spec, Range): - # Range specification - if isinstance(current_value, Quantity): - # Generate Quantity values - param_values[param_name] = param_spec.to_quantities(current_value.dimension) - else: - # Generate plain numeric values - param_values[param_name] = list(param_spec.generate()) - else: - # Plain sequence - use values as-is - param_values[param_name] = list(param_spec) - - # Generate all combinations - keys = list(param_values.keys()) - value_lists = [param_values[k] for k in keys] - combinations = list(itertools.product(*value_lists)) - - return [dict(zip(keys, combo, strict=True)) for combo in combinations] - - def run(self, progress: bool = False) -> StudyResults: - """Run the parametric study. - - Args: - progress: If True, print progress (requires tqdm for fancy progress bar) - - Returns: - StudyResults containing all inputs, outputs, and extracted metrics - """ - grid = self._generate_grid() - n_total = len(grid) - - inputs_list: list[T_Input] = [] - outputs_list: list[T_Output] = [] - all_metrics: list[dict[str, float]] = [] - all_params: list[dict[str, float]] = [] - constraints_passed: list[bool] = [] - - # Create iterator with optional progress bar - iterator: Any = grid - if progress: - iterator = tqdm(grid, desc="Running study", total=n_total) - - for i, param_combo in enumerate(iterator): - # Create modified input - modified_input = self.base - for param_name, param_value in param_combo.items(): - modified_input = _create_modified_input( - modified_input, - param_name, - param_value, - current_value.dimension if isinstance((current_value := getattr(self.base, param_name)), Quantity) else None, - ) - - # Run computation - try: - output = self.compute(modified_input) - success = True - except Exception as e: - # Store None for failed runs - output = None # type: ignore - success = False - if progress and not hasattr(iterator, 'set_postfix'): - print(f" Run {i+1}/{n_total} failed: {e}") - - inputs_list.append(modified_input) - outputs_list.append(output) - - # Extract metrics - if success and output is not None: - metrics = _extract_metrics(output) - all_metrics.append(metrics) - - # Check constraints - passed = all(constraint(output) for constraint in self.constraints) - else: - all_metrics.append({}) - passed = False - - constraints_passed.append(passed) - - # Extract parameter values (numeric form for plotting) - param_dict: dict[str, float] = {} - for param_name, param_value in param_combo.items(): - if isinstance(param_value, Quantity): - param_dict[param_name] = float(param_value.value) - else: - param_dict[param_name] = float(param_value) - all_params.append(param_dict) - - # Consolidate metrics into arrays - if all_metrics and all_metrics[0]: - metric_names = set() - for m in all_metrics: - metric_names.update(m.keys()) - - metrics_arrays = { - name: np.array([m.get(name, np.nan) for m in all_metrics]) - for name in metric_names - } - else: - metrics_arrays = {} - - # Consolidate parameters into arrays - if all_params: - param_names = list(all_params[0].keys()) - params_arrays = { - name: np.array([p[name] for p in all_params]) - for name in param_names - } - else: - params_arrays = {} - - return StudyResults( - inputs=inputs_list, - outputs=outputs_list, - metrics=metrics_arrays, - parameters=params_arrays, - constraints_passed=np.array(constraints_passed), - ) - - -# ============================================================================= -# Uncertainty Analysis -# ============================================================================= - - -@beartype -class UncertaintyAnalysis(Generic[T_Input, T_Output]): - """Monte Carlo uncertainty quantification. - - Samples input parameters from specified distributions and propagates - uncertainty through the computation. - - Example: - >>> analysis = UncertaintyAnalysis( - ... compute=design_engine, - ... base=inputs, - ... distributions={ - ... "gamma": Normal(1.22, 0.02), - ... "chamber_temp": Normal(3200, 50, unit="K"), - ... }, - ... ) - >>> results = analysis.run(n_samples=1000) - >>> print(f"Isp = {results.mean('isp'):.1f} ± {results.std('isp'):.1f} s") - """ - - def __init__( - self, - compute: Callable[[T_Input], T_Output], - base: T_Input, - distributions: dict[str, Distribution], - constraints: list[Callable[[T_Output], bool]] | None = None, - seed: int | None = None, - ) -> None: - """Initialize uncertainty analysis. - - Args: - compute: Function that takes input and returns output - base: Base input dataclass with nominal values - distributions: Dict mapping field names to Distribution specifications - constraints: Optional constraint functions - seed: Random seed for reproducibility - """ - self.compute = compute - self.base = base - self.distributions = distributions - self.constraints = constraints or [] - self.rng = np.random.default_rng(seed) - - self._validate_parameters() - - def _validate_parameters(self) -> None: - """Validate that all uncertain parameters exist.""" - valid_fields = _get_dataclass_fields(self.base) - - for param_name, dist in self.distributions.items(): - if param_name not in valid_fields: - raise ValueError( - f"Parameter '{param_name}' not found in {type(self.base).__name__}. " - f"Valid fields: {list(valid_fields.keys())}" - ) - - # Check unit compatibility for Quantity fields - current_value = getattr(self.base, param_name) - if isinstance(current_value, Quantity) and (not hasattr(dist, 'unit') or dist.unit is None): # type: ignore - raise ValueError( - f"Parameter '{param_name}' is a Quantity, but no unit specified in Distribution" - ) - - def run(self, n_samples: int = 1000, progress: bool = False) -> "UncertaintyResults": - """Run Monte Carlo uncertainty analysis. - - Args: - n_samples: Number of Monte Carlo samples - progress: If True, show progress indicator - - Returns: - UncertaintyResults with statistics and samples - """ - # Generate all samples upfront - samples: dict[str, NDArray[np.float64]] = {} - for param_name, dist in self.distributions.items(): - samples[param_name] = dist.sample(n_samples, self.rng) - - inputs_list: list[T_Input] = [] - outputs_list: list[T_Output] = [] - all_metrics: list[dict[str, float]] = [] - constraints_passed: list[bool] = [] - - iterator: Any = range(n_samples) - if progress: - iterator = tqdm(range(n_samples), desc="Sampling") - - for i in iterator: - # Create modified input with sampled values - modified_input = self.base - - for param_name, dist in self.distributions.items(): - sampled_value = samples[param_name][i] - current_value = getattr(self.base, param_name) - - if isinstance(current_value, Quantity): - # Create Quantity with sampled value - unit = dist.unit # type: ignore - new_value = Quantity(float(sampled_value), unit, current_value.dimension) - else: - new_value = float(sampled_value) - - modified_input = replace(modified_input, **{param_name: new_value}) - - # Run computation - try: - output = self.compute(modified_input) - success = True - except Exception: - output = None # type: ignore - success = False - - inputs_list.append(modified_input) - outputs_list.append(output) - - if success and output is not None: - metrics = _extract_metrics(output) - all_metrics.append(metrics) - passed = all(constraint(output) for constraint in self.constraints) - else: - all_metrics.append({}) - passed = False - - constraints_passed.append(passed) - - # Consolidate metrics - if all_metrics and all_metrics[0]: - metric_names = set() - for m in all_metrics: - metric_names.update(m.keys()) - - metrics_arrays = { - name: np.array([m.get(name, np.nan) for m in all_metrics]) - for name in metric_names - } - else: - metrics_arrays = {} - - return UncertaintyResults( - inputs=inputs_list, - outputs=outputs_list, - metrics=metrics_arrays, - samples=samples, - constraints_passed=np.array(constraints_passed), - n_samples=n_samples, - ) - - -@beartype -@dataclass -class UncertaintyResults: - """Results from uncertainty analysis. - - Provides statistical summaries and access to all samples. - """ - - inputs: list[Any] - outputs: list[Any] - metrics: dict[str, NDArray[np.float64]] - samples: dict[str, NDArray[np.float64]] - constraints_passed: NDArray[np.bool_] - n_samples: int - - def mean(self, metric: str, feasible_only: bool = False) -> float: - """Get mean value of a metric.""" - values = self._get_values(metric, feasible_only) - return float(np.nanmean(values)) - - def std(self, metric: str, feasible_only: bool = False) -> float: - """Get standard deviation of a metric.""" - values = self._get_values(metric, feasible_only) - return float(np.nanstd(values)) - - def percentile( - self, metric: str, p: float | Sequence[float], feasible_only: bool = False - ) -> float | NDArray[np.float64]: - """Get percentile(s) of a metric. - - Args: - metric: Metric name - p: Percentile(s) to compute (0-100) - feasible_only: Only use feasible samples - - Returns: - Percentile value(s) - """ - values = self._get_values(metric, feasible_only) - result = np.nanpercentile(values, p) - if isinstance(p, (int, float)): - return float(result) - return result - - def confidence_interval( - self, metric: str, confidence: float = 0.95, feasible_only: bool = False - ) -> tuple[float, float]: - """Get confidence interval for a metric. - - Args: - metric: Metric name - confidence: Confidence level (0-1), default 0.95 for 95% CI - feasible_only: Only use feasible samples - - Returns: - Tuple of (lower_bound, upper_bound) - """ - alpha = 1 - confidence - lower_p = alpha / 2 * 100 - upper_p = (1 - alpha / 2) * 100 - - values = self._get_values(metric, feasible_only) - return ( - float(np.nanpercentile(values, lower_p)), - float(np.nanpercentile(values, upper_p)), - ) - - def probability_of_success(self) -> float: - """Get fraction of samples that passed all constraints.""" - return float(np.mean(self.constraints_passed)) - - def _get_values(self, metric: str, feasible_only: bool) -> NDArray[np.float64]: - """Get metric values, optionally filtered to feasible only.""" - if metric not in self.metrics: - available = list(self.metrics.keys()) - raise ValueError(f"Unknown metric '{metric}'. Available: {available}") - - values = self.metrics[metric] - if feasible_only: - values = values[self.constraints_passed] - return values - - def summary(self, metrics: list[str] | None = None) -> str: - """Generate a text summary of uncertainty results. - - Args: - metrics: List of metrics to summarize. If None, summarizes all. - - Returns: - Formatted string summary - """ - if metrics is None: - metrics = [m for m in self.metrics if not m.endswith("_si")] - - lines = [ - "Uncertainty Analysis Results", - "=" * 50, - f"Samples: {self.n_samples}", - f"Feasible: {np.sum(self.constraints_passed)} ({self.probability_of_success()*100:.1f}%)", - "", - f"{'Metric':<25} {'Mean':>12} {'Std':>12} {'95% CI':>20}", - "-" * 70, - ] - - for metric in metrics: - if metric in self.metrics: - mean = self.mean(metric) - std = self.std(metric) - ci = self.confidence_interval(metric) - lines.append( - f"{metric:<25} {mean:>12.4g} {std:>12.4g} [{ci[0]:.4g}, {ci[1]:.4g}]" - ) - - return "\n".join(lines) - - def to_dataframe(self) -> pl.DataFrame: - """Export results to Polars DataFrame.""" - data = {**self.samples, **self.metrics, "feasible": self.constraints_passed} - return pl.DataFrame(data) - - def to_csv(self, path: str | Path) -> None: - """Export results to CSV file. - - Args: - path: Output file path - """ - df = self.to_dataframe() - df.write_csv(path) - - -# ============================================================================= -# Multi-Objective Optimization -# ============================================================================= - - -@beartype -def compute_pareto_front( - objectives: NDArray[np.float64], - maximize: Sequence[bool], -) -> NDArray[np.bool_]: - """Identify Pareto-optimal points in a set of objectives. - - A point is Pareto-optimal if no other point dominates it (i.e., no - other point is better in all objectives simultaneously). - - Args: - objectives: Array of shape (n_points, n_objectives) - maximize: List of booleans indicating whether to maximize each objective - - Returns: - Boolean array indicating which points are Pareto-optimal - """ - n_points = objectives.shape[0] - is_pareto = np.ones(n_points, dtype=bool) - - # Flip signs for maximization (we'll minimize internally) - obj = objectives.copy() - for i, is_max in enumerate(maximize): - if is_max: - obj[:, i] = -obj[:, i] - - for i in range(n_points): - if not is_pareto[i]: - continue - - for j in range(n_points): - if i == j or not is_pareto[j]: - continue - - # j dominates i if j is <= in all objectives and < in at least one - if np.all(obj[j] <= obj[i]) and np.any(obj[j] < obj[i]): - is_pareto[i] = False - break - - return is_pareto - - -@beartype -class MultiObjectiveOptimizer(Generic[T_Input, T_Output]): - """Multi-objective optimizer for finding Pareto-optimal designs. - - Uses a combination of grid search and local refinement to find - designs on the Pareto frontier. - - Example: - >>> optimizer = MultiObjectiveOptimizer( - ... compute=design_engine, - ... base=inputs, - ... objectives=["isp_vac", "thrust_to_weight"], - ... maximize=[True, True], - ... vary={"chamber_pressure": Range(5, 20, n=10, unit="MPa")}, - ... ) - >>> pareto_results = optimizer.run() - """ - - def __init__( - self, - compute: Callable[[T_Input], T_Output], - base: T_Input, - objectives: list[str], - maximize: list[bool], - vary: dict[str, Range | Sequence[Any]], - constraints: list[Callable[[T_Output], bool]] | None = None, - ) -> None: - """Initialize multi-objective optimizer. - - Args: - compute: Function that takes input and returns output - base: Base input dataclass - objectives: List of metric names to optimize - maximize: List of booleans for each objective (True = maximize) - vary: Dict mapping field names to Range specifications or plain sequences - constraints: Optional constraint functions - """ - self.compute = compute - self.base = base - self.objectives = objectives - self.maximize = maximize - self.vary = vary - self.constraints = constraints or [] - - if len(objectives) != len(maximize): - raise ValueError("objectives and maximize must have same length") - - def run(self, progress: bool = False) -> "ParetoResults": - """Run the multi-objective optimization. - - First performs a parametric sweep, then identifies Pareto-optimal points. - - Args: - progress: If True, show progress indicator - - Returns: - ParetoResults with Pareto-optimal designs - """ - # Run parametric study - study = ParametricStudy( - compute=self.compute, - base=self.base, - vary=self.vary, - constraints=self.constraints, - ) - results = study.run(progress=progress) - - # Extract objectives - obj_arrays = [] - for obj_name in self.objectives: - if obj_name not in results.metrics: - raise ValueError(f"Objective '{obj_name}' not found in results") - obj_arrays.append(results.get_metric(obj_name)) - - objectives_matrix = np.column_stack(obj_arrays) - - # Filter to feasible points - if results.constraints_passed is not None: - feasible_mask = results.constraints_passed - else: - feasible_mask = np.ones(len(objectives_matrix), dtype=bool) - - # Remove NaN values - valid_mask = feasible_mask & ~np.any(np.isnan(objectives_matrix), axis=1) - valid_indices = np.where(valid_mask)[0] - - if len(valid_indices) == 0: - return ParetoResults( - all_results=results, - pareto_indices=[], - pareto_inputs=[], - pareto_outputs=[], - pareto_objectives=np.array([]).reshape(0, len(self.objectives)), - objective_names=self.objectives, - maximize=self.maximize, - ) - - # Compute Pareto front on valid points only - valid_objectives = objectives_matrix[valid_mask] - is_pareto = compute_pareto_front(valid_objectives, self.maximize) - - # Map back to original indices - pareto_indices = valid_indices[is_pareto].tolist() - pareto_inputs = [results.inputs[i] for i in pareto_indices] - pareto_outputs = [results.outputs[i] for i in pareto_indices] - pareto_objectives = valid_objectives[is_pareto] - - return ParetoResults( - all_results=results, - pareto_indices=pareto_indices, - pareto_inputs=pareto_inputs, - pareto_outputs=pareto_outputs, - pareto_objectives=pareto_objectives, - objective_names=self.objectives, - maximize=self.maximize, - ) - - -@beartype -@dataclass -class ParetoResults: - """Results from multi-objective optimization. - - Contains the Pareto-optimal designs and full study results. - """ - - all_results: StudyResults - pareto_indices: list[int] - pareto_inputs: list[Any] - pareto_outputs: list[Any] - pareto_objectives: NDArray[np.float64] - objective_names: list[str] - maximize: list[bool] - - @property - def n_pareto(self) -> int: - """Number of Pareto-optimal points.""" - return len(self.pareto_indices) - - def get_best(self, objective: str) -> tuple[Any, Any, float]: - """Get the best design for a specific objective. - - Args: - objective: Name of objective to optimize - - Returns: - Tuple of (input, output, objective_value) - """ - if objective not in self.objective_names: - raise ValueError(f"Unknown objective: {objective}") - - obj_idx = self.objective_names.index(objective) - values = self.pareto_objectives[:, obj_idx] - - if self.maximize[obj_idx]: - best_idx = int(np.argmax(values)) - else: - best_idx = int(np.argmin(values)) - - return ( - self.pareto_inputs[best_idx], - self.pareto_outputs[best_idx], - float(values[best_idx]), - ) - - def get_compromise(self, weights: list[float] | None = None) -> tuple[Any, Any]: - """Get a compromise solution from the Pareto front. - - Uses weighted sum of normalized objectives. - - Args: - weights: Weights for each objective (default: equal weights) - - Returns: - Tuple of (input, output) for the compromise solution - """ - if weights is None: - weights = [1.0 / len(self.objectives) for _ in self.objective_names] - - if len(weights) != len(self.objective_names): - raise ValueError("weights must have same length as objectives") - - # Normalize objectives to [0, 1] - obj_norm = self.pareto_objectives.copy() - for i in range(obj_norm.shape[1]): - col = obj_norm[:, i] - col_min, col_max = np.min(col), np.max(col) - if col_max > col_min: - obj_norm[:, i] = (col - col_min) / (col_max - col_min) - else: - obj_norm[:, i] = 0.5 - - # Flip if minimizing - if not self.maximize[i]: - obj_norm[:, i] = 1 - obj_norm[:, i] - - # Weighted sum - scores = np.sum(obj_norm * np.array(weights), axis=1) - best_idx = int(np.argmax(scores)) - - return self.pareto_inputs[best_idx], self.pareto_outputs[best_idx] - - def summary(self) -> str: - """Generate text summary of Pareto results.""" - lines = [ - "Multi-Objective Optimization Results", - "=" * 50, - f"Total designs evaluated: {self.all_results.n_runs}", - f"Feasible designs: {self.all_results.n_feasible}", - f"Pareto-optimal designs: {self.n_pareto}", - "", - "Objectives:", - ] - - for i, (name, is_max) in enumerate(zip(self.objective_names, self.maximize, strict=True)): - direction = "maximize" if is_max else "minimize" - if self.n_pareto > 0: - values = self.pareto_objectives[:, i] - lines.append( - f" {name} ({direction}): " - f"range [{np.min(values):.4g}, {np.max(values):.4g}]" - ) - else: - lines.append(f" {name} ({direction}): no feasible points") - - return "\n".join(lines) - diff --git a/openrocketengine/cycles/__init__.py b/openrocketengine/cycles/__init__.py deleted file mode 100644 index d211daa..0000000 --- a/openrocketengine/cycles/__init__.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Engine cycle analysis module for Rocket. - -This module provides analysis tools for different rocket engine cycles: -- Pressure-fed (simplest) -- Gas generator (turbopump-fed with separate combustion) -- Expander (turbine driven by heated propellant) -- Staged combustion (preburner exhaust into main chamber) - -Each cycle type has different performance characteristics, complexity, -and feasibility constraints. - -Example: - >>> from openrocketengine import EngineInputs, design_engine - >>> from openrocketengine.cycles import GasGeneratorCycle, analyze_cycle - >>> - >>> inputs = EngineInputs.from_propellants("LOX", "CH4", ...) - >>> performance, geometry = design_engine(inputs) - >>> - >>> cycle = GasGeneratorCycle( - ... turbine_inlet_temp=kelvin(900), - ... pump_efficiency=0.70, - ... ) - >>> result = analyze_cycle(inputs, performance, geometry, cycle) - >>> print(f"Net Isp: {result.net_isp.value:.1f} s") -""" - -from openrocketengine.cycles.base import ( - CycleConfiguration, - CyclePerformance, - CycleType, - analyze_cycle, - format_cycle_summary, -) -from openrocketengine.cycles.gas_generator import GasGeneratorCycle -from openrocketengine.cycles.pressure_fed import PressureFedCycle -from openrocketengine.cycles.staged_combustion import ( - FullFlowStagedCombustion, - StagedCombustionCycle, -) - -__all__ = [ - # Base types - "CycleConfiguration", - "CyclePerformance", - "CycleType", - # Cycle configurations - "PressureFedCycle", - "GasGeneratorCycle", - "StagedCombustionCycle", - "FullFlowStagedCombustion", - # Analysis function - "analyze_cycle", - "format_cycle_summary", -] - diff --git a/openrocketengine/cycles/base.py b/openrocketengine/cycles/base.py deleted file mode 100644 index ce8f11a..0000000 --- a/openrocketengine/cycles/base.py +++ /dev/null @@ -1,354 +0,0 @@ -"""Base types for engine cycle analysis. - -This module defines the common interfaces and data structures used by -all engine cycle types. -""" - -import math -from dataclasses import dataclass -from enum import Enum, auto -from typing import Protocol, runtime_checkable - -from beartype import beartype - -from openrocketengine.engine import EngineGeometry, EngineInputs, EnginePerformance -from openrocketengine.units import Quantity, pascals, seconds, kg_per_second - - -class CycleType(Enum): - """Engine cycle type enumeration.""" - - PRESSURE_FED = auto() - GAS_GENERATOR = auto() - EXPANDER = auto() - STAGED_COMBUSTION = auto() - FULL_FLOW_STAGED = auto() - ELECTRIC_PUMP = auto() - - -@beartype -@dataclass(frozen=True, slots=True) -class CyclePerformance: - """Performance results from engine cycle analysis. - - Captures the system-level performance including losses from - turbine drive systems, pump power requirements, and pressure margins. - - Attributes: - net_isp: Effective Isp after accounting for cycle losses [s] - net_thrust: Delivered thrust after losses [N] - cycle_efficiency: Ratio of net Isp to ideal Isp [-] - pump_power_ox: Oxidizer pump power requirement [W] - pump_power_fuel: Fuel pump power requirement [W] - turbine_power: Total turbine power available [W] - turbine_mass_flow: Mass flow through turbine [kg/s] - tank_pressure_ox: Required oxidizer tank pressure [Pa] - tank_pressure_fuel: Required fuel tank pressure [Pa] - npsh_margin_ox: Net Positive Suction Head margin for ox pump [Pa] - npsh_margin_fuel: NPSH margin for fuel pump [Pa] - cycle_type: Type of cycle analyzed - feasible: Whether the cycle closes (power balance satisfied) - warnings: List of any warnings or marginal conditions - """ - - net_isp: Quantity - net_thrust: Quantity - cycle_efficiency: float - pump_power_ox: Quantity - pump_power_fuel: Quantity - turbine_power: Quantity - turbine_mass_flow: Quantity - tank_pressure_ox: Quantity - tank_pressure_fuel: Quantity - npsh_margin_ox: Quantity - npsh_margin_fuel: Quantity - cycle_type: CycleType - feasible: bool - warnings: list[str] - - -@runtime_checkable -class CycleConfiguration(Protocol): - """Protocol that all cycle configurations must implement. - - This protocol ensures that any cycle configuration can be used - with the generic analyze_cycle() function. - """ - - @property - def cycle_type(self) -> CycleType: - """Return the cycle type.""" - ... - - def analyze( - self, - inputs: EngineInputs, - performance: EnginePerformance, - geometry: EngineGeometry, - ) -> CyclePerformance: - """Analyze the cycle and return performance results. - - Args: - inputs: Engine input parameters - performance: Computed engine performance - geometry: Computed engine geometry - - Returns: - CyclePerformance with system-level results - """ - ... - - -@beartype -def analyze_cycle( - inputs: EngineInputs, - performance: EnginePerformance, - geometry: EngineGeometry, - cycle: CycleConfiguration, -) -> CyclePerformance: - """Analyze an engine cycle configuration. - - This is the main entry point for cycle analysis. It delegates to - the specific cycle implementation's analyze() method. - - Args: - inputs: Engine input parameters - performance: Computed engine performance (from design_engine) - geometry: Computed engine geometry (from design_engine) - cycle: Cycle configuration (PressureFedCycle, GasGeneratorCycle, etc.) - - Returns: - CyclePerformance with net performance and feasibility assessment - - Example: - >>> inputs = EngineInputs.from_propellants("LOX", "CH4", ...) - >>> performance, geometry = design_engine(inputs) - >>> cycle = GasGeneratorCycle(turbine_inlet_temp=kelvin(900), ...) - >>> result = analyze_cycle(inputs, performance, geometry, cycle) - """ - return cycle.analyze(inputs, performance, geometry) - - -# ============================================================================= -# Utility Functions -# ============================================================================= - - -@beartype -def pump_power( - mass_flow: Quantity, - pressure_rise: Quantity, - density: float, - efficiency: float, -) -> Quantity: - """Calculate pump power requirement. - - Uses the basic hydraulic power equation: - P = (mdot * delta_P) / (rho * eta) - - Args: - mass_flow: Mass flow rate through pump [kg/s] - pressure_rise: Pressure rise across pump [Pa] - density: Fluid density [kg/m³] - efficiency: Pump efficiency (0-1) - - Returns: - Pump power in Watts - """ - mdot = mass_flow.to("kg/s").value - dp = pressure_rise.to("Pa").value - - # Volumetric flow rate - Q = mdot / density # m³/s - - # Hydraulic power - P_hydraulic = Q * dp # W - - # Shaft power (accounting for efficiency) - P_shaft = P_hydraulic / efficiency - - return Quantity(P_shaft, "W", "power") - - -@beartype -def turbine_power( - mass_flow: Quantity, - inlet_temp: Quantity, - pressure_ratio: float, - gamma: float, - efficiency: float, - R: float = 287.0, # J/(kg·K), approximate for combustion products -) -> Quantity: - """Calculate turbine power output. - - Uses isentropic turbine equations with efficiency factor. - - Args: - mass_flow: Mass flow through turbine [kg/s] - inlet_temp: Turbine inlet temperature [K] - pressure_ratio: Inlet pressure / outlet pressure [-] - gamma: Ratio of specific heats for turbine gas [-] - efficiency: Turbine isentropic efficiency (0-1) - R: Specific gas constant [J/(kg·K)] - - Returns: - Turbine power output in Watts - """ - mdot = mass_flow.to("kg/s").value - T_in = inlet_temp.to("K").value - - # Isentropic temperature ratio - T_ratio_ideal = pressure_ratio ** ((gamma - 1) / gamma) - - # Actual temperature drop - delta_T_ideal = T_in * (1 - 1 / T_ratio_ideal) - delta_T_actual = delta_T_ideal * efficiency - - # Specific heat at constant pressure - cp = gamma * R / (gamma - 1) - - # Turbine power - P = mdot * cp * delta_T_actual - - return Quantity(P, "W", "power") - - -@beartype -def npsh_available( - tank_pressure: Quantity, - fluid_density: float, - vapor_pressure: Quantity, - inlet_height: float = 0.0, - line_losses: Quantity | None = None, -) -> Quantity: - """Calculate Net Positive Suction Head available at pump inlet. - - NPSH_a = (P_tank - P_vapor) / (rho * g) + h - losses - - Args: - tank_pressure: Tank ullage pressure [Pa] - fluid_density: Propellant density [kg/m³] - vapor_pressure: Propellant vapor pressure [Pa] - inlet_height: Height of fluid above pump inlet [m] - line_losses: Pressure losses in feed lines [Pa] - - Returns: - NPSH available in Pascals (pressure equivalent) - """ - g = 9.80665 # m/s² - - P_tank = tank_pressure.to("Pa").value - P_vapor = vapor_pressure.to("Pa").value - losses = line_losses.to("Pa").value if line_losses else 0.0 - - # NPSH in meters of head - npsh_m = (P_tank - P_vapor) / (fluid_density * g) + inlet_height - losses / (fluid_density * g) - - # Convert to pressure equivalent - npsh_pa = npsh_m * fluid_density * g - - return pascals(npsh_pa) - - -@beartype -def estimate_line_losses( - mass_flow: Quantity, - density: float, - pipe_diameter: float, - pipe_length: float, - num_elbows: int = 2, - num_valves: int = 2, -) -> Quantity: - """Estimate pressure losses in feed lines. - - Uses Darcy-Weisbach equation with loss coefficients for fittings. - - Args: - mass_flow: Mass flow rate [kg/s] - density: Fluid density [kg/m³] - pipe_diameter: Pipe inner diameter [m] - pipe_length: Total pipe length [m] - num_elbows: Number of 90° elbows - num_valves: Number of valves - - Returns: - Total pressure loss [Pa] - """ - mdot = mass_flow.to("kg/s").value - D = pipe_diameter - L = pipe_length - - # Calculate velocity - A = math.pi * (D / 2) ** 2 - V = mdot / (density * A) - - # Dynamic pressure - q = 0.5 * density * V ** 2 - - # Friction factor (assuming turbulent flow, smooth pipe) - # Using Blasius correlation as approximation - Re = density * V * D / 1e-3 # Approximate viscosity - if Re > 2300: - f = 0.316 / Re ** 0.25 - else: - f = 64 / Re - - # Pipe friction losses - dp_pipe = f * (L / D) * q - - # Fitting losses (K-factors) - K_elbow = 0.3 # 90° elbow - K_valve = 0.2 # Gate valve (open) - - dp_fittings = (num_elbows * K_elbow + num_valves * K_valve) * q - - return pascals(dp_pipe + dp_fittings) - - -@beartype -def format_cycle_summary(result: CyclePerformance) -> str: - """Format cycle analysis results as readable string. - - Args: - result: CyclePerformance from analyze_cycle() - - Returns: - Formatted multi-line string - """ - status = "✓ FEASIBLE" if result.feasible else "✗ INFEASIBLE" - - lines = [ - f"{'=' * 60}", - f"CYCLE ANALYSIS: {result.cycle_type.name}", - f"Status: {status}", - f"{'=' * 60}", - "", - "PERFORMANCE:", - f" Net Isp: {result.net_isp.value:.1f} s", - f" Net Thrust: {result.net_thrust.to('kN').value:.2f} kN", - f" Cycle Efficiency: {result.cycle_efficiency * 100:.1f}%", - "", - "POWER BALANCE:", - f" Turbine Power: {result.turbine_power.value / 1000:.1f} kW", - f" Pump Power (Ox): {result.pump_power_ox.value / 1000:.1f} kW", - f" Pump Power (Fuel): {result.pump_power_fuel.value / 1000:.1f} kW", - f" Turbine Flow: {result.turbine_mass_flow.value:.3f} kg/s", - "", - "TANK REQUIREMENTS:", - f" Ox Tank Pressure: {result.tank_pressure_ox.to('bar').value:.1f} bar", - f" Fuel Tank Pressure:{result.tank_pressure_fuel.to('bar').value:.1f} bar", - "", - "NPSH MARGINS:", - f" Ox NPSH Margin: {result.npsh_margin_ox.to('bar').value:.2f} bar", - f" Fuel NPSH Margin: {result.npsh_margin_fuel.to('bar').value:.2f} bar", - ] - - if result.warnings: - lines.extend(["", "WARNINGS:"]) - for warning in result.warnings: - lines.append(f" ⚠ {warning}") - - lines.append(f"{'=' * 60}") - - return "\n".join(lines) - diff --git a/openrocketengine/cycles/gas_generator.py b/openrocketengine/cycles/gas_generator.py deleted file mode 100644 index c3320b0..0000000 --- a/openrocketengine/cycles/gas_generator.py +++ /dev/null @@ -1,347 +0,0 @@ -"""Gas generator engine cycle analysis. - -The gas generator (GG) cycle is the most common turbopump-fed cycle. -A small portion of propellants is burned in a separate gas generator -to drive the turbine, then exhausted overboard (or through a secondary nozzle). - -Advantages: -- Proven, reliable technology -- Simpler than staged combustion -- Lower turbine temperatures -- Decoupled turbine from main chamber - -Disadvantages: -- GG exhaust is "wasted" (reduces effective Isp by 1-3%) -- Limited chamber pressure compared to staged combustion -- Requires separate GG and associated plumbing - -Examples: -- SpaceX Merlin (LOX/RP-1) -- Rocketdyne F-1 (LOX/RP-1) -- RS-68 (LOX/LH2) -- Vulcain (LOX/LH2) -""" - -import math -from dataclasses import dataclass - -from beartype import beartype - -from openrocketengine.cycles.base import ( - CyclePerformance, - CycleType, - estimate_line_losses, - npsh_available, - pump_power, - turbine_power, -) -from openrocketengine.engine import EngineGeometry, EngineInputs, EnginePerformance -from openrocketengine.tanks import get_propellant_density -from openrocketengine.units import Quantity, kelvin, kg_per_second, pascals, seconds - - -# Typical vapor pressures for common propellants [Pa] -VAPOR_PRESSURES: dict[str, float] = { - "LOX": 101325, - "LH2": 101325, - "CH4": 101325, - "RP1": 1000, - "Ethanol": 5900, -} - - -def _get_vapor_pressure(propellant: str) -> float: - """Get vapor pressure for a propellant.""" - name = propellant.upper().replace("-", "").replace(" ", "") - for key, value in VAPOR_PRESSURES.items(): - if key.upper() == name: - return value - return 1000.0 - - -@beartype -@dataclass(frozen=True, slots=True) -class GasGeneratorCycle: - """Configuration for a gas generator engine cycle. - - The gas generator produces hot gas to drive the turbines that power - the propellant pumps. The GG exhaust is typically dumped overboard. - - Attributes: - turbine_inlet_temp: Gas generator combustion temperature [K] - Typically 700-1000K to protect turbine blades - pump_efficiency_ox: Oxidizer pump efficiency (0.6-0.75 typical) - pump_efficiency_fuel: Fuel pump efficiency (0.6-0.75 typical) - turbine_efficiency: Turbine isentropic efficiency (0.5-0.7 typical) - turbine_pressure_ratio: Turbine inlet/outlet pressure ratio (2-6 typical) - gg_mixture_ratio: GG O/F ratio (fuel-rich, typically 0.3-0.5) - mechanical_efficiency: Mechanical losses in turbopump (0.95-0.98) - tank_pressure_ox: Oxidizer tank pressure [Pa] - tank_pressure_fuel: Fuel tank pressure [Pa] - """ - - turbine_inlet_temp: Quantity = None # type: ignore # Will validate in __post_init__ - pump_efficiency_ox: float = 0.70 - pump_efficiency_fuel: float = 0.70 - turbine_efficiency: float = 0.60 - turbine_pressure_ratio: float = 4.0 - gg_mixture_ratio: float = 0.4 # Fuel-rich - mechanical_efficiency: float = 0.97 - tank_pressure_ox: Quantity | None = None - tank_pressure_fuel: Quantity | None = None - - def __post_init__(self) -> None: - """Validate inputs.""" - if self.turbine_inlet_temp is None: - object.__setattr__(self, 'turbine_inlet_temp', kelvin(900)) - - @property - def cycle_type(self) -> CycleType: - return CycleType.GAS_GENERATOR - - def analyze( - self, - inputs: EngineInputs, - performance: EnginePerformance, - geometry: EngineGeometry, - ) -> CyclePerformance: - """Analyze gas generator cycle and compute net performance. - - The key equations are: - 1. Pump power = mdot * delta_P / (rho * eta) - 2. Turbine power = mdot_gg * cp * delta_T * eta - 3. Power balance: P_turbine = P_pump_ox + P_pump_fuel - 4. Net Isp = (F_main - mdot_gg * ue_gg) / (mdot_total * g0) - - Args: - inputs: Engine input parameters - performance: Computed engine performance - geometry: Computed engine geometry - - Returns: - CyclePerformance with net performance and power balance - """ - warnings: list[str] = [] - - # Extract values - pc = inputs.chamber_pressure.to("Pa").value - mdot_total = performance.mdot.to("kg/s").value - mdot_ox = performance.mdot_ox.to("kg/s").value - mdot_fuel = performance.mdot_fuel.to("kg/s").value - isp = performance.isp.value - thrust = inputs.thrust.to("N").value - - # Get propellant properties - # Try to determine from engine name - ox_name = "LOX" - fuel_name = "RP1" - if inputs.name: - name_upper = inputs.name.upper() - if "CH4" in name_upper or "METHANE" in name_upper or "METHALOX" in name_upper: - fuel_name = "CH4" - elif "LH2" in name_upper or "HYDROGEN" in name_upper or "HYDROLOX" in name_upper: - fuel_name = "LH2" - - try: - rho_ox = get_propellant_density(ox_name) - except ValueError: - rho_ox = 1141.0 - warnings.append(f"Unknown oxidizer, assuming LOX density: {rho_ox} kg/m³") - - try: - rho_fuel = get_propellant_density(fuel_name) - except ValueError: - rho_fuel = 810.0 - warnings.append(f"Unknown fuel, assuming RP-1 density: {rho_fuel} kg/m³") - - # Determine tank pressures - if self.tank_pressure_ox is not None: - p_tank_ox = self.tank_pressure_ox.to("Pa").value - else: - # Typical tank pressure for turbopump-fed: 2-5 bar - p_tank_ox = 300000 # 3 bar default - - if self.tank_pressure_fuel is not None: - p_tank_fuel = self.tank_pressure_fuel.to("Pa").value - else: - p_tank_fuel = 250000 # 2.5 bar default - - # Calculate pump pressure rise - # Pump must raise from tank pressure to chamber pressure + injector drop + margins - injector_dp = pc * 0.20 # 20% pressure drop across injector - p_pump_outlet = pc + injector_dp - - dp_ox = p_pump_outlet - p_tank_ox - dp_fuel = p_pump_outlet - p_tank_fuel - - # Pump power requirements - P_pump_ox = pump_power( - mass_flow=kg_per_second(mdot_ox), - pressure_rise=pascals(dp_ox), - density=rho_ox, - efficiency=self.pump_efficiency_ox, - ).value - - P_pump_fuel = pump_power( - mass_flow=kg_per_second(mdot_fuel), - pressure_rise=pascals(dp_fuel), - density=rho_fuel, - efficiency=self.pump_efficiency_fuel, - ).value - - # Total pump power (accounting for mechanical losses) - P_pump_total = (P_pump_ox + P_pump_fuel) / self.mechanical_efficiency - - # Gas generator analysis - # Turbine power must equal pump power - P_turbine_required = P_pump_total - - # GG exhaust properties (fuel-rich combustion products) - # Approximate gamma and R for fuel-rich GG - gamma_gg = 1.25 # Lower gamma for fuel-rich - R_gg = 350.0 # J/(kg·K), approximate for fuel-rich products - T_gg = self.turbine_inlet_temp.to("K").value - - # Turbine specific work - # w = cp * T_in * eta * (1 - 1/PR^((gamma-1)/gamma)) - cp_gg = gamma_gg * R_gg / (gamma_gg - 1) - T_ratio = self.turbine_pressure_ratio ** ((gamma_gg - 1) / gamma_gg) - w_turbine = cp_gg * T_gg * self.turbine_efficiency * (1 - 1/T_ratio) - - # GG mass flow required - mdot_gg = P_turbine_required / w_turbine - - # Check GG flow is reasonable (typically 1-5% of total) - gg_fraction = mdot_gg / mdot_total - if gg_fraction > 0.10: - warnings.append( - f"GG flow is {gg_fraction*100:.1f}% of total - unusually high" - ) - - # GG propellant split - mdot_gg_ox = mdot_gg * self.gg_mixture_ratio / (1 + self.gg_mixture_ratio) - mdot_gg_fuel = mdot_gg / (1 + self.gg_mixture_ratio) - - # Net performance calculation - # The GG exhaust has much lower velocity than main chamber - # Approximate GG exhaust velocity - # For low MR (fuel-rich): Isp_gg ~ 200-250s - isp_gg = 220.0 # s, approximate for fuel-rich GG exhaust - g0 = 9.80665 - ue_gg = isp_gg * g0 - - # GG exhaust thrust (negative contribution to net thrust) - F_gg = mdot_gg * ue_gg - - # Net thrust and Isp - # Main chamber produces full thrust - # But we've "spent" mdot_gg propellant for low-Isp exhaust - F_main = thrust - net_thrust = F_main # GG exhaust typically dumps to atmosphere - - # Effective total mass flow (main + GG) - mdot_effective = mdot_total # GG flow comes from same tanks - - # Net Isp considering the GG "loss" - # Two ways to think about it: - # 1. All propellant flows through main chamber at full Isp - # 2. GG flow produces low-Isp exhaust - # Net: weighted average of Isp - net_isp = (F_main + F_gg * 0.3) / (mdot_effective * g0) # GG contributes ~30% of its thrust - - # Alternative: simple debit approach - # net_isp = isp * (1 - gg_fraction) + isp_gg * gg_fraction - net_isp_alt = isp * (1 - gg_fraction * 0.7) # ~70% loss on GG flow - - # Use the more conservative estimate - net_isp = min(net_isp, net_isp_alt) - - cycle_efficiency = net_isp / isp - - # NPSH analysis - p_vapor_ox = _get_vapor_pressure(ox_name) - p_vapor_fuel = _get_vapor_pressure(fuel_name) - - npsh_ox = npsh_available( - tank_pressure=pascals(p_tank_ox), - fluid_density=rho_ox, - vapor_pressure=pascals(p_vapor_ox), - ) - - npsh_fuel = npsh_available( - tank_pressure=pascals(p_tank_fuel), - fluid_density=rho_fuel, - vapor_pressure=pascals(p_vapor_fuel), - ) - - # Feasibility checks - feasible = True - - if npsh_ox.to("Pa").value < 50000: # < 0.5 bar - warnings.append("Low NPSH margin for oxidizer pump - risk of cavitation") - - if npsh_fuel.to("Pa").value < 50000: - warnings.append("Low NPSH margin for fuel pump - risk of cavitation") - - if T_gg > 1100: - warnings.append( - f"Turbine inlet temp {T_gg:.0f}K exceeds typical limit (~1000K)" - ) - - if gg_fraction > 0.05: - warnings.append( - f"GG fraction {gg_fraction*100:.1f}% is high - consider staged combustion" - ) - - return CyclePerformance( - net_isp=seconds(net_isp), - net_thrust=Quantity(net_thrust, "N", "force"), - cycle_efficiency=cycle_efficiency, - pump_power_ox=Quantity(P_pump_ox, "W", "power"), - pump_power_fuel=Quantity(P_pump_fuel, "W", "power"), - turbine_power=Quantity(P_turbine_required, "W", "power"), - turbine_mass_flow=kg_per_second(mdot_gg), - tank_pressure_ox=pascals(p_tank_ox), - tank_pressure_fuel=pascals(p_tank_fuel), - npsh_margin_ox=npsh_ox, - npsh_margin_fuel=npsh_fuel, - cycle_type=self.cycle_type, - feasible=feasible, - warnings=warnings, - ) - - -@beartype -def estimate_turbopump_mass( - pump_power: Quantity, - turbine_power: Quantity, - propellant_type: str = "LOX/RP1", -) -> Quantity: - """Estimate turbopump mass from power requirements. - - Uses historical correlations from existing engines. - - Args: - pump_power: Total pump power [W] - turbine_power: Turbine power [W] - propellant_type: Propellant combination for correlation selection - - Returns: - Estimated turbopump mass [kg] - """ - P = max(pump_power.value, turbine_power.value) - - # Historical correlation: mass ~ k * P^0.6 - # k varies by propellant type and technology level - if "LH2" in propellant_type.upper(): - k = 0.015 # LH2 pumps are larger due to low density - else: - k = 0.008 # LOX/RP-1, LOX/CH4 - - mass = k * P ** 0.6 - - # Minimum mass for small turbopumps - mass = max(mass, 5.0) - - return Quantity(mass, "kg", "mass") - diff --git a/openrocketengine/cycles/pressure_fed.py b/openrocketengine/cycles/pressure_fed.py deleted file mode 100644 index 01267da..0000000 --- a/openrocketengine/cycles/pressure_fed.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Pressure-fed engine cycle analysis. - -Pressure-fed engines use high-pressure gas (typically helium) to push -propellants from tanks into the combustion chamber. They are the simplest -cycle type but require heavy tanks to contain the high pressures. - -Advantages: -- Simplicity and reliability (no turbopumps) -- Fewer failure modes -- Lower development cost - -Disadvantages: -- Heavy tanks (must withstand full chamber pressure + margins) -- Limited chamber pressure (~3 MPa practical limit) -- Lower performance (limited Isp due to pressure constraints) - -Typical applications: -- Upper stages -- Spacecraft thrusters -- Student/amateur rockets -""" - -from dataclasses import dataclass - -from beartype import beartype - -from openrocketengine.cycles.base import ( - CyclePerformance, - CycleType, - estimate_line_losses, - npsh_available, -) -from openrocketengine.engine import EngineGeometry, EngineInputs, EnginePerformance -from openrocketengine.tanks import get_propellant_density -from openrocketengine.units import Quantity, kg_per_second, pascals, seconds - - -# Typical vapor pressures for common propellants [Pa] -# At nominal storage temperatures -VAPOR_PRESSURES: dict[str, float] = { - "LOX": 101325, # ~1 atm at -183°C - "LH2": 101325, # ~1 atm at -253°C - "CH4": 101325, # ~1 atm at -161°C - "RP1": 1000, # Very low at 20°C - "Ethanol": 5900, # ~0.06 atm at 20°C - "N2O4": 96000, # ~0.95 atm at 20°C - "MMH": 4800, # Low at 20°C - "N2O": 5200000, # ~51 atm at 20°C (self-pressurizing) -} - - -def _get_vapor_pressure(propellant: str) -> float: - """Get vapor pressure for a propellant.""" - # Normalize name - name = propellant.upper().replace("-", "").replace(" ", "") - - for key, value in VAPOR_PRESSURES.items(): - if key.upper() == name: - return value - - # Default to low vapor pressure if unknown - return 1000.0 - - -@beartype -@dataclass(frozen=True, slots=True) -class PressureFedCycle: - """Configuration for a pressure-fed engine cycle. - - In a pressure-fed system, the tank pressure must exceed the chamber - pressure plus all line losses and injector pressure drop. - - Attributes: - injector_dp_fraction: Injector pressure drop as fraction of Pc (typically 0.15-0.25) - line_loss_fraction: Feed line losses as fraction of Pc (typically 0.05-0.10) - tank_pressure_margin: Safety margin on tank pressure (typically 1.1-1.2) - pressurant: Pressurant gas type (typically "helium" or "nitrogen") - ox_line_diameter: Oxidizer feed line diameter [m] - fuel_line_diameter: Fuel feed line diameter [m] - ox_line_length: Oxidizer feed line length [m] - fuel_line_length: Fuel feed line length [m] - """ - - injector_dp_fraction: float = 0.20 - line_loss_fraction: float = 0.05 - tank_pressure_margin: float = 1.15 - pressurant: str = "helium" - ox_line_diameter: float = 0.05 # m - fuel_line_diameter: float = 0.04 # m - ox_line_length: float = 2.0 # m - fuel_line_length: float = 2.0 # m - - @property - def cycle_type(self) -> CycleType: - return CycleType.PRESSURE_FED - - def analyze( - self, - inputs: EngineInputs, - performance: EnginePerformance, - geometry: EngineGeometry, - ) -> CyclePerformance: - """Analyze pressure-fed cycle and determine tank pressures. - - Args: - inputs: Engine input parameters - performance: Computed engine performance - geometry: Computed engine geometry - - Returns: - CyclePerformance with pressure requirements and feasibility - """ - warnings: list[str] = [] - - # Extract values - pc = inputs.chamber_pressure.to("Pa").value - mdot_ox = performance.mdot_ox.to("kg/s").value - mdot_fuel = performance.mdot_fuel.to("kg/s").value - - # Get propellant densities - # Extract propellant names from engine name or use defaults - ox_name = "LOX" # Default - fuel_name = "RP1" # Default - if inputs.name: - name_upper = inputs.name.upper() - if "LOX" in name_upper or "LO2" in name_upper: - ox_name = "LOX" - if "CH4" in name_upper or "METHANE" in name_upper: - fuel_name = "CH4" - elif "RP1" in name_upper or "KEROSENE" in name_upper: - fuel_name = "RP1" - elif "ETHANOL" in name_upper: - fuel_name = "Ethanol" - elif "LH2" in name_upper or "HYDROGEN" in name_upper: - fuel_name = "LH2" - - try: - rho_ox = get_propellant_density(ox_name) - except ValueError: - rho_ox = 1141.0 # Default LOX - warnings.append(f"Unknown oxidizer, assuming LOX density: {rho_ox} kg/m³") - - try: - rho_fuel = get_propellant_density(fuel_name) - except ValueError: - rho_fuel = 810.0 # Default RP-1 - warnings.append(f"Unknown fuel, assuming RP-1 density: {rho_fuel} kg/m³") - - # Calculate pressure budget - # Tank pressure must overcome: chamber + injector drop + line losses - dp_injector = pc * self.injector_dp_fraction - dp_lines_ox = pc * self.line_loss_fraction - dp_lines_fuel = pc * self.line_loss_fraction - - # More detailed line loss estimate - dp_lines_ox_calc = estimate_line_losses( - mass_flow=kg_per_second(mdot_ox), - density=rho_ox, - pipe_diameter=self.ox_line_diameter, - pipe_length=self.ox_line_length, - ).to("Pa").value - - dp_lines_fuel_calc = estimate_line_losses( - mass_flow=kg_per_second(mdot_fuel), - density=rho_fuel, - pipe_diameter=self.fuel_line_diameter, - pipe_length=self.fuel_line_length, - ).to("Pa").value - - # Use maximum of estimated and calculated - dp_lines_ox = max(dp_lines_ox, dp_lines_ox_calc) - dp_lines_fuel = max(dp_lines_fuel, dp_lines_fuel_calc) - - # Required tank pressures - p_tank_ox = (pc + dp_injector + dp_lines_ox) * self.tank_pressure_margin - p_tank_fuel = (pc + dp_injector + dp_lines_fuel) * self.tank_pressure_margin - - # NPSH analysis - p_vapor_ox = _get_vapor_pressure(ox_name) - p_vapor_fuel = _get_vapor_pressure(fuel_name) - - npsh_ox = npsh_available( - tank_pressure=pascals(p_tank_ox), - fluid_density=rho_ox, - vapor_pressure=pascals(p_vapor_ox), - line_losses=pascals(dp_lines_ox), - ) - - npsh_fuel = npsh_available( - tank_pressure=pascals(p_tank_fuel), - fluid_density=rho_fuel, - vapor_pressure=pascals(p_vapor_fuel), - line_losses=pascals(dp_lines_fuel), - ) - - # Check feasibility - feasible = True - - # Pressure-fed practical limit is ~3-4 MPa - if pc > 4e6: - warnings.append( - f"Chamber pressure {pc/1e6:.1f} MPa exceeds typical pressure-fed limit (~3-4 MPa)" - ) - - # Tank pressure feasibility - if p_tank_ox > 6e6: - warnings.append( - f"Ox tank pressure {p_tank_ox/1e6:.1f} MPa is very high for pressure-fed" - ) - if p_tank_fuel > 6e6: - warnings.append( - f"Fuel tank pressure {p_tank_fuel/1e6:.1f} MPa is very high for pressure-fed" - ) - - # For pressure-fed, there are no turbopumps, so no pump power - # All "pumping" is done by the pressurized tanks - pump_power_ox = Quantity(0.0, "W", "power") - pump_power_fuel = Quantity(0.0, "W", "power") - turbine_power = Quantity(0.0, "W", "power") - turbine_flow = kg_per_second(0.0) - - # Net performance equals ideal performance (no turbine drive losses) - net_isp = performance.isp - net_thrust = inputs.thrust - cycle_efficiency = 1.0 # No cycle losses for pressure-fed - - return CyclePerformance( - net_isp=net_isp, - net_thrust=net_thrust, - cycle_efficiency=cycle_efficiency, - pump_power_ox=pump_power_ox, - pump_power_fuel=pump_power_fuel, - turbine_power=turbine_power, - turbine_mass_flow=turbine_flow, - tank_pressure_ox=pascals(p_tank_ox), - tank_pressure_fuel=pascals(p_tank_fuel), - npsh_margin_ox=npsh_ox, - npsh_margin_fuel=npsh_fuel, - cycle_type=self.cycle_type, - feasible=feasible, - warnings=warnings, - ) - - -@beartype -def estimate_pressurant_mass( - propellant_volume: Quantity, - tank_pressure: Quantity, - pressurant: str = "helium", - initial_temp: float = 300.0, # K - blowdown_ratio: float = 2.0, -) -> Quantity: - """Estimate pressurant gas mass required. - - Uses ideal gas law with blowdown consideration. - In blowdown mode, tank pressure drops as propellant is expelled. - - Args: - propellant_volume: Volume of propellant to expel [m³] - tank_pressure: Initial tank pressure [Pa] - pressurant: Gas type ("helium" or "nitrogen") - initial_temp: Pressurant initial temperature [K] - blowdown_ratio: Initial/final pressure ratio for blowdown - - Returns: - Required pressurant mass [kg] - """ - # Gas constants - R_helium = 2077.0 # J/(kg·K) - R_nitrogen = 296.8 # J/(kg·K) - - R = R_helium if pressurant.lower() == "helium" else R_nitrogen - - V = propellant_volume.to("m^3").value - P = tank_pressure.to("Pa").value - - # For pressure-regulated system: m = P * V / (R * T) - # For blowdown: need to account for pressure decay - # Simplified: assume average pressure - P_avg = P / (1 + 1/blowdown_ratio) * 2 - - mass = P_avg * V / (R * initial_temp) - - # Add margin for residuals and cooling - mass *= 1.2 - - return Quantity(mass, "kg", "mass") - diff --git a/openrocketengine/cycles/staged_combustion.py b/openrocketengine/cycles/staged_combustion.py deleted file mode 100644 index 3a2bfc1..0000000 --- a/openrocketengine/cycles/staged_combustion.py +++ /dev/null @@ -1,488 +0,0 @@ -"""Staged combustion engine cycle analysis. - -Staged combustion is the highest-performance liquid engine cycle. Unlike -gas generators where turbine exhaust is dumped overboard, staged combustion -routes all turbine exhaust into the main combustion chamber. - -Variants: -- Oxidizer-rich staged combustion (ORSC): Preburner runs oxidizer-rich - Example: RD-180, RD-191, NK-33 -- Fuel-rich staged combustion (FRSC): Preburner runs fuel-rich - Example: RS-25 (SSME), BE-4 -- Full-flow staged combustion (FFSC): Both ox-rich AND fuel-rich preburners - Example: SpaceX Raptor - -Advantages: -- Highest Isp (all propellant goes through main chamber) -- High chamber pressure capability -- High thrust-to-weight ratio - -Disadvantages: -- Most complex cycle -- Expensive development -- Challenging turbine environments (especially ORSC) - -References: - - Sutton & Biblarz, Chapter 6 - - Humble, Henry & Larson, "Space Propulsion Analysis and Design" -""" - -import math -from dataclasses import dataclass - -from beartype import beartype - -from openrocketengine.cycles.base import ( - CyclePerformance, - CycleType, - npsh_available, - pump_power, -) -from openrocketengine.engine import EngineGeometry, EngineInputs, EnginePerformance -from openrocketengine.tanks import get_propellant_density -from openrocketengine.units import Quantity, kelvin, kg_per_second, pascals, seconds - - -# Typical vapor pressures for common propellants [Pa] -VAPOR_PRESSURES: dict[str, float] = { - "LOX": 101325, - "LH2": 101325, - "CH4": 101325, - "RP1": 1000, -} - - -def _get_vapor_pressure(propellant: str) -> float: - """Get vapor pressure for a propellant.""" - name = propellant.upper().replace("-", "").replace(" ", "") - for key, value in VAPOR_PRESSURES.items(): - if key.upper() == name: - return value - return 1000.0 - - -@beartype -@dataclass(frozen=True, slots=True) -class StagedCombustionCycle: - """Configuration for a staged combustion engine cycle. - - In staged combustion, the preburner exhaust (which drove the turbine) - is routed into the main combustion chamber, so no propellant is wasted. - - Attributes: - preburner_temp: Preburner combustion temperature [K] - Typically 700-900K for fuel-rich, 500-700K for ox-rich - pump_efficiency_ox: Oxidizer pump efficiency (0.7-0.8 typical) - pump_efficiency_fuel: Fuel pump efficiency (0.7-0.8 typical) - turbine_efficiency: Turbine isentropic efficiency (0.6-0.75 typical) - turbine_pressure_ratio: Turbine pressure ratio (1.5-3.0 typical) - preburner_mixture_ratio: Preburner O/F ratio - Fuel-rich: 0.3-0.6 (for SSME-type) - Ox-rich: 50-100 (for RD-180-type) - oxidizer_rich: If True, uses ox-rich preburner (ORSC) - mechanical_efficiency: Mechanical losses (0.95-0.98) - tank_pressure_ox: Oxidizer tank pressure [Pa] - tank_pressure_fuel: Fuel tank pressure [Pa] - """ - - preburner_temp: Quantity | None = None - pump_efficiency_ox: float = 0.75 - pump_efficiency_fuel: float = 0.75 - turbine_efficiency: float = 0.70 - turbine_pressure_ratio: float = 2.0 - preburner_mixture_ratio: float = 0.5 # Fuel-rich default - oxidizer_rich: bool = False - mechanical_efficiency: float = 0.97 - tank_pressure_ox: Quantity | None = None - tank_pressure_fuel: Quantity | None = None - - @property - def cycle_type(self) -> CycleType: - return CycleType.STAGED_COMBUSTION - - def analyze( - self, - inputs: EngineInputs, - performance: EnginePerformance, - geometry: EngineGeometry, - ) -> CyclePerformance: - """Analyze staged combustion cycle. - - The key difference from gas generator is that turbine exhaust - goes to the main chamber, so there's no Isp penalty from - dumping low-energy gases. - - The power balance is more complex because the preburner - operates at a pressure higher than the main chamber. - """ - warnings: list[str] = [] - - # Extract values - pc = inputs.chamber_pressure.to("Pa").value - mdot_total = performance.mdot.to("kg/s").value - mdot_ox = performance.mdot_ox.to("kg/s").value - mdot_fuel = performance.mdot_fuel.to("kg/s").value - isp = performance.isp.value - - # Set default preburner temperature - if self.preburner_temp is not None: - T_pb = self.preburner_temp.to("K").value - else: - # Default based on cycle type - T_pb = 600.0 if self.oxidizer_rich else 800.0 - - # Get propellant properties - ox_name = "LOX" - fuel_name = "RP1" - if inputs.name: - name_upper = inputs.name.upper() - if "CH4" in name_upper or "METHANE" in name_upper: - fuel_name = "CH4" - elif "LH2" in name_upper or "HYDROGEN" in name_upper: - fuel_name = "LH2" - - try: - rho_ox = get_propellant_density(ox_name) - except ValueError: - rho_ox = 1141.0 - warnings.append(f"Unknown oxidizer, assuming LOX density: {rho_ox} kg/m³") - - try: - rho_fuel = get_propellant_density(fuel_name) - except ValueError: - rho_fuel = 810.0 - warnings.append(f"Unknown fuel, assuming RP-1 density: {rho_fuel} kg/m³") - - # Tank pressures - if self.tank_pressure_ox is not None: - p_tank_ox = self.tank_pressure_ox.to("Pa").value - else: - p_tank_ox = 400000 # 4 bar typical for staged combustion - - if self.tank_pressure_fuel is not None: - p_tank_fuel = self.tank_pressure_fuel.to("Pa").value - else: - p_tank_fuel = 350000 # 3.5 bar - - # Preburner pressure must be higher than chamber pressure - # Turbine pressure drop + injector losses - p_preburner = pc * 1.3 # 30% higher than chamber - - # Pump pressure rises - # Pumps must deliver to preburner pressure (higher than PC) - injector_dp = pc * 0.20 - p_pump_outlet = p_preburner + injector_dp - - dp_ox = p_pump_outlet - p_tank_ox - dp_fuel = p_pump_outlet - p_tank_fuel - - # Pump power requirements - P_pump_ox = pump_power( - mass_flow=kg_per_second(mdot_ox), - pressure_rise=pascals(dp_ox), - density=rho_ox, - efficiency=self.pump_efficiency_ox, - ).value - - P_pump_fuel = pump_power( - mass_flow=kg_per_second(mdot_fuel), - pressure_rise=pascals(dp_fuel), - density=rho_fuel, - efficiency=self.pump_efficiency_fuel, - ).value - - # Total pump power - P_pump_total = (P_pump_ox + P_pump_fuel) / self.mechanical_efficiency - - # Preburner/turbine analysis - # In staged combustion, ALL propellant eventually goes through main chamber - # Preburner flow drives turbine, then goes to main chamber - - if self.oxidizer_rich: - # Ox-rich: most of oxidizer through preburner with small fuel - # mdot_pb = mdot_ox + mdot_pb_fuel - # Preburner MR is very high (50-100), so mdot_pb_fuel is small - mdot_pb_fuel = mdot_ox / self.preburner_mixture_ratio - mdot_pb = mdot_ox + mdot_pb_fuel - # Remaining fuel goes directly to main chamber - mdot_direct_fuel = mdot_fuel - mdot_pb_fuel - - if mdot_direct_fuel < 0: - warnings.append("Preburner consumes more fuel than available - infeasible") - mdot_direct_fuel = 0 - - # Preburner exhaust is mostly oxygen with some combustion products - gamma_pb = 1.30 # Higher gamma for ox-rich - R_pb = 280.0 # J/(kg·K) - - else: - # Fuel-rich: most of fuel through preburner with small oxidizer - mdot_pb_ox = mdot_fuel * self.preburner_mixture_ratio - mdot_pb = mdot_fuel + mdot_pb_ox - # Remaining oxidizer goes directly to main chamber - mdot_direct_ox = mdot_ox - mdot_pb_ox - - if mdot_direct_ox < 0: - warnings.append("Preburner consumes more oxidizer than available - infeasible") - mdot_direct_ox = 0 - - # Preburner exhaust is fuel-rich combustion products - gamma_pb = 1.20 # Lower gamma for fuel-rich - R_pb = 400.0 # J/(kg·K), higher for lighter products - - # Turbine power available - cp_pb = gamma_pb * R_pb / (gamma_pb - 1) - T_ratio = self.turbine_pressure_ratio ** ((gamma_pb - 1) / gamma_pb) - w_turbine = cp_pb * T_pb * self.turbine_efficiency * (1 - 1/T_ratio) - - P_turbine_available = mdot_pb * w_turbine - - # Power balance check - power_margin = P_turbine_available / P_pump_total if P_pump_total > 0 else float('inf') - - if power_margin < 1.0: - warnings.append( - f"Power balance not achieved: turbine provides {P_turbine_available/1e6:.1f} MW, " - f"pumps need {P_pump_total/1e6:.1f} MW" - ) - - # Net performance - # In staged combustion, ALL propellant goes through main chamber - # at (nearly) full Isp, so cycle efficiency is very high - # Small losses from: - # 1. Preburner inefficiency - # 2. Slightly different combustion from preburned products - - # Estimate efficiency loss (typically 1-3%) - efficiency_loss = 0.02 # 2% loss typical for staged combustion - net_isp = isp * (1 - efficiency_loss) - cycle_efficiency = 1 - efficiency_loss - - # NPSH analysis - p_vapor_ox = _get_vapor_pressure(ox_name) - p_vapor_fuel = _get_vapor_pressure(fuel_name) - - npsh_ox = npsh_available( - tank_pressure=pascals(p_tank_ox), - fluid_density=rho_ox, - vapor_pressure=pascals(p_vapor_ox), - ) - - npsh_fuel = npsh_available( - tank_pressure=pascals(p_tank_fuel), - fluid_density=rho_fuel, - vapor_pressure=pascals(p_vapor_fuel), - ) - - # Feasibility assessment - feasible = power_margin >= 0.95 # Allow small margin - - if power_margin < 1.0: - feasible = False - - if self.oxidizer_rich and T_pb > 700: - warnings.append( - f"Ox-rich preburner at {T_pb:.0f}K - requires specialized turbine materials" - ) - - if not self.oxidizer_rich and T_pb > 1000: - warnings.append( - f"Fuel-rich preburner temp {T_pb:.0f}K is high" - ) - - if pc > 25e6: - warnings.append( - f"Chamber pressure {pc/1e6:.0f} MPa is very high - " - "typical staged combustion limit ~30 MPa" - ) - - return CyclePerformance( - net_isp=seconds(net_isp), - net_thrust=inputs.thrust, # Staged combustion delivers full thrust - cycle_efficiency=cycle_efficiency, - pump_power_ox=Quantity(P_pump_ox, "W", "power"), - pump_power_fuel=Quantity(P_pump_fuel, "W", "power"), - turbine_power=Quantity(P_turbine_available, "W", "power"), - turbine_mass_flow=kg_per_second(mdot_pb), - tank_pressure_ox=pascals(p_tank_ox), - tank_pressure_fuel=pascals(p_tank_fuel), - npsh_margin_ox=npsh_ox, - npsh_margin_fuel=npsh_fuel, - cycle_type=self.cycle_type, - feasible=feasible, - warnings=warnings, - ) - - -@beartype -@dataclass(frozen=True, slots=True) -class FullFlowStagedCombustion: - """Configuration for full-flow staged combustion (FFSC). - - FFSC uses TWO preburners: - - Fuel-rich preburner: drives fuel turbopump - - Ox-rich preburner: drives oxidizer turbopump - - This provides the highest possible performance and allows - independent control of each turbopump. - - Example: SpaceX Raptor - - Attributes: - fuel_preburner_temp: Fuel-rich preburner temperature [K] - ox_preburner_temp: Ox-rich preburner temperature [K] - pump_efficiency: Pump efficiency for both pumps - turbine_efficiency: Turbine efficiency for both turbines - fuel_turbine_pr: Fuel turbine pressure ratio - ox_turbine_pr: Ox turbine pressure ratio - """ - - fuel_preburner_temp: Quantity | None = None - ox_preburner_temp: Quantity | None = None - pump_efficiency: float = 0.77 - turbine_efficiency: float = 0.72 - fuel_turbine_pr: float = 1.8 - ox_turbine_pr: float = 1.5 - tank_pressure_ox: Quantity | None = None - tank_pressure_fuel: Quantity | None = None - - @property - def cycle_type(self) -> CycleType: - return CycleType.FULL_FLOW_STAGED - - def analyze( - self, - inputs: EngineInputs, - performance: EnginePerformance, - geometry: EngineGeometry, - ) -> CyclePerformance: - """Analyze full-flow staged combustion cycle.""" - warnings: list[str] = [] - - # Extract values - pc = inputs.chamber_pressure.to("Pa").value - mdot_ox = performance.mdot_ox.to("kg/s").value - mdot_fuel = performance.mdot_fuel.to("kg/s").value - isp = performance.isp.value - - # Default preburner temperatures - T_fuel_pb = self.fuel_preburner_temp.to("K").value if self.fuel_preburner_temp else 800.0 - T_ox_pb = self.ox_preburner_temp.to("K").value if self.ox_preburner_temp else 600.0 - - # Get propellant properties - try: - rho_ox = get_propellant_density("LOX") - except ValueError: - rho_ox = 1141.0 - - try: - rho_fuel = get_propellant_density("CH4") # FFSC typically uses methane - except ValueError: - rho_fuel = 422.0 - - # Tank pressures - p_tank_ox = self.tank_pressure_ox.to("Pa").value if self.tank_pressure_ox else 500000 - p_tank_fuel = self.tank_pressure_fuel.to("Pa").value if self.tank_pressure_fuel else 450000 - - # Preburner pressures (higher than chamber) - p_preburner = pc * 1.25 - - # Pump requirements - dp_ox = p_preburner - p_tank_ox + pc * 0.2 - dp_fuel = p_preburner - p_tank_fuel + pc * 0.2 - - P_pump_ox = pump_power( - mass_flow=kg_per_second(mdot_ox), - pressure_rise=pascals(dp_ox), - density=rho_ox, - efficiency=self.pump_efficiency, - ).value - - P_pump_fuel = pump_power( - mass_flow=kg_per_second(mdot_fuel), - pressure_rise=pascals(dp_fuel), - density=rho_fuel, - efficiency=self.pump_efficiency, - ).value - - # In FFSC, all oxidizer goes through ox-rich preburner - # All fuel goes through fuel-rich preburner - # Each preburner drives its respective turbopump - - # Fuel-rich preburner (drives fuel pump) - # Small amount of ox mixed with all fuel - gamma_fuel_pb = 1.18 - R_fuel_pb = 450.0 - cp_fuel_pb = gamma_fuel_pb * R_fuel_pb / (gamma_fuel_pb - 1) - T_ratio_fuel = self.fuel_turbine_pr ** ((gamma_fuel_pb - 1) / gamma_fuel_pb) - w_fuel_turbine = cp_fuel_pb * T_fuel_pb * self.turbine_efficiency * (1 - 1/T_ratio_fuel) - - # Ox-rich preburner (drives ox pump) - gamma_ox_pb = 1.30 - R_ox_pb = 280.0 - cp_ox_pb = gamma_ox_pb * R_ox_pb / (gamma_ox_pb - 1) - T_ratio_ox = self.ox_turbine_pr ** ((gamma_ox_pb - 1) / gamma_ox_pb) - w_ox_turbine = cp_ox_pb * T_ox_pb * self.turbine_efficiency * (1 - 1/T_ratio_ox) - - # Power available from each turbine - # In FFSC, all propellant flows through preburners - P_fuel_turbine = mdot_fuel * w_fuel_turbine - P_ox_turbine = mdot_ox * w_ox_turbine - - # Check power balance - fuel_margin = P_fuel_turbine / P_pump_fuel if P_pump_fuel > 0 else float('inf') - ox_margin = P_ox_turbine / P_pump_ox if P_pump_ox > 0 else float('inf') - - feasible = True - if fuel_margin < 0.95: - warnings.append(f"Fuel turbopump power margin low: {fuel_margin:.2f}") - feasible = False - if ox_margin < 0.95: - warnings.append(f"Ox turbopump power margin low: {ox_margin:.2f}") - feasible = False - - # FFSC has minimal Isp loss (all propellant to main chamber) - efficiency_loss = 0.01 # ~1% loss - net_isp = isp * (1 - efficiency_loss) - cycle_efficiency = 1 - efficiency_loss - - # NPSH - p_vapor_ox = _get_vapor_pressure("LOX") - p_vapor_fuel = _get_vapor_pressure("CH4") - - npsh_ox = npsh_available( - tank_pressure=pascals(p_tank_ox), - fluid_density=rho_ox, - vapor_pressure=pascals(p_vapor_ox), - ) - - npsh_fuel = npsh_available( - tank_pressure=pascals(p_tank_fuel), - fluid_density=rho_fuel, - vapor_pressure=pascals(p_vapor_fuel), - ) - - # Warnings - if pc > 30e6: - warnings.append(f"Chamber pressure {pc/1e6:.0f} MPa is at FFSC limit") - - if T_ox_pb > 700: - warnings.append("Ox-rich preburner temp requires advanced turbine materials") - - return CyclePerformance( - net_isp=seconds(net_isp), - net_thrust=inputs.thrust, - cycle_efficiency=cycle_efficiency, - pump_power_ox=Quantity(P_pump_ox, "W", "power"), - pump_power_fuel=Quantity(P_pump_fuel, "W", "power"), - turbine_power=Quantity(P_fuel_turbine + P_ox_turbine, "W", "power"), - turbine_mass_flow=kg_per_second(mdot_ox + mdot_fuel), # All flow through preburners - tank_pressure_ox=pascals(p_tank_ox), - tank_pressure_fuel=pascals(p_tank_fuel), - npsh_margin_ox=npsh_ox, - npsh_margin_fuel=npsh_fuel, - cycle_type=self.cycle_type, - feasible=feasible, - warnings=warnings, - ) - diff --git a/openrocketengine/engine.py b/openrocketengine/engine.py deleted file mode 100644 index ec8a870..0000000 --- a/openrocketengine/engine.py +++ /dev/null @@ -1,632 +0,0 @@ -"""Engine module for OpenRocketEngine. - -This module provides the core data structures and computation functions for -rocket engine design and analysis. - -Design principles: -- Immutable dataclasses for all engine parameters -- Pure functions for all computations (no side effects) -- Explicit data flow: inputs -> performance -> geometry -- Type safety with beartype runtime checking -""" - -import math -from dataclasses import dataclass - -from beartype import beartype - -from openrocketengine.isentropic import ( - G0_SI, - area_ratio_from_mach, - bell_nozzle_length, - chamber_volume, - characteristic_velocity, - cylindrical_chamber_length, - diameter_from_area, - mach_from_pressure_ratio, - mass_flow_rate, - specific_gas_constant, - specific_impulse, - throat_area, - thrust_coefficient, - thrust_coefficient_vacuum, -) -from openrocketengine.units import ( - Quantity, - kelvin, - kg_per_second, - meters, - meters_per_second, - pascals, - seconds, - square_meters, -) - -# ============================================================================= -# Input Data Structures -# ============================================================================= - - -@beartype -@dataclass(frozen=True, slots=True) -class EngineInputs: - """All inputs required to define a rocket engine. - - This immutable dataclass contains all the parameters needed to compute - engine performance and geometry. All physical quantities use the Quantity - class for type safety and unit handling. - - Attributes: - thrust: Sea-level thrust [force] - chamber_pressure: Chamber (stagnation) pressure [pressure] - chamber_temp: Chamber (stagnation) temperature [temperature] - exit_pressure: Nozzle exit pressure [pressure] - ambient_pressure: Ambient pressure for performance calculation [pressure] - molecular_weight: Molecular weight of exhaust gases [kg/kmol] - gamma: Ratio of specific heats (Cp/Cv) [-] - lstar: Characteristic chamber length [length] - mixture_ratio: Oxidizer to fuel mass ratio [-] - contraction_ratio: Chamber area / throat area [-] - contraction_angle: Chamber convergence half-angle [degrees] - bell_fraction: Bell nozzle length as fraction of 15° cone [-] - name: Optional engine name for identification - """ - - thrust: Quantity # Sea-level thrust - chamber_pressure: Quantity # pc - chamber_temp: Quantity # Tc - exit_pressure: Quantity # pe - molecular_weight: float # kg/kmol - gamma: float # Cp/Cv, dimensionless - lstar: Quantity # L*, characteristic length - mixture_ratio: float # O/F ratio, dimensionless - ambient_pressure: Quantity | None = None # pa, defaults to pe - contraction_ratio: float = 4.0 # Ac/At, dimensionless - contraction_angle: float = 45.0 # degrees - bell_fraction: float = 0.8 # fraction of 15° cone length - name: str | None = None - - def __post_init__(self) -> None: - """Validate inputs after initialization.""" - # Validate dimensions - if self.thrust.dimension != "force": - raise ValueError(f"thrust must be force, got {self.thrust.dimension}") - if self.chamber_pressure.dimension != "pressure": - raise ValueError( - f"chamber_pressure must be pressure, got {self.chamber_pressure.dimension}" - ) - if self.chamber_temp.dimension != "temperature": - raise ValueError( - f"chamber_temp must be temperature, got {self.chamber_temp.dimension}" - ) - if self.exit_pressure.dimension != "pressure": - raise ValueError( - f"exit_pressure must be pressure, got {self.exit_pressure.dimension}" - ) - if self.lstar.dimension != "length": - raise ValueError(f"lstar must be length, got {self.lstar.dimension}") - if self.ambient_pressure is not None and self.ambient_pressure.dimension != "pressure": - raise ValueError( - f"ambient_pressure must be pressure, got {self.ambient_pressure.dimension}" - ) - - # Validate physical constraints - if self.gamma <= 1.0: - raise ValueError(f"gamma must be > 1, got {self.gamma}") - if self.molecular_weight <= 0: - raise ValueError(f"molecular_weight must be > 0, got {self.molecular_weight}") - if self.mixture_ratio <= 0: - raise ValueError(f"mixture_ratio must be > 0, got {self.mixture_ratio}") - if self.contraction_ratio < 1.0: - raise ValueError(f"contraction_ratio must be >= 1, got {self.contraction_ratio}") - if not (0 < self.contraction_angle < 90): - raise ValueError( - f"contraction_angle must be between 0 and 90 degrees, got {self.contraction_angle}" - ) - if not (0 < self.bell_fraction <= 1.0): - raise ValueError(f"bell_fraction must be between 0 and 1, got {self.bell_fraction}") - - @property - def effective_ambient_pressure(self) -> Quantity: - """Return ambient pressure, defaulting to exit pressure if not specified.""" - if self.ambient_pressure is not None: - return self.ambient_pressure - return self.exit_pressure - - @classmethod - def from_propellants( - cls, - oxidizer: str, - fuel: str, - thrust: Quantity, - chamber_pressure: Quantity, - mixture_ratio: float | None = None, - exit_pressure: Quantity | None = None, - lstar: Quantity | None = None, - ambient_pressure: Quantity | None = None, - contraction_ratio: float = 4.0, - contraction_angle: float = 45.0, - bell_fraction: float = 0.8, - name: str | None = None, - ) -> "EngineInputs": - """Create EngineInputs from propellant names, automatically computing thermochemistry. - - This factory method uses RocketCEA (NASA CEA) to determine the combustion - properties (chamber temperature, molecular weight, and gamma) from the - specified propellant combination. - - Args: - oxidizer: Oxidizer name (e.g., "LOX", "N2O4", "N2O", "H2O2") - fuel: Fuel name (e.g., "RP1", "LH2", "CH4", "Ethanol", "MMH") - thrust: Sea-level thrust - chamber_pressure: Chamber pressure - mixture_ratio: O/F mass ratio. If None, uses optimal ratio for max Isp. - exit_pressure: Nozzle exit pressure. Defaults to 1 atm (101325 Pa). - lstar: Characteristic length. Defaults to 1.0 m (typical for biprop). - ambient_pressure: Ambient pressure for performance calc. Defaults to exit_pressure. - contraction_ratio: Chamber/throat area ratio. Default 4.0. - contraction_angle: Convergent section half-angle [deg]. Default 45. - bell_fraction: Bell length as fraction of 15° cone. Default 0.8. - name: Optional engine name. - - Returns: - EngineInputs with thermochemistry computed from propellant combination. - - Example: - >>> inputs = EngineInputs.from_propellants( - ... oxidizer="LOX", - ... fuel="RP1", - ... thrust=kilonewtons(100), - ... chamber_pressure=megapascals(7), - ... mixture_ratio=2.7, - ... ) - >>> print(f"Tc = {inputs.chamber_temp}") - """ - from openrocketengine.propellants import ( - get_combustion_properties, - get_optimal_mixture_ratio, - ) - - # Default exit pressure to 1 atm - if exit_pressure is None: - exit_pressure = pascals(101325) - - # Default L* to 1.0 m - if lstar is None: - lstar = meters(1.0) - - # Get chamber pressure in Pa for CEA - pc_pa = chamber_pressure.to("Pa").value - - # Find optimal mixture ratio if not specified - if mixture_ratio is None: - mixture_ratio, _ = get_optimal_mixture_ratio( - oxidizer=oxidizer, - fuel=fuel, - chamber_pressure_pa=pc_pa, - metric="isp", - ) - - # Get combustion properties from CEA - props = get_combustion_properties( - oxidizer=oxidizer, - fuel=fuel, - mixture_ratio=mixture_ratio, - chamber_pressure_pa=pc_pa, - ) - - # Generate name if not provided - if name is None: - name = f"{oxidizer}/{fuel} Engine" - - return cls( - thrust=thrust, - chamber_pressure=chamber_pressure, - chamber_temp=kelvin(props.chamber_temp_k), - exit_pressure=exit_pressure, - molecular_weight=props.molecular_weight, - gamma=props.gamma, - lstar=lstar, - mixture_ratio=mixture_ratio, - ambient_pressure=ambient_pressure, - contraction_ratio=contraction_ratio, - contraction_angle=contraction_angle, - bell_fraction=bell_fraction, - name=name, - ) - - -# ============================================================================= -# Output Data Structures -# ============================================================================= - - -@beartype -@dataclass(frozen=True, slots=True) -class EnginePerformance: - """Computed performance metrics for a rocket engine. - - All values are computed from EngineInputs using isentropic flow equations. - - Attributes: - isp: Specific impulse at sea level [s] - isp_vac: Specific impulse in vacuum [s] - cstar: Characteristic velocity [m/s] - exhaust_velocity: Nozzle exit velocity [m/s] - thrust_coeff: Thrust coefficient at sea level [-] - thrust_coeff_vac: Vacuum thrust coefficient [-] - mdot: Total mass flow rate [kg/s] - mdot_ox: Oxidizer mass flow rate [kg/s] - mdot_fuel: Fuel mass flow rate [kg/s] - expansion_ratio: Nozzle expansion ratio Ae/At [-] - exit_mach: Mach number at nozzle exit [-] - """ - - isp: Quantity # seconds - isp_vac: Quantity # seconds - cstar: Quantity # m/s - exhaust_velocity: Quantity # m/s - thrust_coeff: float # dimensionless - thrust_coeff_vac: float # dimensionless - mdot: Quantity # kg/s - mdot_ox: Quantity # kg/s - mdot_fuel: Quantity # kg/s - expansion_ratio: float # dimensionless - exit_mach: float # dimensionless - - -@beartype -@dataclass(frozen=True, slots=True) -class EngineGeometry: - """Computed geometry for a rocket engine. - - All dimensions are computed from EngineInputs and EnginePerformance. - - Attributes: - throat_area: Throat cross-sectional area [m^2] - throat_diameter: Throat diameter [m] - exit_area: Nozzle exit area [m^2] - exit_diameter: Nozzle exit diameter [m] - chamber_area: Chamber cross-sectional area [m^2] - chamber_diameter: Chamber diameter [m] - chamber_volume: Total chamber volume [m^3] - chamber_length: Cylindrical chamber length [m] - nozzle_length: Nozzle length (from throat to exit) [m] - expansion_ratio: Ae/At [-] - contraction_ratio: Ac/At [-] - """ - - throat_area: Quantity - throat_diameter: Quantity - exit_area: Quantity - exit_diameter: Quantity - chamber_area: Quantity - chamber_diameter: Quantity - chamber_volume: Quantity - chamber_length: Quantity - nozzle_length: Quantity - expansion_ratio: float - contraction_ratio: float - - -# ============================================================================= -# Computation Functions -# ============================================================================= - - -@beartype -def compute_performance(inputs: EngineInputs) -> EnginePerformance: - """Compute engine performance from inputs. - - This is a pure function that takes engine inputs and returns computed - performance metrics using isentropic flow equations. - - Args: - inputs: Engine input parameters - - Returns: - Computed performance metrics - """ - # Extract values in SI units - thrust_N = inputs.thrust.to("N").value - pc_Pa = inputs.chamber_pressure.to("Pa").value - Tc_K = inputs.chamber_temp.to("K").value - pe_Pa = inputs.exit_pressure.to("Pa").value - pa_Pa = inputs.effective_ambient_pressure.to("Pa").value - MW = inputs.molecular_weight - gamma = inputs.gamma - MR = inputs.mixture_ratio - - # Compute gas properties - R = specific_gas_constant(MW) - - # Pressure ratios - pe_pc = pe_Pa / pc_Pa - pa_pc = pa_Pa / pc_Pa - - # Exit Mach number from pressure ratio - exit_mach = mach_from_pressure_ratio(pc_Pa / pe_Pa, gamma) - - # Expansion ratio from exit Mach - expansion_ratio = area_ratio_from_mach(exit_mach, gamma) - - # Characteristic velocity - cstar_val = characteristic_velocity(gamma, R, Tc_K) - - # Thrust coefficients - Cf = thrust_coefficient(gamma, pe_pc, pa_pc, expansion_ratio) - Cf_vac = thrust_coefficient_vacuum(gamma, pe_pc, expansion_ratio) - - # Specific impulse - Isp_val = specific_impulse(cstar_val, Cf, G0_SI) - Isp_vac_val = specific_impulse(cstar_val, Cf_vac, G0_SI) - - # Mass flow rate - mdot_val = mass_flow_rate(thrust_N, Isp_val, G0_SI) - mdot_ox_val = mdot_val * MR / (MR + 1.0) - mdot_fuel_val = mdot_val / (MR + 1.0) - - # Exhaust velocity - ue_val = Isp_val * G0_SI - - return EnginePerformance( - isp=seconds(Isp_val), - isp_vac=seconds(Isp_vac_val), - cstar=meters_per_second(cstar_val), - exhaust_velocity=meters_per_second(ue_val), - thrust_coeff=Cf, - thrust_coeff_vac=Cf_vac, - mdot=kg_per_second(mdot_val), - mdot_ox=kg_per_second(mdot_ox_val), - mdot_fuel=kg_per_second(mdot_fuel_val), - expansion_ratio=expansion_ratio, - exit_mach=exit_mach, - ) - - -@beartype -def compute_geometry(inputs: EngineInputs, performance: EnginePerformance) -> EngineGeometry: - """Compute engine geometry from inputs and performance. - - This is a pure function that takes engine inputs and computed performance - to determine physical dimensions. - - Args: - inputs: Engine input parameters - performance: Computed performance metrics - - Returns: - Computed geometry - """ - # Extract values - pc_Pa = inputs.chamber_pressure.to("Pa").value - mdot_val = performance.mdot.to("kg/s").value - cstar_val = performance.cstar.to("m/s").value - lstar_m = inputs.lstar.to("m").value - expansion_ratio = performance.expansion_ratio - contraction_ratio = inputs.contraction_ratio - contraction_angle_rad = math.radians(inputs.contraction_angle) - bell_fraction = inputs.bell_fraction - - # Throat geometry - At = throat_area(mdot_val, cstar_val, pc_Pa) - Dt = diameter_from_area(At) - Rt = Dt / 2.0 - - # Exit geometry - Ae = At * expansion_ratio - De = diameter_from_area(Ae) - Re = De / 2.0 - - # Chamber geometry - Ac = At * contraction_ratio - Dc = diameter_from_area(Ac) - Rc = Dc / 2.0 - - # Chamber volume from L* - Vc = chamber_volume(lstar_m, At) - - # Cylindrical chamber length - Lcyl = cylindrical_chamber_length(Vc, Ac, Rc, Rt, contraction_angle_rad) - # Ensure positive length - Lcyl = max(Lcyl, 0.0) - - # Nozzle length (bell nozzle) - Ln = bell_nozzle_length(Rt, Re, bell_fraction) - - return EngineGeometry( - throat_area=square_meters(At), - throat_diameter=meters(Dt), - exit_area=square_meters(Ae), - exit_diameter=meters(De), - chamber_area=square_meters(Ac), - chamber_diameter=meters(Dc), - chamber_volume=Quantity(Vc, "m^3", "volume"), - chamber_length=meters(Lcyl), - nozzle_length=meters(Ln), - expansion_ratio=expansion_ratio, - contraction_ratio=contraction_ratio, - ) - - -@beartype -def design_engine(inputs: EngineInputs) -> tuple[EnginePerformance, EngineGeometry]: - """Complete engine design from inputs. - - Convenience function that computes both performance and geometry. - - Args: - inputs: Engine input parameters - - Returns: - Tuple of (performance, geometry) - """ - performance = compute_performance(inputs) - geometry = compute_geometry(inputs, performance) - return performance, geometry - - -# ============================================================================= -# Analysis Functions -# ============================================================================= - - -@beartype -def thrust_at_altitude( - inputs: EngineInputs, - performance: EnginePerformance, - geometry: EngineGeometry, - ambient_pressure: Quantity, -) -> Quantity: - """Calculate thrust at a given ambient pressure (altitude). - - Args: - inputs: Engine input parameters - performance: Computed performance (used for expansion ratio) - geometry: Computed geometry (used for exit area) - ambient_pressure: Ambient pressure at altitude - - Returns: - Thrust at the specified altitude - """ - pc_Pa = inputs.chamber_pressure.to("Pa").value - pe_Pa = inputs.exit_pressure.to("Pa").value - pa_Pa = ambient_pressure.to("Pa").value - gamma = inputs.gamma - cstar_val = performance.cstar.to("m/s").value - mdot_val = performance.mdot.to("kg/s").value - expansion_ratio = performance.expansion_ratio - - pe_pc = pe_Pa / pc_Pa - pa_pc = pa_Pa / pc_Pa - - Cf = thrust_coefficient(gamma, pe_pc, pa_pc, expansion_ratio) - thrust_N = mdot_val * cstar_val * Cf - - return Quantity(thrust_N, "N", "force") - - -@beartype -def isp_at_altitude( - inputs: EngineInputs, - performance: EnginePerformance, - ambient_pressure: Quantity, -) -> Quantity: - """Calculate specific impulse at a given ambient pressure (altitude). - - Args: - inputs: Engine input parameters - performance: Computed performance - ambient_pressure: Ambient pressure at altitude - - Returns: - Specific impulse at the specified altitude - """ - pc_Pa = inputs.chamber_pressure.to("Pa").value - pe_Pa = inputs.exit_pressure.to("Pa").value - pa_Pa = ambient_pressure.to("Pa").value - gamma = inputs.gamma - cstar_val = performance.cstar.to("m/s").value - expansion_ratio = performance.expansion_ratio - - pe_pc = pe_Pa / pc_Pa - pa_pc = pa_Pa / pc_Pa - - Cf = thrust_coefficient(gamma, pe_pc, pa_pc, expansion_ratio) - Isp = specific_impulse(cstar_val, Cf, G0_SI) - - return seconds(Isp) - - -# ============================================================================= -# Summary and Display -# ============================================================================= - - -@beartype -def format_performance_summary(inputs: EngineInputs, performance: EnginePerformance) -> str: - """Format a human-readable performance summary. - - Args: - inputs: Engine input parameters - performance: Computed performance - - Returns: - Formatted string summary - """ - name = inputs.name or "Unnamed Engine" - lines = [ - f"{'=' * 60}", - f"ENGINE PERFORMANCE SUMMARY: {name}", - f"{'=' * 60}", - "", - "INPUTS:", - f" Thrust (SL): {inputs.thrust}", - f" Chamber Pressure: {inputs.chamber_pressure}", - f" Chamber Temp: {inputs.chamber_temp}", - f" Exit Pressure: {inputs.exit_pressure}", - f" Molecular Weight: {inputs.molecular_weight:.2f} kg/kmol", - f" Gamma: {inputs.gamma:.3f}", - f" Mixture Ratio: {inputs.mixture_ratio:.2f}", - "", - "PERFORMANCE:", - f" Isp (SL): {performance.isp.value:.1f} s", - f" Isp (Vac): {performance.isp_vac.value:.1f} s", - f" C*: {performance.cstar.value:.1f} m/s", - f" Exit Velocity: {performance.exhaust_velocity.value:.1f} m/s", - f" Thrust Coeff (SL): {performance.thrust_coeff:.3f}", - f" Thrust Coeff (Vac): {performance.thrust_coeff_vac:.3f}", - f" Exit Mach: {performance.exit_mach:.2f}", - "", - "MASS FLOW:", - f" Total: {performance.mdot.value:.3f} kg/s", - f" Oxidizer: {performance.mdot_ox.value:.3f} kg/s", - f" Fuel: {performance.mdot_fuel.value:.3f} kg/s", - "", - f" Expansion Ratio: {performance.expansion_ratio:.2f}", - f"{'=' * 60}", - ] - return "\n".join(lines) - - -@beartype -def format_geometry_summary(inputs: EngineInputs, geometry: EngineGeometry) -> str: - """Format a human-readable geometry summary. - - Args: - inputs: Engine input parameters - geometry: Computed geometry - - Returns: - Formatted string summary - """ - name = inputs.name or "Unnamed Engine" - lines = [ - f"{'=' * 60}", - f"ENGINE GEOMETRY SUMMARY: {name}", - f"{'=' * 60}", - "", - "THROAT:", - f" Area: {geometry.throat_area.value * 1e4:.4f} cm^2", - f" Diameter: {geometry.throat_diameter.value * 100:.3f} cm", - "", - "EXIT:", - f" Area: {geometry.exit_area.value * 1e4:.2f} cm^2", - f" Diameter: {geometry.exit_diameter.value * 100:.2f} cm", - "", - "CHAMBER:", - f" Area: {geometry.chamber_area.value * 1e4:.2f} cm^2", - f" Diameter: {geometry.chamber_diameter.value * 100:.2f} cm", - f" Volume: {geometry.chamber_volume.value * 1e6:.1f} cm^3", - f" Length (cyl): {geometry.chamber_length.value * 100:.2f} cm", - "", - "NOZZLE:", - f" Length: {geometry.nozzle_length.value * 100:.2f} cm", - "", - "RATIOS:", - f" Expansion (Ae/At): {geometry.expansion_ratio:.2f}", - f" Contraction (Ac/At):{geometry.contraction_ratio:.2f}", - f"{'=' * 60}", - ] - return "\n".join(lines) - diff --git a/openrocketengine/examples/__init__.py b/openrocketengine/examples/__init__.py deleted file mode 100644 index 9e1f835..0000000 --- a/openrocketengine/examples/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Example scripts for OpenRocketEngine.""" - diff --git a/openrocketengine/examples/basic_engine.py b/openrocketengine/examples/basic_engine.py deleted file mode 100644 index 709c121..0000000 --- a/openrocketengine/examples/basic_engine.py +++ /dev/null @@ -1,249 +0,0 @@ -#!/usr/bin/env python -"""Basic engine design example for OpenRocketEngine. - -This example demonstrates the complete workflow for designing a small -liquid rocket engine: - -1. Define engine inputs (thrust, pressures, temperatures, etc.) -2. Compute performance metrics (Isp, c*, Cf, mass flow rates) -3. Compute geometry (throat, chamber, exit dimensions) -4. Generate nozzle contour -5. Visualize the design -6. Export contour for CAD - -All outputs are organized into a timestamped directory structure. -""" - -from openrocketengine import OutputContext -from openrocketengine.engine import ( - EngineInputs, - compute_geometry, - compute_performance, - format_geometry_summary, - format_performance_summary, -) -from openrocketengine.nozzle import ( - full_chamber_contour, - generate_nozzle_from_geometry, -) -from openrocketengine.plotting import ( - plot_engine_cross_section, - plot_engine_dashboard, - plot_nozzle_contour, - plot_performance_vs_altitude, -) -from openrocketengine.units import kelvin, megapascals, meters, newtons, pascals - - -def main() -> None: - """Run the basic engine design example.""" - print("=" * 70) - print("OpenRocketEngine - Basic Engine Design Example") - print("=" * 70) - print() - - # ========================================================================= - # Step 1: Define Engine Inputs - # ========================================================================= - # - # We're designing a small pressure-fed engine with the following specs: - # - ~5 kN (1100 lbf) sea-level thrust - # - LOX/Ethanol propellants (assumed combustion properties) - # - Pressure-fed, so moderate chamber pressure - # - # The thermochemical properties (Tc, gamma, MW) would normally come from - # NASA CEA or similar tool. Here we use representative values for - # LOX/Ethanol at O/F = 1.3 - - print("Step 1: Defining engine inputs...") - print() - - inputs = EngineInputs( - name="Student Engine Mk1", - # Performance targets - thrust=newtons(5000), # 5 kN sea-level thrust - # Chamber conditions - chamber_pressure=megapascals(2.0), # 2 MPa (~290 psi) - chamber_temp=kelvin(3200), # Flame temperature from CEA - exit_pressure=pascals(101325), # Expanded to sea level - # Propellant properties (from CEA for LOX/Ethanol) - molecular_weight=21.5, # kg/kmol - gamma=1.22, # Cp/Cv - mixture_ratio=1.3, # O/F mass ratio - # Chamber geometry parameters - lstar=meters(1.0), # Characteristic length (typical for biprop) - contraction_ratio=4.0, # Ac/At - contraction_angle=45.0, # degrees - bell_fraction=0.8, # 80% bell nozzle - ) - - print(f" Engine Name: {inputs.name}") - print(f" Design Thrust: {inputs.thrust}") - print(f" Chamber Pressure: {inputs.chamber_pressure}") - print(f" Chamber Temp: {inputs.chamber_temp}") - print(f" Exit Pressure: {inputs.exit_pressure}") - print(f" Molecular Weight: {inputs.molecular_weight} kg/kmol") - print(f" Gamma: {inputs.gamma}") - print(f" Mixture Ratio: {inputs.mixture_ratio}") - print() - - # ========================================================================= - # Step 2: Compute Performance - # ========================================================================= - - print("Step 2: Computing performance...") - print() - - performance = compute_performance(inputs) - - print(f" Specific Impulse (SL): {performance.isp.value:.1f} s") - print(f" Specific Impulse (Vac): {performance.isp_vac.value:.1f} s") - print(f" Characteristic Velocity: {performance.cstar.value:.0f} m/s") - print(f" Thrust Coefficient (SL): {performance.thrust_coeff:.3f}") - print(f" Exit Mach Number: {performance.exit_mach:.2f}") - print(f" Expansion Ratio: {performance.expansion_ratio:.1f}") - print() - print(f" Total Mass Flow: {performance.mdot.value:.3f} kg/s") - print(f" Oxidizer Flow: {performance.mdot_ox.value:.3f} kg/s") - print(f" Fuel Flow: {performance.mdot_fuel.value:.3f} kg/s") - print() - - # ========================================================================= - # Step 3: Compute Geometry - # ========================================================================= - - print("Step 3: Computing geometry...") - print() - - geometry = compute_geometry(inputs, performance) - - # Convert to more convenient units for display - Dt_mm = geometry.throat_diameter.to("m").value * 1000 - De_mm = geometry.exit_diameter.to("m").value * 1000 - Dc_mm = geometry.chamber_diameter.to("m").value * 1000 - Lc_mm = geometry.chamber_length.to("m").value * 1000 - Ln_mm = geometry.nozzle_length.to("m").value * 1000 - - print(f" Throat Diameter: {Dt_mm:.1f} mm") - print(f" Exit Diameter: {De_mm:.1f} mm") - print(f" Chamber Diameter: {Dc_mm:.1f} mm") - print(f" Chamber Length: {Lc_mm:.1f} mm") - print(f" Nozzle Length: {Ln_mm:.1f} mm") - print(f" Expansion Ratio: {geometry.expansion_ratio:.1f}") - print(f" Contraction Ratio: {geometry.contraction_ratio:.1f}") - print() - - # ========================================================================= - # Step 4: Generate Nozzle Contour - # ========================================================================= - - print("Step 4: Generating nozzle contour...") - print() - - # Generate just the divergent nozzle section - nozzle_contour = generate_nozzle_from_geometry( - geometry, bell_fraction=inputs.bell_fraction, num_points=100 - ) - - print(f" Contour Type: {nozzle_contour.contour_type}") - print(f" Number of Points: {len(nozzle_contour.x)}") - print(f" Nozzle Length: {nozzle_contour.length * 1000:.1f} mm") - print() - - # Generate full chamber contour (chamber + convergent + divergent) - full_contour = full_chamber_contour( - inputs, geometry, nozzle_contour, num_chamber_points=50, num_convergent_points=30 - ) - - print(f" Full Contour Type: {full_contour.contour_type}") - print(f" Total Points: {len(full_contour.x)}") - print(f" Total Length: {full_contour.length * 1000:.1f} mm") - print() - - # ========================================================================= - # Step 5-7: Save All Outputs to Organized Directory - # ========================================================================= - - print("Step 5-7: Saving outputs...") - print() - - # Use OutputContext to organize all outputs - with OutputContext("student_engine_mk1", include_timestamp=True) as ctx: - # Add metadata about this run - ctx.add_metadata("engine_name", inputs.name) - ctx.add_metadata("thrust_N", inputs.thrust.to("N").value) - ctx.add_metadata("chamber_pressure_MPa", inputs.chamber_pressure.to("MPa").value) - - # Export contours to CSV (automatically goes to data/) - ctx.log("Exporting nozzle contours...") - nozzle_contour.to_csv(ctx.path("nozzle_contour.csv")) - full_contour.to_csv(ctx.path("full_chamber_contour.csv")) - - # Save text summaries (automatically goes to reports/) - ctx.log("Saving performance summaries...") - ctx.save_text(format_performance_summary(inputs, performance), "performance_summary.txt") - ctx.save_text(format_geometry_summary(inputs, geometry), "geometry_summary.txt") - - # Save design summary as JSON - ctx.save_summary({ - "engine_name": inputs.name, - "performance": { - "isp_sl_s": performance.isp.value, - "isp_vac_s": performance.isp_vac.value, - "cstar_m_s": performance.cstar.value, - "thrust_coeff_sl": performance.thrust_coeff, - "thrust_coeff_vac": performance.thrust_coeff_vac, - "exit_mach": performance.exit_mach, - "mdot_kg_s": performance.mdot.value, - "mdot_ox_kg_s": performance.mdot_ox.value, - "mdot_fuel_kg_s": performance.mdot_fuel.value, - }, - "geometry": { - "throat_diameter_mm": Dt_mm, - "exit_diameter_mm": De_mm, - "chamber_diameter_mm": Dc_mm, - "chamber_length_mm": Lc_mm, - "nozzle_length_mm": Ln_mm, - "expansion_ratio": geometry.expansion_ratio, - "contraction_ratio": geometry.contraction_ratio, - }, - "inputs": { - "thrust_N": inputs.thrust.to("N").value, - "chamber_pressure_Pa": inputs.chamber_pressure.to("Pa").value, - "chamber_temp_K": inputs.chamber_temp.to("K").value, - "molecular_weight": inputs.molecular_weight, - "gamma": inputs.gamma, - "mixture_ratio": inputs.mixture_ratio, - }, - }) - - # Create visualizations (automatically goes to plots/) - ctx.log("Generating visualizations...") - - fig1 = plot_engine_cross_section( - geometry, full_contour, inputs, show_dimensions=True, - title=f"{inputs.name} Cross-Section" - ) - fig1.savefig(ctx.path("engine_cross_section.png"), dpi=150, bbox_inches="tight") - - fig2 = plot_nozzle_contour(nozzle_contour, title=f"{inputs.name} Nozzle Contour") - fig2.savefig(ctx.path("nozzle_contour.png"), dpi=150, bbox_inches="tight") - - fig3 = plot_performance_vs_altitude(inputs, performance, geometry, max_altitude_km=80) - fig3.savefig(ctx.path("altitude_performance.png"), dpi=150, bbox_inches="tight") - - fig4 = plot_engine_dashboard(inputs, performance, geometry, full_contour) - fig4.savefig(ctx.path("engine_dashboard.png"), dpi=150, bbox_inches="tight") - - ctx.log("All outputs saved!") - print() - print(f" Output directory: {ctx.output_dir}") - - print() - print("=" * 70) - print("Design complete!") - print("=" * 70) - - -if __name__ == "__main__": - main() diff --git a/openrocketengine/examples/cycle_comparison.py b/openrocketengine/examples/cycle_comparison.py deleted file mode 100644 index 3894a19..0000000 --- a/openrocketengine/examples/cycle_comparison.py +++ /dev/null @@ -1,269 +0,0 @@ -#!/usr/bin/env python -"""Engine cycle comparison example for Rocket. - -This example demonstrates how to compare different engine cycle architectures -for a given set of requirements: - -1. Pressure-fed (simplest, lowest performance) -2. Gas generator (most common, good balance) -3. Staged combustion (highest performance, most complex) - -Understanding cycle tradeoffs is critical for: -- Selecting the right architecture for your mission -- Understanding performance vs. complexity tradeoffs -- Estimating system-level impacts (tank pressure, turbomachinery) -""" - -from openrocketengine import ( - EngineInputs, - OutputContext, - design_engine, -) -from openrocketengine.cycles import ( - GasGeneratorCycle, - PressureFedCycle, - StagedCombustionCycle, -) -from openrocketengine.units import kelvin, kilonewtons, megapascals - - -def print_header(text: str) -> None: - """Print a formatted section header.""" - print() - print("┌" + "─" * 68 + "┐") - print(f"│ {text:<66} │") - print("└" + "─" * 68 + "┘") - - -def main() -> None: - """Run the engine cycle comparison example.""" - print() - print("╔" + "═" * 68 + "╗") - print("║" + " " * 16 + "ENGINE CYCLE COMPARISON STUDY" + " " * 23 + "║") - print("║" + " " * 18 + "LOX/CH4 Methalox Engine" + " " * 27 + "║") - print("╚" + "═" * 68 + "╝") - - # ========================================================================= - # Common Engine Requirements - # ========================================================================= - - print_header("ENGINE REQUIREMENTS") - - # Base engine thermochemistry (same for all cycles) - base_inputs = EngineInputs.from_propellants( - oxidizer="LOX", - fuel="CH4", - thrust=kilonewtons(500), - chamber_pressure=megapascals(15), - mixture_ratio=3.2, - name="Methalox-500", - ) - - print(f" Thrust target: {base_inputs.thrust.to('kN').value:.0f} kN") - print(f" Chamber pressure: {base_inputs.chamber_pressure.to('MPa').value:.0f} MPa") - print(f" Propellants: LOX / CH4") - print(f" Mixture ratio: {base_inputs.mixture_ratio}") - print() - - # Get baseline performance and geometry - performance, geometry = design_engine(base_inputs) - - print(f" Ideal Isp (vac): {performance.isp_vac.value:.1f} s") - print(f" Ideal c*: {performance.cstar.to('m/s').value:.0f} m/s") - print(f" Mass flow: {performance.mdot.to('kg/s').value:.1f} kg/s") - - # Store results for comparison - results: list[dict] = [] - - # ========================================================================= - # Cycle 1: Pressure-Fed - # ========================================================================= - - print_header("CYCLE 1: PRESSURE-FED") - - print(" Architecture:") - print(" - Propellants pushed by tank pressure (no pumps)") - print(" - Requires high tank pressure → heavy tanks") - print(" - Simplest system, highest reliability") - print() - - pressure_fed = PressureFedCycle( - tank_pressure_margin=1.3, - line_loss_fraction=0.05, - injector_dp_fraction=0.15, - ) - - pf_result = pressure_fed.analyze(base_inputs, performance, geometry) - - print(" Results:") - print(f" Tank pressure (ox): {pf_result.tank_pressure_ox.to('MPa').value:.1f} MPa") - print(f" Tank pressure (fuel): {pf_result.tank_pressure_fuel.to('MPa').value:.1f} MPa") - print(f" Net Isp: {pf_result.net_isp.to('s').value:.1f} s") - print(f" Net thrust: {pf_result.net_thrust.to('kN').value:.0f} kN") - print(f" Cycle efficiency: {pf_result.cycle_efficiency*100:.1f}%") - if pf_result.warnings: - print(f" Warnings: {len(pf_result.warnings)}") - for w in pf_result.warnings: - print(f" ⚠ {w}") - - results.append({ - "cycle": "Pressure-Fed", - "net_isp": pf_result.net_isp.to("s").value, - "tank_pressure_MPa": pf_result.tank_pressure_ox.to("MPa").value, - "pump_power_kW": 0, - "efficiency": pf_result.cycle_efficiency, - }) - - # ========================================================================= - # Cycle 2: Gas Generator - # ========================================================================= - - print_header("CYCLE 2: GAS GENERATOR") - - print(" Architecture:") - print(" - Small combustor (GG) drives turbine") - print(" - Turbine exhaust dumped overboard (Isp loss)") - print(" - Moderate complexity, proven technology") - print() - - gas_generator = GasGeneratorCycle( - turbine_inlet_temp=kelvin(900), - pump_efficiency_ox=0.70, - pump_efficiency_fuel=0.70, - turbine_efficiency=0.65, - gg_mixture_ratio=0.4, - ) - - gg_result = gas_generator.analyze(base_inputs, performance, geometry) - - total_pump_power_gg = ( - gg_result.pump_power_ox.to("kW").value + - gg_result.pump_power_fuel.to("kW").value - ) - - print(" Results:") - print(f" Turbine mass flow: {gg_result.turbine_mass_flow.to('kg/s').value:.1f} kg/s") - print(f" Net Isp: {gg_result.net_isp.to('s').value:.1f} s") - print(f" Net thrust: {gg_result.net_thrust.to('kN').value:.0f} kN") - print(f" Cycle efficiency: {gg_result.cycle_efficiency*100:.1f}%") - print(f" Isp loss vs ideal: {performance.isp_vac.value - gg_result.net_isp.to('s').value:.1f} s") - if gg_result.warnings: - print(f" Warnings: {len(gg_result.warnings)}") - for w in gg_result.warnings: - print(f" ⚠ {w}") - - results.append({ - "cycle": "Gas Generator", - "net_isp": gg_result.net_isp.to("s").value, - "tank_pressure_MPa": gg_result.tank_pressure_ox.to("MPa").value if gg_result.tank_pressure_ox else 0.5, - "pump_power_kW": total_pump_power_gg, - "efficiency": gg_result.cycle_efficiency, - }) - - # ========================================================================= - # Cycle 3: Staged Combustion (Oxidizer-Rich) - # ========================================================================= - - print_header("CYCLE 3: STAGED COMBUSTION (OX-RICH)") - - print(" Architecture:") - print(" - Preburner runs oxygen-rich") - print(" - ALL flow goes through main chamber (no dump)") - print(" - Highest performance, most complex") - print() - - staged_combustion = StagedCombustionCycle( - preburner_temp=kelvin(750), - pump_efficiency_ox=0.75, - pump_efficiency_fuel=0.75, - turbine_efficiency=0.70, - preburner_mixture_ratio=50.0, - oxidizer_rich=True, - ) - - sc_result = staged_combustion.analyze(base_inputs, performance, geometry) - - total_pump_power_sc = ( - sc_result.pump_power_ox.to("kW").value + - sc_result.pump_power_fuel.to("kW").value - ) - - print(" Results:") - print(f" Turbine mass flow: {sc_result.turbine_mass_flow.to('kg/s').value:.1f} kg/s") - print(f" Net Isp: {sc_result.net_isp.to('s').value:.1f} s") - print(f" Net thrust: {sc_result.net_thrust.to('kN').value:.0f} kN") - print(f" Cycle efficiency: {sc_result.cycle_efficiency*100:.1f}%") - if sc_result.warnings: - print(f" Warnings: {len(sc_result.warnings)}") - for w in sc_result.warnings: - print(f" ⚠ {w}") - - results.append({ - "cycle": "Staged Combustion", - "net_isp": sc_result.net_isp.to("s").value, - "tank_pressure_MPa": sc_result.tank_pressure_ox.to("MPa").value if sc_result.tank_pressure_ox else 0.5, - "pump_power_kW": total_pump_power_sc, - "efficiency": sc_result.cycle_efficiency, - }) - - # ========================================================================= - # Comparison Summary - # ========================================================================= - - print_header("COMPARISON SUMMARY") - - print() - print(f" {'Cycle':<20} {'Net Isp':<12} {'Efficiency':<12} {'Tank P (MPa)':<12}") - print(" " + "-" * 56) - - for r in results: - isp_str = f"{r['net_isp']:.1f} s" - eff_str = f"{r['efficiency']*100:.1f}%" - tank_str = f"{r['tank_pressure_MPa']:.1f}" - print(f" {r['cycle']:<20} {isp_str:<12} {eff_str:<12} {tank_str:<12}") - - # Compute comparison from actual results - if len(results) >= 3: - isp_gain = results[2]["net_isp"] - results[1]["net_isp"] - isp_gain_pct = 100 * isp_gain / results[1]["net_isp"] if results[1]["net_isp"] > 0 else 0 - - print() - print(f" Staged combustion vs Gas Generator:") - print(f" Isp gain: {isp_gain:.1f} s ({isp_gain_pct:.1f}%)") - - print() - - # ========================================================================= - # Save Results - # ========================================================================= - - print_header("SAVING RESULTS") - - with OutputContext("cycle_comparison", include_timestamp=True) as ctx: - ctx.save_summary({ - "requirements": { - "thrust_kN": base_inputs.thrust.to("kN").value, - "chamber_pressure_MPa": base_inputs.chamber_pressure.to("MPa").value, - "propellants": "LOX/CH4", - "mixture_ratio": base_inputs.mixture_ratio, - }, - "ideal_performance": { - "isp_vac_s": performance.isp_vac.value, - "cstar_m_s": performance.cstar.value, - "mdot_kg_s": performance.mdot.value, - }, - "cycles": results, - }) - - print() - print(f" Results saved to: {ctx.output_dir}") - - print() - print("╔" + "═" * 68 + "╗") - print("║" + " " * 24 + "ANALYSIS COMPLETE" + " " * 27 + "║") - print("╚" + "═" * 68 + "╝") - print() - - -if __name__ == "__main__": - main() diff --git a/openrocketengine/examples/optimization.py b/openrocketengine/examples/optimization.py deleted file mode 100644 index 8219d64..0000000 --- a/openrocketengine/examples/optimization.py +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env python -"""Multi-objective optimization example for Rocket. - -This example demonstrates how to find Pareto-optimal engine designs -that balance competing objectives: - -1. Maximize Isp (specific impulse) -2. Minimize engine mass (via throat size as proxy) -3. Satisfy thermal constraints - -Real engine design involves tradeoffs - you can't maximize everything. -Pareto fronts show the best achievable combinations. -""" - -from openrocketengine import ( - EngineInputs, - MultiObjectiveOptimizer, - OutputContext, - Range, - design_engine, -) -from openrocketengine.units import kilonewtons, megapascals - - -def main() -> None: - """Run the multi-objective optimization example.""" - print("=" * 70) - print("Rocket - Multi-Objective Optimization Example") - print("=" * 70) - print() - - # ========================================================================= - # Define the Optimization Problem - # ========================================================================= - - print("Problem: Design a LOX/CH4 engine balancing Isp vs. compactness") - print("-" * 70) - print() - print("Objectives:") - print(" 1. Maximize vacuum Isp (performance)") - print(" 2. Minimize throat diameter (smaller = lighter, cheaper)") - print() - print("Design variables:") - print(" - Chamber pressure: 5-25 MPa") - print(" - Mixture ratio: 2.5-4.0") - print() - print("Constraints:") - print(" - Expansion ratio < 80 (practical nozzle size)") - print(" - Throat diameter > 3 cm (manufacturability)") - print() - - # Baseline design - baseline = EngineInputs.from_propellants( - oxidizer="LOX", - fuel="CH4", - thrust=kilonewtons(100), - chamber_pressure=megapascals(10), - mixture_ratio=3.2, - name="Methalox-Baseline", - ) - - # Define constraints - def expansion_constraint(result: tuple) -> bool: - """Expansion ratio must be < 80.""" - _, geometry = result - return geometry.expansion_ratio < 80 - - def throat_constraint(result: tuple) -> bool: - """Throat diameter must be > 3 cm for manufacturability.""" - _, geometry = result - return geometry.throat_diameter.to("m").value * 100 > 3.0 - - # ========================================================================= - # Run Multi-Objective Optimization - # ========================================================================= - - print("Running optimization...") - print() - - optimizer = MultiObjectiveOptimizer( - compute=design_engine, - base=baseline, - objectives=["isp_vac", "throat_diameter"], - maximize=[True, False], # Max Isp, Min throat (smaller = better) - vary={ - "chamber_pressure": Range(5, 25, n=15, unit="MPa"), - "mixture_ratio": Range(2.5, 4.0, n=10), - }, - constraints=[expansion_constraint, throat_constraint], - ) - - pareto_results = optimizer.run(progress=True) - - print() - print(f"Total designs evaluated: {pareto_results.n_total}") - print(f"Feasible designs: {pareto_results.n_feasible}") - print(f"Pareto-optimal designs: {pareto_results.n_pareto}") - print() - - # ========================================================================= - # Analyze Pareto Front - # ========================================================================= - - print("=" * 70) - print("PARETO-OPTIMAL DESIGNS") - print("=" * 70) - print() - print("These designs represent the best tradeoffs between Isp and size:") - print() - print(f" {'#':<4} {'Pc (MPa)':<10} {'MR':<8} {'Isp (vac)':<12} {'Throat (cm)':<12} {'ε':<8}") - print(" " + "-" * 54) - - pareto_df = pareto_results.pareto_front() - - for i, row in enumerate(pareto_df.iter_rows(named=True)): - pc_mpa = row["chamber_pressure"] / 1e6 - dt_cm = row["throat_diameter"] * 100 - print(f" {i+1:<4} {pc_mpa:<10.0f} {row['mixture_ratio']:<8.1f} {row['isp_vac']:<12.1f} {dt_cm:<12.2f} {row['expansion_ratio']:<8.1f}") - - print() - - # ========================================================================= - # Interpret Results - # ========================================================================= - - print("=" * 70) - print("DESIGN RECOMMENDATIONS") - print("=" * 70) - print() - - # Best Isp design - best_isp_idx = pareto_df["isp_vac"].arg_max() - best_isp_row = pareto_df.row(best_isp_idx, named=True) - - print("For MAXIMUM PERFORMANCE (highest Isp):") - print(f" Chamber pressure: {best_isp_row['chamber_pressure']/1e6:.0f} MPa") - print(f" Mixture ratio: {best_isp_row['mixture_ratio']:.1f}") - print(f" Isp (vac): {best_isp_row['isp_vac']:.1f} s") - print(f" Throat diameter: {best_isp_row['throat_diameter']*100:.2f} cm") - print() - - # Smallest design - best_size_idx = pareto_df["throat_diameter"].arg_min() - best_size_row = pareto_df.row(best_size_idx, named=True) - - print("For MINIMUM SIZE (smallest throat):") - print(f" Chamber pressure: {best_size_row['chamber_pressure']/1e6:.0f} MPa") - print(f" Mixture ratio: {best_size_row['mixture_ratio']:.1f}") - print(f" Isp (vac): {best_size_row['isp_vac']:.1f} s") - print(f" Throat diameter: {best_size_row['throat_diameter']*100:.2f} cm") - print() - - # Balanced design (middle of Pareto front) - mid_idx = len(pareto_df) // 2 - mid_row = pareto_df.row(mid_idx, named=True) - - print("For BALANCED DESIGN (middle of Pareto front):") - print(f" Chamber pressure: {mid_row['chamber_pressure']/1e6:.0f} MPa") - print(f" Mixture ratio: {mid_row['mixture_ratio']:.1f}") - print(f" Isp (vac): {mid_row['isp_vac']:.1f} s") - print(f" Throat diameter: {mid_row['throat_diameter']*100:.2f} cm") - print() - - # ========================================================================= - # Tradeoff Analysis - # ========================================================================= - - print("=" * 70) - print("TRADEOFF ANALYSIS") - print("=" * 70) - print() - - isp_range = pareto_df["isp_vac"].max() - pareto_df["isp_vac"].min() - dt_range = (pareto_df["throat_diameter"].max() - pareto_df["throat_diameter"].min()) * 100 - - print(f" Isp range on Pareto front: {pareto_df['isp_vac'].min():.1f} - {pareto_df['isp_vac'].max():.1f} s (Δ = {isp_range:.1f} s)") - print(f" Throat range on Pareto front: {pareto_df['throat_diameter'].min()*100:.2f} - {pareto_df['throat_diameter'].max()*100:.2f} cm (Δ = {dt_range:.2f} cm)") - print() - print(" Interpretation:") - print(f" - You can gain up to {isp_range:.1f} s of Isp...") - print(f" - ...at the cost of {dt_range:.1f} cm larger throat") - print(f" - Marginal tradeoff: {isp_range/dt_range:.1f} s of Isp per cm of throat") - print() - - # ========================================================================= - # Save Results - # ========================================================================= - - print("=" * 70) - print("Saving Results") - print("=" * 70) - print() - - with OutputContext("optimization_results", include_timestamp=True) as ctx: - # Export all results - ctx.log("Exporting full design space...") - pareto_results.all_results.to_csv(ctx.path("all_designs.csv")) - - ctx.log("Exporting Pareto front...") - pareto_df.write_csv(ctx.path("pareto_front.csv")) - - # Save summary - ctx.save_summary({ - "problem": { - "objectives": ["isp_vac (maximize)", "throat_diameter (minimize)"], - "design_variables": { - "chamber_pressure_MPa": [5, 25], - "mixture_ratio": [2.5, 4.0], - }, - "constraints": [ - "expansion_ratio < 80", - "throat_diameter > 3 cm", - ], - }, - "results": { - "total_designs": pareto_results.n_total, - "feasible_designs": pareto_results.n_feasible, - "pareto_optimal": pareto_results.n_pareto, - }, - "recommendations": { - "max_performance": { - "chamber_pressure_MPa": best_isp_row["chamber_pressure"] / 1e6, - "mixture_ratio": best_isp_row["mixture_ratio"], - "isp_vac_s": best_isp_row["isp_vac"], - }, - "min_size": { - "chamber_pressure_MPa": best_size_row["chamber_pressure"] / 1e6, - "mixture_ratio": best_size_row["mixture_ratio"], - "throat_diameter_cm": best_size_row["throat_diameter"] * 100, - }, - "balanced": { - "chamber_pressure_MPa": mid_row["chamber_pressure"] / 1e6, - "mixture_ratio": mid_row["mixture_ratio"], - "isp_vac_s": mid_row["isp_vac"], - "throat_diameter_cm": mid_row["throat_diameter"] * 100, - }, - }, - }) - - print() - print(f" All results saved to: {ctx.output_dir}") - - print() - print("=" * 70) - print("Optimization Complete!") - print("=" * 70) - print() - - -if __name__ == "__main__": - main() - diff --git a/openrocketengine/examples/propellant_design.py b/openrocketengine/examples/propellant_design.py deleted file mode 100644 index d04edcc..0000000 --- a/openrocketengine/examples/propellant_design.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python -"""Propellant-based engine design example for Rocket. - -This example demonstrates the simplified workflow where you specify -propellants and the library automatically determines combustion properties. - -No need to manually look up Tc, gamma, or molecular weight! -""" - -from openrocketengine import OutputContext, design_engine, plot_engine_dashboard -from openrocketengine.engine import EngineInputs -from openrocketengine.nozzle import full_chamber_contour, generate_nozzle_from_geometry -from openrocketengine.units import kilonewtons, megapascals - - -def main() -> None: - """Run the propellant-based design example.""" - print("=" * 70) - print("Rocket - Propellant-Based Design") - print("=" * 70) - print() - print("Using NASA CEA (via RocketCEA) for thermochemistry calculations") - print() - - # Store all engine designs for comparison - designs: list[tuple[str, EngineInputs, any, any]] = [] - - # ========================================================================= - # Design a LOX/RP-1 Engine (like Merlin) - # ========================================================================= - - print("-" * 70) - print("Design 1: LOX/RP-1 Engine (Kerolox)") - print("-" * 70) - print() - - # Just specify propellants, thrust, and pressure - that's it! - lox_rp1 = EngineInputs.from_propellants( - oxidizer="LOX", - fuel="RP1", - thrust=kilonewtons(100), # 100 kN - chamber_pressure=megapascals(7), # 7 MPa (~1000 psi) - mixture_ratio=2.7, # Typical for LOX/RP-1 - name="Kerolox-100", - ) - - print(f"Engine: {lox_rp1.name}") - print(" Propellants: LOX / RP-1") - print(f" Mixture Ratio: {lox_rp1.mixture_ratio}") - print(f" Chamber Temp: {lox_rp1.chamber_temp.to('K').value:.0f} K (auto-calculated!)") - print(f" Gamma: {lox_rp1.gamma:.3f} (auto-calculated!)") - print(f" Molecular Weight: {lox_rp1.molecular_weight:.1f} kg/kmol (auto-calculated!)") - print() - - perf1, geom1 = design_engine(lox_rp1) - print("Performance:") - print(f" Isp (SL): {perf1.isp.value:.1f} s") - print(f" Isp (Vac): {perf1.isp_vac.value:.1f} s") - print(f" Thrust Coeff: {perf1.thrust_coeff:.3f}") - print(f" Mass Flow: {perf1.mdot.value:.2f} kg/s") - print() - print("Geometry:") - print(f" Throat Diameter: {geom1.throat_diameter.to('m').value * 100:.1f} cm") - print(f" Exit Diameter: {geom1.exit_diameter.to('m').value * 100:.1f} cm") - print(f" Expansion Ratio: {geom1.expansion_ratio:.1f}") - print() - - designs.append(("LOX/RP-1", lox_rp1, perf1, geom1)) - - # ========================================================================= - # Design a LOX/Methane Engine (like Raptor) - # ========================================================================= - - print("-" * 70) - print("Design 2: LOX/Methane Engine (Methalox)") - print("-" * 70) - print() - - lox_ch4 = EngineInputs.from_propellants( - oxidizer="LOX", - fuel="CH4", - thrust=kilonewtons(200), - chamber_pressure=megapascals(10), # Higher pressure - mixture_ratio=3.2, - name="Methalox-200", - ) - - print(f"Engine: {lox_ch4.name}") - print(f" Chamber Temp: {lox_ch4.chamber_temp.to('K').value:.0f} K") - print(f" Gamma: {lox_ch4.gamma:.3f}") - print() - - perf2, geom2 = design_engine(lox_ch4) - print("Performance:") - print(f" Isp (SL): {perf2.isp.value:.1f} s") - print(f" Isp (Vac): {perf2.isp_vac.value:.1f} s") - print() - - designs.append(("LOX/CH4", lox_ch4, perf2, geom2)) - - # ========================================================================= - # Design a LOX/LH2 Engine (like RS-25/SSME) - # ========================================================================= - - print("-" * 70) - print("Design 3: LOX/LH2 Engine (Hydrolox)") - print("-" * 70) - print() - - lox_lh2 = EngineInputs.from_propellants( - oxidizer="LOX", - fuel="LH2", - thrust=kilonewtons(50), # Smaller for demo - chamber_pressure=megapascals(15), # High pressure like SSME - mixture_ratio=6.0, # Typical for LOX/LH2 - name="Hydrolox-50", - ) - - print(f"Engine: {lox_lh2.name}") - print(f" Chamber Temp: {lox_lh2.chamber_temp.to('K').value:.0f} K") - print(f" Molecular Weight: {lox_lh2.molecular_weight:.1f} kg/kmol (low = high Isp!)") - print() - - perf3, geom3 = design_engine(lox_lh2) - print("Performance:") - print(f" Isp (SL): {perf3.isp.value:.1f} s") - print(f" Isp (Vac): {perf3.isp_vac.value:.1f} s <- Highest!") - print() - - designs.append(("LOX/LH2", lox_lh2, perf3, geom3)) - - # ========================================================================= - # Comparison Summary - # ========================================================================= - - print("=" * 70) - print("COMPARISON SUMMARY") - print("=" * 70) - print() - print(f"{'Engine':<20} {'Isp(SL)':<10} {'Isp(Vac)':<10} {'MW':<8} {'Tc (K)':<10}") - print("-" * 70) - - for name, inputs, perf, _ in designs: - print( - f"{name:<20} " - f"{perf.isp.value:<10.1f} " - f"{perf.isp_vac.value:<10.1f} " - f"{inputs.molecular_weight:<8.1f} " - f"{inputs.chamber_temp.to('K').value:<10.0f}" - ) - - print() - - # ========================================================================= - # Generate Dashboards for All Engines - # ========================================================================= - - print("=" * 70) - print("GENERATING VISUALIZATIONS") - print("=" * 70) - print() - - with OutputContext("propellant_comparison", include_timestamp=True) as ctx: - # Save comparison summary - ctx.save_summary({ - "designs": [ - { - "name": name, - "propellants": f"{inputs.name}", - "isp_sl": perf.isp.value, - "isp_vac": perf.isp_vac.value, - "molecular_weight": inputs.molecular_weight, - "chamber_temp_K": inputs.chamber_temp.to("K").value, - "gamma": inputs.gamma, - "thrust_kN": inputs.thrust.to("kN").value, - "chamber_pressure_MPa": inputs.chamber_pressure.to("MPa").value, - } - for name, inputs, perf, _ in designs - ] - }, "comparison_summary.json") - - for name, inputs, perf, geom in designs: - ctx.log(f"Generating dashboard for {inputs.name}...") - nozzle = generate_nozzle_from_geometry(geom) - contour = full_chamber_contour(inputs, geom, nozzle) - fig = plot_engine_dashboard(inputs, perf, geom, contour) - - safe_name = inputs.name.lower().replace("-", "_").replace(" ", "_") - fig.savefig(ctx.path(f"{safe_name}_dashboard.png"), dpi=150, bbox_inches="tight") - - print() - print(f" All outputs saved to: {ctx.output_dir}") - - print() - print("=" * 70) - print("Done!") - print("=" * 70) - - -if __name__ == "__main__": - main() diff --git a/openrocketengine/examples/thermal_analysis.py b/openrocketengine/examples/thermal_analysis.py deleted file mode 100644 index aa89c5a..0000000 --- a/openrocketengine/examples/thermal_analysis.py +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env python -"""Thermal analysis example for Rocket. - -This example demonstrates thermal screening to determine if an engine -design can be regeneratively cooled: - -1. Estimate heat flux using the Bartz correlation -2. Check cooling feasibility for different coolants -3. Understand the relationship between chamber pressure and cooling - -High chamber pressure engines (like Raptor) face severe thermal challenges. -This analysis helps catch infeasible designs early. -""" - -from openrocketengine import ( - EngineInputs, - OutputContext, - design_engine, -) -from openrocketengine.thermal import ( - check_cooling_feasibility, - estimate_heat_flux, - heat_flux_profile, -) -from openrocketengine.units import kelvin, kilonewtons, megapascals - - -def print_header(text: str) -> None: - """Print a formatted section header.""" - print() - print("┌" + "─" * 68 + "┐") - print(f"│ {text:<66} │") - print("└" + "─" * 68 + "┘") - - -def main() -> None: - """Run the thermal analysis example.""" - print() - print("╔" + "═" * 68 + "╗") - print("║" + " " * 18 + "THERMAL FEASIBILITY ANALYSIS" + " " * 22 + "║") - print("║" + " " * 15 + "Can This Engine Be Regeneratively Cooled?" + " " * 12 + "║") - print("╚" + "═" * 68 + "╝") - - # ========================================================================= - # Design a High-Performance Engine - # ========================================================================= - - print_header("ENGINE DESIGN") - - # High chamber pressure like Raptor - inputs = EngineInputs.from_propellants( - oxidizer="LOX", - fuel="CH4", - thrust=kilonewtons(500), - chamber_pressure=megapascals(25), # High pressure! - mixture_ratio=3.2, - name="High-Pc-Methalox", - ) - - performance, geometry = design_engine(inputs) - - print(f" Engine: {inputs.name}") - print(f" Thrust: {inputs.thrust.to('kN').value:.0f} kN") - print(f" Chamber pressure: {inputs.chamber_pressure.to('MPa').value:.0f} MPa") - print(f" Chamber temperature: {inputs.chamber_temp.to('K').value:.0f} K") - print() - print(f" Isp (vac): {performance.isp_vac.value:.1f} s") - print(f" Throat diameter: {geometry.throat_diameter.to('m').value*100:.2f} cm") - - # ========================================================================= - # Heat Flux Estimation - # ========================================================================= - - print_header("HEAT FLUX ANALYSIS") - - print(" Using Bartz correlation for convective heat transfer...") - print() - - # Estimate heat flux at key locations - locations = ["chamber", "throat", "exit"] - - print(f" {'Location':<15} {'Heat Flux (MW/m²)':<20}") - print(" " + "-" * 35) - - max_q = 0.0 - max_location = "" - - for location in locations: - q = estimate_heat_flux( - inputs=inputs, - performance=performance, - geometry=geometry, - location=location, - ) - q_mw = q.to("W/m^2").value / 1e6 - - if q_mw > max_q: - max_q = q_mw - max_location = location - - print(f" {location:<15} {q_mw:<20.1f}") - - print() - print(f" Maximum heat flux: {max_q:.1f} MW/m² at {max_location}") - - # ========================================================================= - # Heat Flux Profile Along Nozzle - # ========================================================================= - - print_header("AXIAL HEAT FLUX PROFILE") - - x_positions, heat_fluxes = heat_flux_profile( - inputs=inputs, - performance=performance, - geometry=geometry, - n_points=11, - ) - - print(f" {'x/L':<10} {'Heat Flux (MW/m²)':<20}") - print(" " + "-" * 30) - - for x, q in zip(x_positions, heat_fluxes, strict=True): - q_mw = q / 1e6 # heat_flux_profile returns W/m² - bar = "█" * int(q_mw / 5) # Simple bar chart - print(f" {x:<10.2f} {q_mw:<10.1f} {bar}") - - # ========================================================================= - # Cooling Feasibility Check - Methane - # ========================================================================= - - print_header("COOLING FEASIBILITY: METHANE (CH4)") - - print(" Checking if methane can cool this engine...") - print() - - ch4_cooling = check_cooling_feasibility( - inputs=inputs, - performance=performance, - geometry=geometry, - coolant="CH4", - coolant_inlet_temp=kelvin(110), # Near boiling point - max_wall_temp=kelvin(920), # Material limit - ) - - if ch4_cooling.feasible: - print(" ✓ FEASIBLE with methane cooling!") - else: - print(" ✗ NOT FEASIBLE with methane cooling") - - print() - print(f" Max wall temperature: {ch4_cooling.max_wall_temp.to('K').value:.0f} K (limit: {ch4_cooling.max_allowed_temp.to('K').value:.0f} K)") - print(f" Throat heat flux: {ch4_cooling.throat_heat_flux.to('W/m^2').value/1e6:.1f} MW/m²") - print(f" Coolant outlet temp: {ch4_cooling.coolant_outlet_temp.to('K').value:.0f} K") - print(f" Flow margin: {ch4_cooling.flow_margin:.2f}x") - if ch4_cooling.warnings: - print(f" Warnings:") - for w in ch4_cooling.warnings: - print(f" ⚠ {w}") - - # ========================================================================= - # Cooling Feasibility Check - RP-1 (for comparison) - # ========================================================================= - - print_header("COOLING FEASIBILITY: RP-1 (KEROSENE)") - - # Design a kerosene engine for comparison - rp1_inputs = EngineInputs.from_propellants( - oxidizer="LOX", - fuel="RP1", - thrust=kilonewtons(500), - chamber_pressure=megapascals(25), - mixture_ratio=2.7, - name="High-Pc-Kerolox", - ) - rp1_perf, rp1_geom = design_engine(rp1_inputs) - - print(" Checking RP-1 cooling for LOX/RP-1 engine...") - print() - - rp1_cooling = check_cooling_feasibility( - inputs=rp1_inputs, - performance=rp1_perf, - geometry=rp1_geom, - coolant="RP1", - coolant_inlet_temp=kelvin(300), # Room temperature - max_wall_temp=kelvin(920), - ) - - if rp1_cooling.feasible: - print(" ✓ FEASIBLE with RP-1 cooling!") - else: - print(" ✗ NOT FEASIBLE with RP-1 cooling") - - print() - print(f" Max wall temperature: {rp1_cooling.max_wall_temp.to('K').value:.0f} K") - print(f" Coolant outlet temp: {rp1_cooling.coolant_outlet_temp.to('K').value:.0f} K") - print(f" Flow margin: {rp1_cooling.flow_margin:.2f}x") - - # ========================================================================= - # Chamber Pressure Impact Study - # ========================================================================= - - print_header("CHAMBER PRESSURE vs. COOLING FEASIBILITY") - - print(" How does Pc affect cooling feasibility?") - print() - print(f" {'Pc (MPa)':<12} {'Throat q (MW/m²)':<20} {'Coolable?':<12}") - print(" " + "-" * 44) - - for pc_mpa in [5, 10, 15, 20, 25, 30]: - test_inputs = EngineInputs.from_propellants( - oxidizer="LOX", - fuel="CH4", - thrust=kilonewtons(500), - chamber_pressure=megapascals(pc_mpa), - mixture_ratio=3.2, - name=f"Test-{pc_mpa}MPa", - ) - test_perf, test_geom = design_engine(test_inputs) - - q_throat = estimate_heat_flux(test_inputs, test_perf, test_geom, location="throat") - q_mw = q_throat.to("W/m^2").value / 1e6 - - cooling = check_cooling_feasibility( - test_inputs, test_perf, test_geom, - coolant="CH4", - coolant_inlet_temp=kelvin(110), - max_wall_temp=kelvin(920), - ) - - status = "✓ Yes" if cooling.feasible else "✗ No" - print(f" {pc_mpa:<12} {q_mw:<20.1f} {status:<12}") - - # ========================================================================= - # Save Results - # ========================================================================= - - print_header("SAVING RESULTS") - - with OutputContext("thermal_analysis", include_timestamp=True) as ctx: - ctx.save_summary({ - "engine": { - "name": inputs.name, - "thrust_kN": inputs.thrust.to("kN").value, - "chamber_pressure_MPa": inputs.chamber_pressure.to("MPa").value, - "chamber_temp_K": inputs.chamber_temp.to("K").value, - }, - "heat_flux": { - "max_MW_m2": max_q, - "max_location": max_location, - }, - "ch4_cooling": { - "feasible": ch4_cooling.feasible, - "max_wall_temp_K": ch4_cooling.max_wall_temp.value, - "flow_margin": ch4_cooling.flow_margin, - }, - "rp1_cooling": { - "feasible": rp1_cooling.feasible, - "max_wall_temp_K": rp1_cooling.max_wall_temp.value, - "flow_margin": rp1_cooling.flow_margin, - }, - }) - - print() - print(f" Results saved to: {ctx.output_dir}") - - print() - print("╔" + "═" * 68 + "╗") - print("║" + " " * 24 + "ANALYSIS COMPLETE" + " " * 27 + "║") - print("╚" + "═" * 68 + "╝") - print() - - -if __name__ == "__main__": - main() diff --git a/openrocketengine/examples/trade_study.py b/openrocketengine/examples/trade_study.py deleted file mode 100644 index 8fc150c..0000000 --- a/openrocketengine/examples/trade_study.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python -"""Parametric trade study example for Rocket. - -This example demonstrates how to run systematic trade studies to explore -the design space and make informed engineering decisions: - -1. Chamber pressure sweeps (most impactful parameter) -2. Multi-parameter grid studies -3. Constraint-based filtering -4. Polars DataFrame export -5. Mixture ratio studies (requires CEA recalculation) - -These tools let you answer questions like: -- "How does Isp change with chamber pressure?" -- "Which designs satisfy my throat size requirement?" -- "What's the tradeoff between performance and size?" -""" - -from openrocketengine import ( - EngineInputs, - OutputContext, - ParametricStudy, - Range, - design_engine, -) -from openrocketengine.units import kilonewtons, megapascals - - -def main() -> None: - """Run the parametric trade study example.""" - print("=" * 70) - print("Rocket - Parametric Trade Study Example") - print("=" * 70) - print() - - # ========================================================================= - # Baseline Engine Design - # ========================================================================= - - print("Baseline Engine: LOX/CH4 Methalox") - print("-" * 70) - - baseline = EngineInputs.from_propellants( - oxidizer="LOX", - fuel="CH4", - thrust=kilonewtons(200), - chamber_pressure=megapascals(10), - mixture_ratio=3.2, - name="Methalox-Baseline", - ) - - perf, geom = design_engine(baseline) - print(f" Isp (vac): {perf.isp_vac.value:.1f} s") - print(f" Thrust: {baseline.thrust.to('kN').value:.0f} kN") - print() - - # ========================================================================= - # Study 1: Mixture Ratio Trade (with CEA recalculation) - # ========================================================================= - - print("=" * 70) - print("Study 1: Mixture Ratio Sweep (with CEA thermochemistry)") - print("=" * 70) - print() - print("Question: What mixture ratio maximizes Isp for LOX/CH4?") - print() - print("Note: Each point recalculates combustion properties via NASA CEA") - print() - - mixture_ratios = [2.4, 2.6, 2.8, 3.0, 3.2, 3.4, 3.6, 3.8, 4.0] - mr_results = [] - - print(f" {'MR':<8} {'Tc (K)':<10} {'Isp (vac)':<12} {'Isp (SL)':<12}") - print(" " + "-" * 42) - - for mr in mixture_ratios: - inputs = EngineInputs.from_propellants( - oxidizer="LOX", - fuel="CH4", - thrust=kilonewtons(200), - chamber_pressure=megapascals(10), - mixture_ratio=mr, - ) - perf, geom = design_engine(inputs) - mr_results.append({ - "mr": mr, - "Tc": inputs.chamber_temp.to("K").value, - "isp_vac": perf.isp_vac.value, - "isp_sl": perf.isp.value, - }) - print(f" {mr:<8.1f} {inputs.chamber_temp.to('K').value:<10.0f} {perf.isp_vac.value:<12.1f} {perf.isp.value:<12.1f}") - - # Find optimal MR - best = max(mr_results, key=lambda x: x["isp_vac"]) - print() - print(f" → Optimal MR for max Isp: {best['mr']:.1f} (Isp = {best['isp_vac']:.1f} s)") - print(f" At this MR: Tc = {best['Tc']:.0f} K") - print() - - # ========================================================================= - # Study 2: Chamber Pressure Sweep with Range - # ========================================================================= - - print("=" * 70) - print("Study 2: Chamber Pressure Trade") - print("=" * 70) - print() - print("Question: How does performance scale with chamber pressure?") - print() - - # Using Range for continuous sweeps with units - pc_study = ParametricStudy( - compute=design_engine, - base=baseline, - vary={"chamber_pressure": Range(5, 25, n=9, unit="MPa")}, - ) - - pc_results = pc_study.run(progress=True) - - print() - print("Results:") - print(f" {'Pc (MPa)':<10} {'Isp (vac)':<12} {'Throat (cm)':<12} {'c* (m/s)':<10}") - print(" " + "-" * 44) - - df = pc_results.to_dataframe() - for row in df.iter_rows(named=True): - throat_cm = row["throat_diameter"] * 100 - # chamber_pressure is already in MPa (unit specified in Range) - print(f" {row['chamber_pressure']:<10.0f} {row['isp_vac']:<12.1f} {throat_cm:<12.1f} {row['cstar']:<10.0f}") - - # Compute actual insights from data - print() - pc_low = df["chamber_pressure"].min() - pc_high = df["chamber_pressure"].max() - isp_at_low = df.filter(df["chamber_pressure"] == pc_low)["isp_vac"][0] - isp_at_high = df.filter(df["chamber_pressure"] == pc_high)["isp_vac"][0] - dt_at_low = df.filter(df["chamber_pressure"] == pc_low)["throat_diameter"][0] * 100 - dt_at_high = df.filter(df["chamber_pressure"] == pc_high)["throat_diameter"][0] * 100 - - isp_change = isp_at_high - isp_at_low - dt_change = dt_at_high - dt_at_low - - print(f" From {pc_low:.0f} to {pc_high:.0f} MPa:") - print(f" Isp changed by {isp_change:+.1f} s ({100*isp_change/isp_at_low:+.1f}%)") - print(f" Throat diameter changed by {dt_change:+.1f} cm ({100*dt_change/dt_at_low:+.1f}%)") - print() - - # ========================================================================= - # Study 3: Multi-Parameter Grid with Constraints - # ========================================================================= - - print("=" * 70) - print("Study 3: Multi-Parameter Design Space Exploration") - print("=" * 70) - print() - print("Sweeping: Chamber Pressure (5-20 MPa) × Contraction Ratio (3-6)") - print("Constraint: Throat diameter > 8 cm (manufacturability)") - print() - - def throat_constraint(result: tuple) -> bool: - """Filter out designs with throat diameter < 8 cm.""" - _, geometry = result - return geometry.throat_diameter.to("m").value * 100 > 8.0 - - grid_study = ParametricStudy( - compute=design_engine, - base=baseline, - vary={ - "chamber_pressure": Range(5, 20, n=6, unit="MPa"), - "contraction_ratio": [3.0, 4.0, 5.0, 6.0], - }, - constraints=[throat_constraint], - ) - - grid_results = grid_study.run(progress=True) - - print() - n_total = len(grid_results.inputs) - n_feasible = grid_results.constraints_passed.sum() - print(f" Total designs evaluated: {n_total}") - print(f" Feasible designs: {n_feasible} ({100*n_feasible/n_total:.0f}%)") - print() - - # Export to Polars DataFrame for further analysis - df = grid_results.to_dataframe() - - # Filter to feasible designs and show best by Isp - feasible_df = df.filter(df["feasible"]) - best_designs = feasible_df.sort("isp_vac", descending=True).head(5) - - print("Top 5 Feasible Designs by Vacuum Isp:") - print(f" {'Pc (MPa)':<10} {'CR':<8} {'Isp (vac)':<12} {'Dt (cm)':<10}") - print(" " + "-" * 40) - - for row in best_designs.iter_rows(named=True): - # chamber_pressure is already in MPa (unit specified in Range) - dt_cm = row["throat_diameter"] * 100 - print(f" {row['chamber_pressure']:<10.0f} {row['contraction_ratio']:<8.1f} {row['isp_vac']:<12.1f} {dt_cm:<10.1f}") - - print() - - # ========================================================================= - # Save All Results - # ========================================================================= - - print("=" * 70) - print("Saving Results") - print("=" * 70) - print() - - with OutputContext("trade_study_results", include_timestamp=True) as ctx: - # Export DataFrames to CSV - ctx.log("Exporting chamber pressure study...") - pc_results.to_csv(ctx.path("chamber_pressure_sweep.csv")) - - ctx.log("Exporting grid study...") - grid_results.to_csv(ctx.path("grid_study.csv")) - - # Save summary - ctx.save_summary({ - "studies": { - "mixture_ratio_sweep": { - "parameter": "mixture_ratio", - "range": [2.4, 4.0], - "optimal_mr": best["mr"], - "optimal_isp_vac": best["isp_vac"], - "note": "Each point recalculates CEA thermochemistry", - }, - "chamber_pressure_sweep": { - "parameter": "chamber_pressure", - "range_MPa": [5, 25], - "n_points": 9, - }, - "grid_study": { - "parameters": ["chamber_pressure", "contraction_ratio"], - "total_designs": n_total, - "feasible_designs": int(n_feasible), - "constraint": "throat_diameter > 8 cm", - }, - } - }) - - print() - print(f" All results saved to: {ctx.output_dir}") - - print() - print("=" * 70) - print("Trade Study Complete!") - print("=" * 70) - - -if __name__ == "__main__": - main() - diff --git a/openrocketengine/examples/uncertainty_analysis.py b/openrocketengine/examples/uncertainty_analysis.py deleted file mode 100644 index 27c0941..0000000 --- a/openrocketengine/examples/uncertainty_analysis.py +++ /dev/null @@ -1,244 +0,0 @@ -#!/usr/bin/env python -"""Uncertainty analysis example for Rocket. - -This example demonstrates Monte Carlo uncertainty quantification to understand -how input uncertainties propagate through engine design calculations: - -1. Define probability distributions for uncertain inputs -2. Run Monte Carlo sampling -3. Analyze output statistics and confidence intervals -4. Identify which inputs drive the most uncertainty - -This answers questions like: -- "If my mixture ratio varies ±5%, how much does Isp vary?" -- "What's the 95% confidence interval on my thrust coefficient?" -- "Which input uncertainty should I focus on reducing?" -""" - -from openrocketengine import ( - EngineInputs, - Normal, - OutputContext, - UncertaintyAnalysis, - Uniform, - design_engine, -) -from openrocketengine.units import kilonewtons, megapascals - - -def main() -> None: - """Run the uncertainty analysis example.""" - print("=" * 70) - print("Rocket - Uncertainty Analysis Example") - print("=" * 70) - print() - - # ========================================================================= - # Define Nominal Design Point - # ========================================================================= - - print("Nominal Engine Design: LOX/RP-1 Kerolox") - print("-" * 70) - - nominal = EngineInputs.from_propellants( - oxidizer="LOX", - fuel="RP1", - thrust=kilonewtons(100), - chamber_pressure=megapascals(7), - mixture_ratio=2.7, - name="Kerolox-100", - ) - - perf, geom = design_engine(nominal) - print(f" Nominal Isp (vac): {perf.isp_vac.value:.1f} s") - print(f" Nominal Isp (SL): {perf.isp.value:.1f} s") - print(f" Nominal Thrust Coeff: {perf.thrust_coeff:.3f}") - print(f" Nominal Throat Dia: {geom.throat_diameter.to('m').value*100:.2f} cm") - print() - - # ========================================================================= - # Study 1: Single Source of Uncertainty (Mixture Ratio) - # ========================================================================= - - print("=" * 70) - print("Study 1: Mixture Ratio Uncertainty") - print("=" * 70) - print() - print("The mixture ratio is controlled by the propellant valves.") - print("Assume MR = 2.7 ± 0.1 (Normal distribution, σ = 0.1)") - print() - - mr_uncertainty = UncertaintyAnalysis( - compute=design_engine, - base=nominal, - distributions={ - "mixture_ratio": Normal(mean=2.7, std=0.1), - }, - seed=42, # For reproducibility - ) - - mr_results = mr_uncertainty.run(n_samples=1000, progress=True) - - print() - print("Monte Carlo Results (N=1000):") - print() - - # Get statistics for key metrics - stats = mr_results.statistics() - - print(f" {'Metric':<20} {'Mean':<12} {'Std Dev':<12} {'95% CI':<20}") - print(" " + "-" * 64) - - for metric in ["isp_vac", "isp", "thrust_coeff"]: - mean = stats[metric]["mean"] - std = stats[metric]["std"] - ci_low, ci_high = mr_results.confidence_interval(metric, 0.95) - print(f" {metric:<20} {mean:<12.2f} {std:<12.4f} [{ci_low:.2f}, {ci_high:.2f}]") - - print() - print(" Interpretation:") - print(f" - MR uncertainty of σ=0.1 causes Isp variation of σ≈{stats['isp_vac']['std']:.2f} s") - print(f" - 95% of the time, Isp(vac) will be in [{mr_results.confidence_interval('isp_vac', 0.95)[0]:.1f}, {mr_results.confidence_interval('isp_vac', 0.95)[1]:.1f}] s") - print() - - # ========================================================================= - # Study 2: Multiple Sources of Uncertainty - # ========================================================================= - - print("=" * 70) - print("Study 2: Multiple Uncertainty Sources") - print("=" * 70) - print() - print("Real engines have uncertainty in multiple inputs:") - print(" - Chamber pressure: Pc = 7 MPa ± 0.3 MPa (Normal)") - print(" - Mixture ratio: MR = 2.7 ± 0.1 (Normal)") - print(" - Gamma: γ = 1.24 ± 0.02 (Normal, combustion model uncertainty)") - print() - - multi_uncertainty = UncertaintyAnalysis( - compute=design_engine, - base=nominal, - distributions={ - "chamber_pressure": Normal(mean=7, std=0.3, unit="MPa"), - "mixture_ratio": Normal(mean=2.7, std=0.1), - "gamma": Normal(mean=nominal.gamma, std=0.02), - }, - seed=42, - ) - - multi_results = multi_uncertainty.run(n_samples=2000, progress=True) - - print() - print("Monte Carlo Results (N=2000):") - print() - - stats = multi_results.statistics() - - print(f" {'Metric':<20} {'Mean':<12} {'Std Dev':<12} {'Range (99%)':<20}") - print(" " + "-" * 64) - - for metric in ["isp_vac", "isp", "thrust_coeff", "cstar", "expansion_ratio"]: - mean = stats[metric]["mean"] - std = stats[metric]["std"] - ci_low, ci_high = multi_results.confidence_interval(metric, 0.99) - print(f" {metric:<20} {mean:<12.2f} {std:<12.4f} [{ci_low:.2f}, {ci_high:.2f}]") - - print() - - # ========================================================================= - # Study 3: Uniform Distribution (Manufacturing Tolerances) - # ========================================================================= - - print("=" * 70) - print("Study 3: Manufacturing Tolerance Analysis") - print("=" * 70) - print() - print("Manufacturing tolerances are often uniformly distributed.") - print(" - Contraction ratio: CR = 4.0 ± 0.2 (Uniform)") - print(" - L* (characteristic length): L* = 1.0 ± 0.05 m (Uniform)") - print() - - mfg_uncertainty = UncertaintyAnalysis( - compute=design_engine, - base=nominal, - distributions={ - "contraction_ratio": Uniform(low=3.8, high=4.2), - "lstar": Uniform(low=0.95, high=1.05, unit="m"), - }, - seed=42, - ) - - mfg_results = mfg_uncertainty.run(n_samples=1000, progress=True) - - print() - print("Impact of Manufacturing Tolerances:") - print() - - stats = mfg_results.statistics() - - # These parameters mainly affect geometry, not performance - for metric in ["chamber_diameter", "chamber_length"]: - mean = stats[metric]["mean"] - std = stats[metric]["std"] - cv = 100 * std / mean # Coefficient of variation - print(f" {metric:<20}: mean={mean*100:.2f} cm, CV={cv:.2f}%") - - print() - print(" Note: Geometric tolerances have minimal impact on performance") - print(" but affect fit/interface dimensions.") - print() - - # ========================================================================= - # Export Results - # ========================================================================= - - print("=" * 70) - print("Saving Results") - print("=" * 70) - print() - - with OutputContext("uncertainty_analysis", include_timestamp=True) as ctx: - # Export raw Monte Carlo samples - ctx.log("Exporting MR uncertainty samples...") - mr_results.to_csv(ctx.path("mr_uncertainty_samples.csv")) - - ctx.log("Exporting multi-source uncertainty samples...") - multi_results.to_csv(ctx.path("multi_uncertainty_samples.csv")) - - ctx.log("Exporting manufacturing tolerance samples...") - mfg_results.to_csv(ctx.path("mfg_tolerance_samples.csv")) - - # Save statistical summary - ctx.save_summary({ - "mr_uncertainty": { - "inputs": {"mixture_ratio": {"distribution": "Normal", "mean": 2.7, "std": 0.1}}, - "n_samples": 1000, - "isp_vac": { - "mean": float(mr_results.statistics()["isp_vac"]["mean"]), - "std": float(mr_results.statistics()["isp_vac"]["std"]), - "ci_95": list(mr_results.confidence_interval("isp_vac", 0.95)), - }, - }, - "multi_uncertainty": { - "inputs": { - "chamber_pressure": {"distribution": "Normal", "mean_MPa": 7, "std_MPa": 0.3}, - "mixture_ratio": {"distribution": "Normal", "mean": 2.7, "std": 0.1}, - "gamma": {"distribution": "Normal", "mean": nominal.gamma, "std": 0.02}, - }, - "n_samples": 2000, - }, - }) - - print() - print(f" All results saved to: {ctx.output_dir}") - - print() - print("=" * 70) - print("Uncertainty Analysis Complete!") - print("=" * 70) - print() - - -if __name__ == "__main__": - main() - diff --git a/openrocketengine/isentropic.py b/openrocketengine/isentropic.py deleted file mode 100644 index 7840e28..0000000 --- a/openrocketengine/isentropic.py +++ /dev/null @@ -1,633 +0,0 @@ -"""Isentropic flow equations for rocket engine analysis. - -This module contains the core thermodynamic calculations for rocket engine -performance analysis. All functions are pure (no side effects) and -numba-accelerated for performance. - -The equations are based on isentropic flow relations for ideal gases, -which form the foundation of rocket propulsion analysis. - -References: - - Sutton & Biblarz, "Rocket Propulsion Elements", 9th Ed. - - Huzel & Huang, "Modern Engineering for Design of Liquid-Propellant Rocket Engines" - - Hill & Peterson, "Mechanics and Thermodynamics of Propulsion", 2nd Ed. -""" - -import math - -import numba -import numpy as np -from numpy.typing import NDArray - -# ============================================================================= -# Constants -# ============================================================================= - -# Standard gravity acceleration -G0_SI: float = 9.80665 # m/s^2 -G0_IMP: float = 32.174 # ft/s^2 - -# Universal gas constant -R_UNIVERSAL_SI: float = 8314.46 # J/(kmol·K) -R_UNIVERSAL_IMP: float = 1545.35 # ft·lbf/(lbmol·R) - - -# ============================================================================= -# Core Isentropic Flow Functions (Numba Accelerated) -# ============================================================================= - - -@numba.njit(cache=True) -def specific_gas_constant(molecular_weight: float) -> float: - """Calculate specific gas constant from molecular weight. - - Args: - molecular_weight: Molecular weight of the gas [kg/kmol] - - Returns: - Specific gas constant R [J/(kg·K)] - """ - return R_UNIVERSAL_SI / molecular_weight - - -@numba.njit(cache=True) -def characteristic_velocity(gamma: float, R: float, Tc: float) -> float: - """Calculate characteristic velocity (c*). - - c* is a measure of the energy available from the combustion process, - independent of nozzle performance. - - Args: - gamma: Ratio of specific heats (Cp/Cv) [-] - R: Specific gas constant [J/(kg·K)] - Tc: Chamber (stagnation) temperature [K] - - Returns: - Characteristic velocity [m/s] - """ - # c* = sqrt(gamma * R * Tc) / (gamma * sqrt((2/(gamma+1))^((gamma+1)/(gamma-1)))) - term1 = math.sqrt(gamma * R * Tc) - term2 = gamma * math.sqrt((2.0 / (gamma + 1.0)) ** ((gamma + 1.0) / (gamma - 1.0))) - return term1 / term2 - - -@numba.njit(cache=True) -def thrust_coefficient( - gamma: float, pe_pc: float, pa_pc: float, expansion_ratio: float -) -> float: - """Calculate thrust coefficient (Cf). - - Cf characterizes the nozzle's ability to convert thermal energy - into directed kinetic energy. - - Args: - gamma: Ratio of specific heats [-] - pe_pc: Exit pressure / chamber pressure ratio [-] - pa_pc: Ambient pressure / chamber pressure ratio [-] - expansion_ratio: Nozzle exit area / throat area (Ae/At) [-] - - Returns: - Thrust coefficient [-] - """ - # Momentum thrust term - gm1 = gamma - 1.0 - gp1 = gamma + 1.0 - exponent = gm1 / gamma - - term1 = 2.0 * gamma**2 / gm1 - term2 = (2.0 / gp1) ** (gp1 / gm1) - term3 = 1.0 - pe_pc**exponent - - Cf_momentum = math.sqrt(term1 * term2 * term3) - - # Pressure thrust term - Cf_pressure = (pe_pc - pa_pc) * expansion_ratio - - return Cf_momentum + Cf_pressure - - -@numba.njit(cache=True) -def thrust_coefficient_vacuum(gamma: float, pe_pc: float, expansion_ratio: float) -> float: - """Calculate vacuum thrust coefficient (Cf_vac). - - Args: - gamma: Ratio of specific heats [-] - pe_pc: Exit pressure / chamber pressure ratio [-] - expansion_ratio: Nozzle exit area / throat area (Ae/At) [-] - - Returns: - Vacuum thrust coefficient [-] - """ - return thrust_coefficient(gamma, pe_pc, 0.0, expansion_ratio) - - -@numba.njit(cache=True) -def specific_impulse(cstar: float, Cf: float, g0: float = G0_SI) -> float: - """Calculate specific impulse (Isp). - - Isp is the key performance metric for rocket engines, representing - the thrust produced per unit weight flow rate of propellant. - - Args: - cstar: Characteristic velocity [m/s] - Cf: Thrust coefficient [-] - g0: Standard gravity [m/s^2], default 9.80665 - - Returns: - Specific impulse [s] - """ - return cstar * Cf / g0 - - -@numba.njit(cache=True) -def exhaust_velocity(gamma: float, R: float, Tc: float, pe_pc: float) -> float: - """Calculate exhaust velocity (ue). - - This is the velocity of the exhaust gases at the nozzle exit - for isentropic expansion. - - Args: - gamma: Ratio of specific heats [-] - R: Specific gas constant [J/(kg·K)] - Tc: Chamber temperature [K] - pe_pc: Exit pressure / chamber pressure ratio [-] - - Returns: - Exhaust velocity [m/s] - """ - gm1 = gamma - 1.0 - exponent = gm1 / gamma - - term1 = 2.0 * gamma * R * Tc / gm1 - term2 = 1.0 - pe_pc**exponent - - return math.sqrt(term1 * term2) - - -@numba.njit(cache=True) -def mass_flow_rate(thrust: float, Isp: float, g0: float = G0_SI) -> float: - """Calculate total mass flow rate from thrust and Isp. - - Args: - thrust: Engine thrust [N] - Isp: Specific impulse [s] - g0: Standard gravity [m/s^2] - - Returns: - Mass flow rate [kg/s] - """ - return thrust / (Isp * g0) - - -@numba.njit(cache=True) -def mass_flow_rate_from_throat( - pc: float, At: float, gamma: float, R: float, Tc: float -) -> float: - """Calculate mass flow rate from throat conditions. - - Uses the choked flow condition at the throat. - - Args: - pc: Chamber pressure [Pa] - At: Throat area [m^2] - gamma: Ratio of specific heats [-] - R: Specific gas constant [J/(kg·K)] - Tc: Chamber temperature [K] - - Returns: - Mass flow rate [kg/s] - """ - gp1 = gamma + 1.0 - gm1 = gamma - 1.0 - - term1 = pc * At - term2 = gamma / (R * Tc) - term3 = (2.0 / gp1) ** (gp1 / gm1) - - return term1 * math.sqrt(term2 * term3) - - -@numba.njit(cache=True) -def throat_area(mdot: float, cstar: float, pc: float) -> float: - """Calculate required throat area. - - Args: - mdot: Mass flow rate [kg/s] - cstar: Characteristic velocity [m/s] - pc: Chamber pressure [Pa] - - Returns: - Throat area [m^2] - """ - return mdot * cstar / pc - - -@numba.njit(cache=True) -def area_from_diameter(diameter: float) -> float: - """Calculate circular area from diameter. - - Args: - diameter: Diameter [m] - - Returns: - Area [m^2] - """ - return math.pi * (diameter / 2.0) ** 2 - - -@numba.njit(cache=True) -def diameter_from_area(area: float) -> float: - """Calculate diameter from circular area. - - Args: - area: Area [m^2] - - Returns: - Diameter [m] - """ - return 2.0 * math.sqrt(area / math.pi) - - -# ============================================================================= -# Mach Number Relations -# ============================================================================= - - -@numba.njit(cache=True) -def mach_from_pressure_ratio(pc_p: float, gamma: float) -> float: - """Calculate Mach number from stagnation-to-static pressure ratio. - - Args: - pc_p: Chamber (stagnation) pressure / local static pressure [-] - gamma: Ratio of specific heats [-] - - Returns: - Mach number [-] - """ - gm1 = gamma - 1.0 - exponent = gm1 / gamma - - return math.sqrt((2.0 / gm1) * (pc_p**exponent - 1.0)) - - -@numba.njit(cache=True) -def pressure_ratio_from_mach(M: float, gamma: float) -> float: - """Calculate stagnation-to-static pressure ratio from Mach number. - - Args: - M: Mach number [-] - gamma: Ratio of specific heats [-] - - Returns: - pc/p ratio [-] - """ - gm1 = gamma - 1.0 - exponent = gamma / gm1 - - return (1.0 + gm1 / 2.0 * M**2) ** exponent - - -@numba.njit(cache=True) -def temperature_ratio_from_mach(M: float, gamma: float) -> float: - """Calculate stagnation-to-static temperature ratio from Mach number. - - Args: - M: Mach number [-] - gamma: Ratio of specific heats [-] - - Returns: - Tc/T ratio [-] - """ - return 1.0 + (gamma - 1.0) / 2.0 * M**2 - - -@numba.njit(cache=True) -def area_ratio_from_mach(M: float, gamma: float) -> float: - """Calculate area ratio (A/A*) from Mach number. - - A* is the critical (sonic) area, i.e., the throat area for choked flow. - - Args: - M: Mach number [-] - gamma: Ratio of specific heats [-] - - Returns: - Area ratio A/A* [-] - """ - if M <= 0.0: - return float("inf") - - gm1 = gamma - 1.0 - gp1 = gamma + 1.0 - exponent = gp1 / (2.0 * gm1) - - term1 = 1.0 / M - term2 = (2.0 / gp1) * (1.0 + gm1 / 2.0 * M**2) - - return term1 * term2**exponent - - -@numba.njit(cache=True) -def mach_from_area_ratio_supersonic(area_ratio: float, gamma: float) -> float: - """Calculate supersonic Mach number from area ratio using Newton-Raphson. - - For a given A/A* > 1, there are two solutions: subsonic and supersonic. - This function returns the supersonic solution (M > 1). - - Args: - area_ratio: Area ratio A/A* [-], must be >= 1 - gamma: Ratio of specific heats [-] - - Returns: - Supersonic Mach number [-] - """ - if area_ratio < 1.0: - return 1.0 # At throat - - # Initial guess for supersonic flow - M = 2.0 + area_ratio / 5.0 - - # Newton-Raphson iteration - for _ in range(50): - f = area_ratio_from_mach(M, gamma) - area_ratio - - # Numerical derivative - dM = 1e-8 - df = (area_ratio_from_mach(M + dM, gamma) - area_ratio_from_mach(M - dM, gamma)) / ( - 2.0 * dM - ) - - if abs(df) < 1e-12: - break - - M_new = M - f / df - - if M_new < 1.0: - M_new = 1.0 + 0.1 - - if abs(M_new - M) < 1e-10: - break - - M = M_new - - return M - - -@numba.njit(cache=True) -def mach_from_area_ratio_subsonic(area_ratio: float, gamma: float) -> float: - """Calculate subsonic Mach number from area ratio using Newton-Raphson. - - For a given A/A* > 1, there are two solutions: subsonic and supersonic. - This function returns the subsonic solution (M < 1). - - Args: - area_ratio: Area ratio A/A* [-], must be >= 1 - gamma: Ratio of specific heats [-] - - Returns: - Subsonic Mach number [-] - """ - if area_ratio < 1.0: - return 1.0 - - # Initial guess for subsonic flow - M = 0.5 - - # Newton-Raphson iteration - for _ in range(50): - f = area_ratio_from_mach(M, gamma) - area_ratio - - # Numerical derivative - dM = 1e-8 - df = (area_ratio_from_mach(M + dM, gamma) - area_ratio_from_mach(M - dM, gamma)) / ( - 2.0 * dM - ) - - if abs(df) < 1e-12: - break - - M_new = M - f / df - - if M_new > 1.0: - M_new = 0.99 - if M_new < 0.0: - M_new = 0.01 - - if abs(M_new - M) < 1e-10: - break - - M = M_new - - return M - - -# ============================================================================= -# Throat and Exit Conditions -# ============================================================================= - - -@numba.njit(cache=True) -def throat_temperature(Tc: float, gamma: float) -> float: - """Calculate throat (critical) temperature. - - Args: - Tc: Chamber temperature [K] - gamma: Ratio of specific heats [-] - - Returns: - Throat temperature [K] - """ - return Tc / (1.0 + (gamma - 1.0) / 2.0) - - -@numba.njit(cache=True) -def throat_pressure(pc: float, gamma: float) -> float: - """Calculate throat (critical) pressure. - - Args: - pc: Chamber pressure [Pa] - gamma: Ratio of specific heats [-] - - Returns: - Throat pressure [Pa] - """ - exponent = gamma / (gamma - 1.0) - return pc * (2.0 / (gamma + 1.0)) ** exponent - - -@numba.njit(cache=True) -def expansion_ratio_from_pressure_ratio(pc_pe: float, gamma: float) -> float: - """Calculate nozzle expansion ratio from chamber-to-exit pressure ratio. - - Args: - pc_pe: Chamber pressure / exit pressure [-] - gamma: Ratio of specific heats [-] - - Returns: - Expansion ratio (Ae/At) [-] - """ - # First get exit Mach number - Me = mach_from_pressure_ratio(pc_pe, gamma) - # Then get area ratio - return area_ratio_from_mach(Me, gamma) - - -@numba.njit(cache=True) -def exit_pressure_from_expansion_ratio( - expansion_ratio: float, pc: float, gamma: float -) -> float: - """Calculate exit pressure from expansion ratio. - - Args: - expansion_ratio: Nozzle expansion ratio (Ae/At) [-] - pc: Chamber pressure [Pa] - gamma: Ratio of specific heats [-] - - Returns: - Exit pressure [Pa] - """ - # Get exit Mach number (supersonic solution) - Me = mach_from_area_ratio_supersonic(expansion_ratio, gamma) - # Get pressure ratio - pc_pe = pressure_ratio_from_mach(Me, gamma) - return pc / pc_pe - - -# ============================================================================= -# Chamber Geometry -# ============================================================================= - - -@numba.njit(cache=True) -def chamber_volume(lstar: float, At: float) -> float: - """Calculate chamber volume from L* and throat area. - - L* (characteristic length) is defined as the chamber volume divided - by the throat area: L* = Vc / At - - Args: - lstar: Characteristic length [m] - At: Throat area [m^2] - - Returns: - Chamber volume [m^3] - """ - return lstar * At - - -@numba.njit(cache=True) -def cylindrical_chamber_length( - Vc: float, Ac: float, Rc: float, Rt: float, contraction_angle: float -) -> float: - """Calculate length of cylindrical section of chamber. - - Accounts for the converging section geometry. - - Args: - Vc: Chamber volume [m^3] - Ac: Chamber cross-sectional area [m^2] - Rc: Chamber radius [m] - Rt: Throat radius [m] - contraction_angle: Convergence half-angle [radians] - - Returns: - Cylindrical section length [m] - """ - # Volume of converging cone section - cone_length = (Rc - Rt) / math.tan(contraction_angle) - # Approximate cylindrical length (subtract converging section contribution) - return Vc / Ac - 0.5 * cone_length - - -@numba.njit(cache=True) -def conical_nozzle_length(Rt: float, Re: float, half_angle: float) -> float: - """Calculate length of a conical nozzle. - - Args: - Rt: Throat radius [m] - Re: Exit radius [m] - half_angle: Nozzle half-angle [radians] - - Returns: - Nozzle length [m] - """ - return (Re - Rt) / math.tan(half_angle) - - -@numba.njit(cache=True) -def bell_nozzle_length( - Rt: float, Re: float, bell_fraction: float = 0.8, reference_angle: float = 0.2618 -) -> float: - """Calculate length of a bell (parabolic) nozzle. - - Bell nozzles are typically specified as a percentage of the length - of a 15-degree conical nozzle with the same expansion ratio. - - Args: - Rt: Throat radius [m] - Re: Exit radius [m] - bell_fraction: Length as fraction of 15° cone (e.g., 0.8 for 80% bell) - reference_angle: Reference cone half-angle [radians], default 15° = 0.2618 - - Returns: - Nozzle length [m] - """ - conical_length = conical_nozzle_length(Rt, Re, reference_angle) - return conical_length * bell_fraction - - -# ============================================================================= -# Vectorized Functions for Parametric Studies -# ============================================================================= - - -@numba.njit(cache=True, parallel=True) -def thrust_coefficient_sweep( - gamma: float, - pe_pc: float, - pa_pc_array: NDArray[np.float64], - expansion_ratio: float, -) -> NDArray[np.float64]: - """Calculate thrust coefficient for array of ambient pressures. - - Useful for altitude performance analysis. - - Args: - gamma: Ratio of specific heats [-] - pe_pc: Exit pressure / chamber pressure ratio [-] - pa_pc_array: Array of ambient pressure / chamber pressure ratios [-] - expansion_ratio: Nozzle expansion ratio [-] - - Returns: - Array of thrust coefficients [-] - """ - n = len(pa_pc_array) - result = np.empty(n, dtype=np.float64) - - for i in numba.prange(n): - result[i] = thrust_coefficient(gamma, pe_pc, pa_pc_array[i], expansion_ratio) - - return result - - -@numba.njit(cache=True) -def area_ratio_sweep( - mach_array: NDArray[np.float64], gamma: float -) -> NDArray[np.float64]: - """Calculate area ratios for array of Mach numbers. - - Args: - mach_array: Array of Mach numbers [-] - gamma: Ratio of specific heats [-] - - Returns: - Array of area ratios [-] - """ - n = len(mach_array) - result = np.empty(n, dtype=np.float64) - - for i in range(n): - result[i] = area_ratio_from_mach(mach_array[i], gamma) - - return result - diff --git a/openrocketengine/nozzle.py b/openrocketengine/nozzle.py deleted file mode 100644 index 099fa16..0000000 --- a/openrocketengine/nozzle.py +++ /dev/null @@ -1,427 +0,0 @@ -"""Nozzle contour generation for rocket engines. - -This module provides functions to generate nozzle contours for various -nozzle types including: -- Conical nozzles (simple 15° half-angle) -- Rao parabolic bell nozzles (optimized for performance) - -The contours can be exported to CSV for CAD import. - -References: - - Rao, G.V.R., "Exhaust Nozzle Contour for Optimum Thrust", - Jet Propulsion, Vol. 28, No. 6, 1958 - - Huzel & Huang, "Modern Engineering for Design of Liquid-Propellant - Rocket Engines", Chapter 4 -""" - -import math -from dataclasses import dataclass -from pathlib import Path - -import numpy as np -from beartype import beartype -from numpy.typing import NDArray - -from openrocketengine.engine import EngineGeometry, EngineInputs -from openrocketengine.units import Quantity - -# ============================================================================= -# Nozzle Contour Data Structure -# ============================================================================= - - -@beartype -@dataclass(frozen=True) -class NozzleContour: - """Nozzle contour defined by axial and radial coordinates. - - The contour represents the inner wall of the nozzle from the chamber - through the throat to the exit. Coordinates are in meters. - - Attributes: - x: Axial positions [m], with x=0 at throat - y: Radial positions [m] (radius, not diameter) - contour_type: Type of contour ("rao_bell", "conical", etc.) - """ - - x: NDArray[np.float64] - y: NDArray[np.float64] - contour_type: str - - def __post_init__(self) -> None: - """Validate contour data.""" - if len(self.x) != len(self.y): - raise ValueError( - f"x and y arrays must have same length, got {len(self.x)} and {len(self.y)}" - ) - if len(self.x) < 2: - raise ValueError("Contour must have at least 2 points") - - def to_csv(self, path: str | Path, include_header: bool = True) -> None: - """Export contour to CSV file for CAD import. - - Args: - path: Output file path - include_header: Whether to include column headers - """ - path = Path(path) - with path.open("w") as f: - if include_header: - f.write("x_m,y_m,x_mm,y_mm\n") - for xi, yi in zip(self.x, self.y, strict=True): - f.write(f"{xi:.8f},{yi:.8f},{xi * 1000:.6f},{yi * 1000:.6f}\n") - - def to_arrays_mm(self) -> tuple[NDArray[np.float64], NDArray[np.float64]]: - """Return contour coordinates in millimeters. - - Returns: - Tuple of (x_mm, y_mm) arrays - """ - return self.x * 1000, self.y * 1000 - - @property - def length(self) -> float: - """Total axial length of the contour [m].""" - return float(self.x[-1] - self.x[0]) - - @property - def throat_radius(self) -> float: - """Radius at throat (minimum y value) [m].""" - return float(np.min(self.y)) - - @property - def exit_radius(self) -> float: - """Radius at exit [m].""" - return float(self.y[-1]) - - @property - def inlet_radius(self) -> float: - """Radius at inlet [m].""" - return float(self.y[0]) - - -# ============================================================================= -# Rao Bell Nozzle Contour -# ============================================================================= - - -@beartype -def rao_bell_contour( - throat_radius: Quantity, - exit_radius: Quantity, - expansion_ratio: float, - bell_fraction: float = 0.8, - num_points: int = 100, -) -> NozzleContour: - """Generate a Rao parabolic bell nozzle contour. - - The Rao bell nozzle uses a parabolic approximation to the ideal - thrust-optimized contour. It consists of: - 1. A circular arc leaving the throat (radius = 0.382 * Rt) - 2. A parabolic section to the exit - - The bell_fraction parameter specifies the length as a fraction of - an equivalent 15° conical nozzle. - - Args: - throat_radius: Throat radius [length] - exit_radius: Exit radius [length] - expansion_ratio: Area ratio Ae/At [-] - bell_fraction: Length as fraction of 15° cone (typically 0.8) - num_points: Number of points in the contour - - Returns: - NozzleContour with the bell nozzle shape - """ - # Convert to SI - Rt = throat_radius.to("m").value - Re = exit_radius.to("m").value - - # Rao parameters (from empirical correlations) - # Initial angle leaving throat depends on expansion ratio - # Final angle at exit also depends on expansion ratio - # These are approximations from Rao's paper and Huzel & Huang - - # Throat circular arc radius - Rn = 0.382 * Rt # Radius of curvature leaving throat - - # Reference conical nozzle length (15° half-angle) - Lc_15 = (Re - Rt) / math.tan(math.radians(15)) - - # Bell nozzle length - Ln = Lc_15 * bell_fraction - - # Initial and final angles (empirical fits from Rao curves) - # These depend on expansion ratio and bell fraction - theta_n = _rao_initial_angle(expansion_ratio, bell_fraction) - theta_e = _rao_exit_angle(expansion_ratio, bell_fraction) - - # Convert to radians - theta_n_rad = math.radians(theta_n) - theta_e_rad = math.radians(theta_e) - - # Generate contour points - - # Point N: End of throat circular arc - # x_N is relative to throat center - x_N = Rn * math.sin(theta_n_rad) - y_N = Rt + Rn * (1 - math.cos(theta_n_rad)) - - # Point E: Exit - x_E = Ln - y_E = Re - - # Generate throat arc (from throat to point N) - n_arc = num_points // 4 - theta_arc = np.linspace(0, theta_n_rad, n_arc) - x_arc = Rn * np.sin(theta_arc) - y_arc = Rt + Rn * (1 - np.cos(theta_arc)) - - # Generate parabolic section (from N to E) - # Using quadratic Bezier curve approximation - n_parabola = num_points - n_arc - - # Control point for quadratic Bezier - # The tangent at N has slope tan(theta_n) - # The tangent at E has slope tan(theta_e) - # Find intersection of these tangents - - m_N = math.tan(theta_n_rad) - m_E = math.tan(theta_e_rad) - - # Line from N: y - y_N = m_N * (x - x_N) - # Line from E: y - y_E = m_E * (x - x_E) - # Solve for intersection (control point Q) - - if abs(m_N - m_E) > 1e-10: - x_Q = (y_E - y_N + m_N * x_N - m_E * x_E) / (m_N - m_E) - y_Q = y_N + m_N * (x_Q - x_N) - else: - # Parallel tangents (shouldn't happen for reasonable parameters) - x_Q = (x_N + x_E) / 2 - y_Q = (y_N + y_E) / 2 - - # Generate Bezier curve points - t = np.linspace(0, 1, n_parabola) - x_parabola = (1 - t) ** 2 * x_N + 2 * (1 - t) * t * x_Q + t**2 * x_E - y_parabola = (1 - t) ** 2 * y_N + 2 * (1 - t) * t * y_Q + t**2 * y_E - - # Combine arc and parabola (skip first point of parabola to avoid duplicate) - x = np.concatenate([x_arc, x_parabola[1:]]) - y = np.concatenate([y_arc, y_parabola[1:]]) - - return NozzleContour(x=x, y=y, contour_type="rao_bell") - - -def _rao_initial_angle(expansion_ratio: float, bell_fraction: float) -> float: - """Calculate initial expansion angle for Rao bell nozzle. - - Empirical correlation based on Rao's curves. - - Args: - expansion_ratio: Area ratio Ae/At - bell_fraction: Bell length fraction (0.6-1.0) - - Returns: - Initial angle in degrees - """ - # Empirical fit (approximation of Rao curves) - # For 80% bell: ~30-35° for low eps, ~20-25° for high eps - eps = expansion_ratio - - if bell_fraction <= 0.6: - theta = 38 - 2.5 * math.log10(eps) - elif bell_fraction <= 0.8: - theta = 33 - 3.5 * math.log10(eps) - else: - theta = 28 - 4.0 * math.log10(eps) - - # Clamp to reasonable values - return max(15.0, min(45.0, theta)) - - -def _rao_exit_angle(expansion_ratio: float, bell_fraction: float) -> float: - """Calculate exit angle for Rao bell nozzle. - - Empirical correlation based on Rao's curves. - - Args: - expansion_ratio: Area ratio Ae/At - bell_fraction: Bell length fraction (0.6-1.0) - - Returns: - Exit angle in degrees - """ - # Empirical fit - # Exit angle is typically 6-12° for most practical nozzles - eps = expansion_ratio - - if bell_fraction <= 0.6: - theta = 14 - 1.5 * math.log10(eps) - elif bell_fraction <= 0.8: - theta = 11 - 2.0 * math.log10(eps) - else: - theta = 8 - 2.5 * math.log10(eps) - - # Clamp to reasonable values - return max(4.0, min(15.0, theta)) - - -# ============================================================================= -# Conical Nozzle Contour -# ============================================================================= - - -@beartype -def conical_contour( - throat_radius: Quantity, - exit_radius: Quantity, - half_angle: float = 15.0, - num_points: int = 100, -) -> NozzleContour: - """Generate a conical nozzle contour. - - A simple conical nozzle with constant divergence angle. - The standard half-angle is 15°. - - Args: - throat_radius: Throat radius [length] - exit_radius: Exit radius [length] - half_angle: Nozzle half-angle in degrees (default 15°) - num_points: Number of points in the contour - - Returns: - NozzleContour with the conical shape - """ - Rt = throat_radius.to("m").value - Re = exit_radius.to("m").value - - # Nozzle length - Ln = (Re - Rt) / math.tan(math.radians(half_angle)) - - # Generate linear contour - x = np.linspace(0, Ln, num_points) - y = Rt + x * math.tan(math.radians(half_angle)) - - return NozzleContour(x=x, y=y, contour_type="conical") - - -# ============================================================================= -# Full Chamber Contour (Chamber + Convergent + Nozzle) -# ============================================================================= - - -@beartype -def full_chamber_contour( - inputs: EngineInputs, - geometry: EngineGeometry, - nozzle_contour: NozzleContour, - num_chamber_points: int = 50, - num_convergent_points: int = 30, -) -> NozzleContour: - """Generate complete chamber contour including chamber and convergent section. - - Combines: - 1. Cylindrical chamber section - 2. Convergent section (circular arc transition + conical) - 3. Throat region with circular arc - 4. Divergent nozzle section - - Args: - inputs: Engine inputs (for contraction angle) - geometry: Computed geometry - nozzle_contour: Pre-computed divergent nozzle contour - num_chamber_points: Points for cylindrical section - num_convergent_points: Points for convergent section - - Returns: - Complete chamber contour from inlet to exit - """ - # Extract dimensions - Rc = geometry.chamber_diameter.to("m").value / 2 - Rt = geometry.throat_diameter.to("m").value / 2 - Lcyl = geometry.chamber_length.to("m").value - contraction_angle = math.radians(inputs.contraction_angle) - - # Upstream radius of curvature (typically 1.5 * Rt for smooth transition) - R1 = 1.5 * Rt - - # Convergent section geometry - # The convergent has a circular arc transition from chamber to conical section - - # Calculate convergent section - # Point where circular arc meets conical section - theta_c = contraction_angle - x_tan = R1 * math.sin(theta_c) # axial distance from throat to tangent point - y_tan = Rt + R1 * (1 - math.cos(theta_c)) # radius at tangent point - - # Length of conical section (from tangent point to chamber) - L_cone = (Rc - y_tan) / math.tan(theta_c) if Rc > y_tan else 0 - - # Total convergent length - L_conv = x_tan + L_cone - - # Generate chamber section (negative x, before throat) - x_chamber = np.linspace(-(Lcyl + L_conv), -L_conv, num_chamber_points) - y_chamber = np.full_like(x_chamber, Rc) - - # Generate convergent conical section - if L_cone > 0: - n_cone = num_convergent_points // 2 - x_cone = np.linspace(-L_conv, -(x_tan), n_cone) - y_cone = Rc - (x_cone + L_conv) * math.tan(theta_c) - else: - x_cone = np.array([]) - y_cone = np.array([]) - - # Generate convergent circular arc (transition to throat) - # Arc center is at (0, Rt + R1), tangent to throat at bottom - # Arc goes from tangent point with cone (angle = theta_c) to throat (angle = 0) - n_arc = num_convergent_points - len(x_cone) - theta_range = np.linspace(theta_c, 0, n_arc) - x_arc = -R1 * np.sin(theta_range) # Negative (upstream of throat) - y_arc = Rt + R1 * (1 - np.cos(theta_range)) # From y_tan down to Rt - - # Shift nozzle contour (it starts at x=0 at throat) - x_nozzle = nozzle_contour.x - y_nozzle = nozzle_contour.y - - # Combine all sections - x_all = np.concatenate([x_chamber, x_cone, x_arc[:-1], x_nozzle]) - y_all = np.concatenate([y_chamber, y_cone, y_arc[:-1], y_nozzle]) - - return NozzleContour(x=x_all, y=y_all, contour_type=f"full_{nozzle_contour.contour_type}") - - -# ============================================================================= -# Convenience Functions -# ============================================================================= - - -@beartype -def generate_nozzle_from_geometry( - geometry: EngineGeometry, - bell_fraction: float = 0.8, - num_points: int = 100, -) -> NozzleContour: - """Generate a Rao bell nozzle contour from engine geometry. - - Convenience function that extracts the necessary parameters from - EngineGeometry. - - Args: - geometry: Computed engine geometry - bell_fraction: Bell length fraction (default 0.8) - num_points: Number of contour points - - Returns: - NozzleContour for the divergent section - """ - return rao_bell_contour( - throat_radius=geometry.throat_diameter / 2, - exit_radius=geometry.exit_diameter / 2, - expansion_ratio=geometry.expansion_ratio, - bell_fraction=bell_fraction, - num_points=num_points, - ) - diff --git a/openrocketengine/output.py b/openrocketengine/output.py deleted file mode 100644 index d0d2202..0000000 --- a/openrocketengine/output.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Output management for Rocket. - -This module provides utilities for organizing outputs from rocket design -analyses into structured directories with consistent naming. - -Example: - >>> from openrocketengine.output import OutputContext - >>> with OutputContext("my_engine_study") as ctx: - ... fig.savefig(ctx.path("engine_dashboard.png")) - ... contour.to_csv(ctx.path("nozzle_contour.csv")) - ... ctx.save_summary({"isp": 300, "thrust": 50000}) -""" - -import json -import shutil -from datetime import datetime -from pathlib import Path -from typing import Any - -from beartype import beartype - - -@beartype -class OutputContext: - """Context manager for organizing analysis outputs. - - Creates a structured output directory with: - - Timestamp-based naming for version control - - Subdirectories for different output types - - Automatic metadata logging - - Summary JSON export - - Directory structure: - {base_dir}/{name}_{timestamp}/ - ├── plots/ # Visualization outputs - ├── data/ # CSV, contour exports - ├── reports/ # Text summaries - └── metadata.json # Run information - - Attributes: - name: Study/analysis name - output_dir: Path to the output directory - timestamp: Creation timestamp - """ - - def __init__( - self, - name: str, - base_dir: str | Path | None = None, - include_timestamp: bool = True, - create_subdirs: bool = True, - ) -> None: - """Initialize output context. - - Args: - name: Name for this analysis/study (used in directory name) - base_dir: Base directory for outputs. Defaults to ./outputs/ - include_timestamp: Whether to include timestamp in directory name - create_subdirs: Whether to create plots/, data/, reports/ subdirs - """ - self.name = name - self.timestamp = datetime.now() - self._include_timestamp = include_timestamp - self._create_subdirs = create_subdirs - self._metadata: dict[str, Any] = { - "name": name, - "created": self.timestamp.isoformat(), - "files": [], - } - - # Determine base directory - if base_dir is None: - base_dir = Path.cwd() / "outputs" - self.base_dir = Path(base_dir) - - # Create output directory name - if include_timestamp: - timestamp_str = self.timestamp.strftime("%Y%m%d_%H%M%S") - dir_name = f"{name}_{timestamp_str}" - else: - dir_name = name - - self.output_dir = self.base_dir / dir_name - self._entered = False - - def __enter__(self) -> "OutputContext": - """Enter the context and create directories.""" - self._entered = True - - # Create main output directory - self.output_dir.mkdir(parents=True, exist_ok=True) - - # Create subdirectories - if self._create_subdirs: - (self.output_dir / "plots").mkdir(exist_ok=True) - (self.output_dir / "data").mkdir(exist_ok=True) - (self.output_dir / "reports").mkdir(exist_ok=True) - - return self - - def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - """Exit context and write metadata.""" - self._metadata["completed"] = datetime.now().isoformat() - self._metadata["success"] = exc_type is None - - # Write metadata - metadata_path = self.output_dir / "metadata.json" - with open(metadata_path, "w") as f: - json.dump(self._metadata, f, indent=2, default=str) - - self._entered = False - - def path(self, filename: str, subdir: str | None = None) -> Path: - """Get full path for an output file. - - Automatically routes files to appropriate subdirectories based on extension. - - Args: - filename: Name of the output file - subdir: Optional subdirectory override. If None, auto-routes: - - .png, .pdf, .svg → plots/ - - .csv, .json → data/ - - .txt, .md → reports/ - - Returns: - Full path to the output file - """ - if not self._entered: - raise RuntimeError("OutputContext must be used as a context manager") - - # Auto-route based on extension if subdir not specified - if subdir is None and self._create_subdirs: - ext = Path(filename).suffix.lower() - if ext in {".png", ".pdf", ".svg", ".jpg", ".jpeg"}: - subdir = "plots" - elif ext in {".csv", ".json", ".npy", ".npz"}: - subdir = "data" - elif ext in {".txt", ".md", ".rst", ".log"}: - subdir = "reports" - - if subdir: - full_path = self.output_dir / subdir / filename - else: - full_path = self.output_dir / filename - - # Track file for metadata - self._metadata["files"].append(str(full_path.relative_to(self.output_dir))) - - return full_path - - def plots_dir(self) -> Path: - """Get path to plots subdirectory.""" - return self.output_dir / "plots" - - def data_dir(self) -> Path: - """Get path to data subdirectory.""" - return self.output_dir / "data" - - def reports_dir(self) -> Path: - """Get path to reports subdirectory.""" - return self.output_dir / "reports" - - def save_summary(self, summary: dict[str, Any], filename: str = "summary.json") -> Path: - """Save a summary dictionary as JSON. - - Args: - summary: Dictionary of summary data - filename: Output filename - - Returns: - Path to saved file - """ - path = self.path(filename, subdir="data") - with open(path, "w") as f: - json.dump(summary, f, indent=2, default=str) - return path - - def save_text(self, text: str, filename: str = "report.txt") -> Path: - """Save text to a report file. - - Args: - text: Text content to save - filename: Output filename - - Returns: - Path to saved file - """ - path = self.path(filename, subdir="reports") - with open(path, "w") as f: - f.write(text) - return path - - def add_metadata(self, key: str, value: Any) -> None: - """Add custom metadata. - - Args: - key: Metadata key - value: Metadata value (must be JSON-serializable) - """ - self._metadata[key] = value - - def log(self, message: str) -> None: - """Log a message to both console and log file. - - Args: - message: Message to log - """ - timestamp = datetime.now().strftime("%H:%M:%S") - formatted = f"[{timestamp}] {message}" - print(formatted) - - log_path = self.output_dir / "run.log" - with open(log_path, "a") as f: - f.write(formatted + "\n") - - -@beartype -def get_default_output_dir() -> Path: - """Get the default output directory. - - Returns ./outputs/ in the current working directory. - - Returns: - Path to default output directory - """ - return Path.cwd() / "outputs" - - -@beartype -def list_outputs(base_dir: str | Path | None = None) -> list[Path]: - """List all output directories. - - Args: - base_dir: Base directory to search. Defaults to ./outputs/ - - Returns: - List of output directory paths, sorted by modification time (newest first) - """ - if base_dir is None: - base_dir = get_default_output_dir() - base_dir = Path(base_dir) - - if not base_dir.exists(): - return [] - - outputs = [d for d in base_dir.iterdir() if d.is_dir()] - return sorted(outputs, key=lambda p: p.stat().st_mtime, reverse=True) - - -@beartype -def clean_outputs(base_dir: str | Path | None = None, keep_latest: int = 5) -> int: - """Clean old output directories, keeping the N most recent. - - Args: - base_dir: Base directory to clean. Defaults to ./outputs/ - keep_latest: Number of recent outputs to keep - - Returns: - Number of directories removed - """ - outputs = list_outputs(base_dir) - - if len(outputs) <= keep_latest: - return 0 - - to_remove = outputs[keep_latest:] - for output_dir in to_remove: - shutil.rmtree(output_dir) - - return len(to_remove) - diff --git a/openrocketengine/plotting.py b/openrocketengine/plotting.py deleted file mode 100644 index 6df6ac9..0000000 --- a/openrocketengine/plotting.py +++ /dev/null @@ -1,1023 +0,0 @@ -"""Visualization module for OpenRocketEngine. - -Provides plotting functions for: -- Engine cross-section views -- Performance curves (Isp vs altitude, thrust vs altitude) -- Nozzle contour visualization -- Trade study plots -- Cycle comparison charts - -All plots use matplotlib with a consistent, professional style. -""" - -import matplotlib.pyplot as plt -import numpy as np -from beartype import beartype -from matplotlib.figure import Figure -from matplotlib.patches import PathPatch -from matplotlib.path import Path as MplPath -from numpy.typing import NDArray - -from openrocketengine.engine import ( - EngineGeometry, - EngineInputs, - EnginePerformance, - isp_at_altitude, - thrust_at_altitude, -) -from openrocketengine.isentropic import ( - area_ratio_from_mach, - mach_from_pressure_ratio, - thrust_coefficient, -) -from openrocketengine.nozzle import NozzleContour -from openrocketengine.units import pascals - -# ============================================================================= -# Plot Style Configuration -# ============================================================================= - -# Professional color palette -COLORS = { - "primary": "#2E86AB", # Steel blue - "secondary": "#A23B72", # Berry - "accent": "#F18F01", # Orange - "chamber": "#454545", # Dark gray for chamber walls - "fill": "#E8E8E8", # Light gray for fill - "grid": "#CCCCCC", # Grid lines - "text": "#333333", # Text color -} - -# Default figure size -DEFAULT_FIGSIZE = (12, 6) - - -def _setup_style() -> None: - """Configure matplotlib style for consistent appearance.""" - plt.rcParams.update( - { - "font.family": "sans-serif", - "font.sans-serif": ["Helvetica", "Arial", "DejaVu Sans"], - "font.size": 11, - "axes.titlesize": 14, - "axes.labelsize": 12, - "axes.linewidth": 1.2, - "axes.edgecolor": COLORS["text"], - "axes.labelcolor": COLORS["text"], - "xtick.labelsize": 10, - "ytick.labelsize": 10, - "xtick.color": COLORS["text"], - "ytick.color": COLORS["text"], - "legend.fontsize": 10, - "figure.titlesize": 16, - "grid.alpha": 0.5, - "grid.linewidth": 0.8, - } - ) - - -# ============================================================================= -# Engine Cross-Section Plot -# ============================================================================= - - -@beartype -def plot_engine_cross_section( - geometry: EngineGeometry, - contour: NozzleContour, - inputs: EngineInputs | None = None, - show_dimensions: bool = True, - show_centerline: bool = True, - figsize: tuple[float, float] = DEFAULT_FIGSIZE, - title: str | None = None, -) -> Figure: - """Plot a 2D cross-section of the engine chamber and nozzle. - - Creates a symmetric cross-section view showing: - - Chamber wall profile (top and bottom halves) - - Throat location - - Key dimensions (optional) - - Centerline (optional) - - Args: - geometry: Computed engine geometry - contour: Nozzle contour (can be just nozzle or full chamber) - inputs: Engine inputs (for title and additional info) - show_dimensions: Whether to annotate key dimensions - show_centerline: Whether to show the centerline - figsize: Figure size (width, height) in inches - title: Optional custom title - - Returns: - matplotlib Figure object - """ - _setup_style() - - fig, ax = plt.subplots(figsize=figsize) - - # Get contour data - x = contour.x - y = contour.y - - # Create symmetric contour (top and bottom) - x_full = np.concatenate([x, x[::-1]]) - y_full = np.concatenate([y, -y[::-1]]) - - # Create filled polygon for chamber wall - vertices = np.column_stack([x_full, y_full]) - codes = [MplPath.MOVETO] + [MplPath.LINETO] * (len(vertices) - 2) + [MplPath.CLOSEPOLY] - path = MplPath(vertices, codes) - patch = PathPatch( - path, - facecolor=COLORS["fill"], - edgecolor=COLORS["chamber"], - linewidth=2, - ) - ax.add_patch(patch) - - # Plot contour lines explicitly for clarity - ax.plot(x, y, color=COLORS["chamber"], linewidth=2, label="Chamber wall") - ax.plot(x, -y, color=COLORS["chamber"], linewidth=2) - - # Centerline - if show_centerline: - ax.axhline(y=0, color=COLORS["primary"], linestyle="--", linewidth=1, alpha=0.7) - ax.text( - x[-1] * 0.98, - 0, - "CL", - ha="right", - va="bottom", - fontsize=9, - color=COLORS["primary"], - ) - - # Mark throat location (minimum radius point) - throat_idx = np.argmin(y) - throat_x = x[throat_idx] - throat_y = y[throat_idx] - - ax.axvline(x=throat_x, color=COLORS["secondary"], linestyle=":", linewidth=1.5, alpha=0.7) - ax.plot(throat_x, throat_y, "o", color=COLORS["secondary"], markersize=6) - ax.plot(throat_x, -throat_y, "o", color=COLORS["secondary"], markersize=6) - - # Dimension annotations - if show_dimensions: - _add_dimension_annotations(ax, geometry, contour, x, y) - - # Set axis properties - ax.set_aspect("equal") - ax.set_xlabel("Axial Position (m)") - ax.set_ylabel("Radial Position (m)") - - # Add margin - x_margin = (x[-1] - x[0]) * 0.1 - y_max = max(y) * 1.3 - ax.set_xlim(x[0] - x_margin, x[-1] + x_margin) - ax.set_ylim(-y_max, y_max) - - # Grid - ax.grid(True, alpha=0.3, linestyle="-", linewidth=0.5) - - # Title - if title: - ax.set_title(title) - elif inputs and inputs.name: - ax.set_title(f"Engine Cross-Section: {inputs.name}") - else: - ax.set_title("Engine Cross-Section") - - fig.tight_layout() - return fig - - -def _add_dimension_annotations( - ax: plt.Axes, - geometry: EngineGeometry, - contour: NozzleContour, - x: NDArray[np.float64], - y: NDArray[np.float64], -) -> None: - """Add dimension annotations to the cross-section plot.""" - # Throat diameter - throat_idx = np.argmin(y) - throat_x = x[throat_idx] - throat_r = y[throat_idx] - - # Exit diameter - exit_r = y[-1] - exit_x = x[-1] - - # Annotation style - arrowprops = dict(arrowstyle="<->", color=COLORS["accent"], lw=1.5) - text_offset = 0.02 * (x[-1] - x[0]) - - # Throat diameter annotation - Dt_mm = geometry.throat_diameter.to("m").value * 1000 - ax.annotate( - "", - xy=(throat_x, throat_r), - xytext=(throat_x, -throat_r), - arrowprops=arrowprops, - ) - ax.text( - throat_x + text_offset, - 0, - f"Dt={Dt_mm:.1f}mm", - fontsize=9, - va="center", - color=COLORS["accent"], - ) - - # Exit diameter annotation - De_mm = geometry.exit_diameter.to("m").value * 1000 - ax.annotate( - "", - xy=(exit_x, exit_r), - xytext=(exit_x, -exit_r), - arrowprops=arrowprops, - ) - ax.text( - exit_x - text_offset, - exit_r * 0.5, - f"De={De_mm:.1f}mm", - fontsize=9, - va="center", - ha="right", - color=COLORS["accent"], - ) - - # Expansion ratio annotation - eps = geometry.expansion_ratio - ax.text( - exit_x - text_offset, - -exit_r * 0.5, - f"ε={eps:.1f}", - fontsize=9, - va="center", - ha="right", - color=COLORS["text"], - ) - - -# ============================================================================= -# Nozzle Contour Plot -# ============================================================================= - - -@beartype -def plot_nozzle_contour( - contour: NozzleContour, - figsize: tuple[float, float] = (10, 6), - title: str | None = None, - units: str = "mm", -) -> Figure: - """Plot a nozzle contour profile. - - Shows just the nozzle contour (single line, not symmetric view). - Useful for verifying contour generation and CAD export. - - Args: - contour: Nozzle contour to plot - figsize: Figure size - title: Optional title - units: Display units ("m" or "mm") - - Returns: - matplotlib Figure - """ - _setup_style() - - fig, ax = plt.subplots(figsize=figsize) - - if units == "mm": - x = contour.x * 1000 - y = contour.y * 1000 - xlabel = "Axial Position (mm)" - ylabel = "Radius (mm)" - else: - x = contour.x - y = contour.y - xlabel = "Axial Position (m)" - ylabel = "Radius (m)" - - ax.plot(x, y, color=COLORS["primary"], linewidth=2, label="Contour") - ax.fill_between(x, 0, y, alpha=0.2, color=COLORS["primary"]) - - # Mark throat - throat_idx = np.argmin(y) - ax.axvline(x=x[throat_idx], color=COLORS["secondary"], linestyle=":", alpha=0.7) - ax.plot(x[throat_idx], y[throat_idx], "o", color=COLORS["secondary"], markersize=8) - ax.text( - x[throat_idx], - y[throat_idx] * 1.1, - "Throat", - ha="center", - fontsize=10, - color=COLORS["secondary"], - ) - - ax.set_xlabel(xlabel) - ax.set_ylabel(ylabel) - ax.set_title(title or f"Nozzle Contour ({contour.contour_type})") - ax.grid(True, alpha=0.3) - ax.set_ylim(bottom=0) - - fig.tight_layout() - return fig - - -# ============================================================================= -# Performance vs Altitude -# ============================================================================= - - -@beartype -def plot_performance_vs_altitude( - inputs: EngineInputs, - performance: EnginePerformance, - geometry: EngineGeometry, - max_altitude_km: float | int = 100.0, - num_points: int = 100, - figsize: tuple[float, float] = DEFAULT_FIGSIZE, -) -> Figure: - """Plot thrust and Isp vs altitude. - - Shows how engine performance changes with altitude due to - decreasing ambient pressure. - - Args: - inputs: Engine inputs - performance: Computed performance - geometry: Computed geometry - max_altitude_km: Maximum altitude to plot (km) - num_points: Number of altitude points - figsize: Figure size - - Returns: - matplotlib Figure with two subplots - """ - _setup_style() - - # Generate altitude array - altitudes_km = np.linspace(0, max_altitude_km, num_points) - - # Simple exponential atmosphere model - # P = P0 * exp(-h/H) where H ≈ 8.5 km - P0 = 101325 # Pa - H = 8500 # m - pressures_Pa = P0 * np.exp(-altitudes_km * 1000 / H) - - # Calculate thrust and Isp at each altitude - thrust_vals = np.zeros(num_points) - isp_vals = np.zeros(num_points) - - for i, pa in enumerate(pressures_Pa): - pa_qty = pascals(pa) - thrust_vals[i] = thrust_at_altitude(inputs, performance, geometry, pa_qty).to("kN").value - isp_vals[i] = isp_at_altitude(inputs, performance, pa_qty).value - - # Create figure with two subplots - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize) - - # Thrust plot - ax1.plot(altitudes_km, thrust_vals, color=COLORS["primary"], linewidth=2) - ax1.axhline( - y=inputs.thrust.to("kN").value, - color=COLORS["secondary"], - linestyle="--", - alpha=0.7, - label="Design thrust (SL)", - ) - ax1.set_xlabel("Altitude (km)") - ax1.set_ylabel("Thrust (kN)") - ax1.set_title("Thrust vs Altitude") - ax1.grid(True, alpha=0.3) - ax1.legend() - ax1.set_xlim(0, max_altitude_km) - - # Isp plot - ax2.plot(altitudes_km, isp_vals, color=COLORS["accent"], linewidth=2) - ax2.axhline( - y=performance.isp.value, - color=COLORS["secondary"], - linestyle="--", - alpha=0.7, - label="Isp (SL)", - ) - ax2.axhline( - y=performance.isp_vac.value, - color=COLORS["primary"], - linestyle=":", - alpha=0.7, - label="Isp (Vac)", - ) - ax2.set_xlabel("Altitude (km)") - ax2.set_ylabel("Specific Impulse (s)") - ax2.set_title("Isp vs Altitude") - ax2.grid(True, alpha=0.3) - ax2.legend() - ax2.set_xlim(0, max_altitude_km) - - # Overall title - name = inputs.name or "Engine" - fig.suptitle(f"Altitude Performance: {name}", fontsize=14, y=1.02) - - fig.tight_layout() - return fig - - -# ============================================================================= -# Trade Study Plots -# ============================================================================= - - -@beartype -def plot_isp_vs_expansion_ratio( - gamma: float | int = 1.2, - pc_pe_range: tuple[float, float] = (10, 200), - num_points: int = 100, - figsize: tuple[float, float] = (10, 6), -) -> Figure: - """Plot theoretical Isp vs expansion ratio for different pressure ratios. - - Useful for understanding nozzle design trade-offs. - - Args: - gamma: Ratio of specific heats - pc_pe_range: Range of chamber-to-exit pressure ratios - num_points: Number of points - figsize: Figure size - - Returns: - matplotlib Figure - """ - _setup_style() - - fig, ax = plt.subplots(figsize=figsize) - - # Different pressure ratios to plot - pc_pe_values = [20, 50, 100, 150, 200] - - for pc_pe in pc_pe_values: - # Calculate exit Mach - Me = mach_from_pressure_ratio(pc_pe, gamma) - eps = area_ratio_from_mach(Me, gamma) - - # Calculate Cf for perfectly expanded nozzle (pa = pe) - pe_pc = 1.0 / pc_pe - Cf = thrust_coefficient(gamma, pe_pc, pe_pc, eps) - - # Plot point - ax.scatter([eps], [Cf], s=100, zorder=5) - ax.annotate( - f"pc/pe={pc_pe}", - xy=(eps, Cf), - xytext=(10, 5), - textcoords="offset points", - fontsize=9, - ) - - # Generate curve for range of expansion ratios - eps_range = np.linspace(2, 100, 200) - Cf_optimal = np.zeros_like(eps_range) - - for i, eps in enumerate(eps_range): - # Find pressure ratio that gives this expansion ratio - Me = 2.0 # Initial guess - for _ in range(50): - eps_calc = area_ratio_from_mach(Me, gamma) - if abs(eps_calc - eps) < 0.01: - break - Me += (eps - eps_calc) * 0.1 - - # Cf for this expansion ratio (optimally expanded) - pc_pe = (1 + (gamma - 1) / 2 * Me**2) ** (gamma / (gamma - 1)) - pe_pc = 1.0 / pc_pe - Cf_optimal[i] = thrust_coefficient(gamma, pe_pc, pe_pc, eps) - - ax.plot(eps_range, Cf_optimal, color=COLORS["primary"], linewidth=2, label="Optimal Cf") - - ax.set_xlabel("Expansion Ratio (Ae/At)") - ax.set_ylabel("Thrust Coefficient (Cf)") - ax.set_title(f"Thrust Coefficient vs Expansion Ratio (γ={gamma})") - ax.grid(True, alpha=0.3) - ax.legend() - - fig.tight_layout() - return fig - - -# ============================================================================= -# Summary Dashboard -# ============================================================================= - - -@beartype -def plot_engine_dashboard( - inputs: EngineInputs, - performance: EnginePerformance, - geometry: EngineGeometry, - contour: NozzleContour, - figsize: tuple[float, float] = (16, 10), -) -> Figure: - """Create a comprehensive dashboard with engine summary. - - Includes: - - Engine cross-section - - Performance vs altitude - - Key parameters table - - Args: - inputs: Engine inputs - performance: Computed performance - geometry: Computed geometry - contour: Nozzle contour - figsize: Figure size - - Returns: - matplotlib Figure - """ - _setup_style() - - fig = plt.figure(figsize=figsize) - - # Create grid layout - gs = fig.add_gridspec(2, 3, height_ratios=[1.2, 1], width_ratios=[1.5, 1, 1]) - - # Cross-section (spans two columns) - ax_cross = fig.add_subplot(gs[0, :2]) - - # Get contour data - x = contour.x - y = contour.y - - # Plot symmetric contour - ax_cross.fill_between(x, y, -y, color=COLORS["fill"], alpha=0.8) - ax_cross.plot(x, y, color=COLORS["chamber"], linewidth=2) - ax_cross.plot(x, -y, color=COLORS["chamber"], linewidth=2) - ax_cross.axhline(y=0, color=COLORS["primary"], linestyle="--", linewidth=1, alpha=0.5) - - # Mark throat - throat_idx = np.argmin(y) - ax_cross.axvline(x=x[throat_idx], color=COLORS["secondary"], linestyle=":", alpha=0.7) - - ax_cross.set_aspect("equal") - ax_cross.set_xlabel("Axial Position (m)") - ax_cross.set_ylabel("Radial Position (m)") - ax_cross.set_title("Engine Cross-Section") - ax_cross.grid(True, alpha=0.3) - - # Parameters table (right side of top row) - ax_params = fig.add_subplot(gs[0, 2]) - ax_params.axis("off") - - # Create parameter text - name = inputs.name or "Unnamed Engine" - params_text = f""" - {name} - ───────────────────── - PERFORMANCE - Thrust (SL): {inputs.thrust.to('kN').value:.2f} kN - Isp (SL): {performance.isp.value:.1f} s - Isp (Vac): {performance.isp_vac.value:.1f} s - C*: {performance.cstar.value:.0f} m/s - - MASS FLOW - Total: {performance.mdot.value:.3f} kg/s - O/F Ratio: {inputs.mixture_ratio:.2f} - - GEOMETRY - Dt: {geometry.throat_diameter.to('m').value*1000:.1f} mm - De: {geometry.exit_diameter.to('m').value*1000:.1f} mm - ε (Ae/At): {geometry.expansion_ratio:.1f} - - CONDITIONS - Pc: {inputs.chamber_pressure.to('MPa').value:.2f} MPa - Tc: {inputs.chamber_temp.to('K').value:.0f} K - γ: {inputs.gamma:.3f} - """ - - ax_params.text( - 0.1, - 0.95, - params_text, - transform=ax_params.transAxes, - fontsize=10, - fontfamily="monospace", - verticalalignment="top", - bbox=dict(boxstyle="round", facecolor="white", edgecolor=COLORS["grid"]), - ) - - # Altitude performance plots (bottom row) - altitudes_km = np.linspace(0, 80, 50) - P0 = 101325 - H = 8500 - pressures_Pa = P0 * np.exp(-altitudes_km * 1000 / H) - - thrust_vals = np.zeros(len(altitudes_km)) - isp_vals = np.zeros(len(altitudes_km)) - - for i, pa in enumerate(pressures_Pa): - pa_qty = pascals(pa) - thrust_vals[i] = thrust_at_altitude(inputs, performance, geometry, pa_qty).to("kN").value - isp_vals[i] = isp_at_altitude(inputs, performance, pa_qty).value - - # Thrust vs altitude - ax_thrust = fig.add_subplot(gs[1, 0]) - ax_thrust.plot(altitudes_km, thrust_vals, color=COLORS["primary"], linewidth=2) - ax_thrust.set_xlabel("Altitude (km)") - ax_thrust.set_ylabel("Thrust (kN)") - ax_thrust.set_title("Thrust vs Altitude") - ax_thrust.grid(True, alpha=0.3) - - # Isp vs altitude - ax_isp = fig.add_subplot(gs[1, 1]) - ax_isp.plot(altitudes_km, isp_vals, color=COLORS["accent"], linewidth=2) - ax_isp.axhline(y=performance.isp_vac.value, color=COLORS["secondary"], linestyle="--", alpha=0.7) - ax_isp.set_xlabel("Altitude (km)") - ax_isp.set_ylabel("Isp (s)") - ax_isp.set_title("Specific Impulse vs Altitude") - ax_isp.grid(True, alpha=0.3) - - # Nozzle contour detail - ax_nozzle = fig.add_subplot(gs[1, 2]) - x_mm = contour.x * 1000 - y_mm = contour.y * 1000 - ax_nozzle.plot(x_mm, y_mm, color=COLORS["primary"], linewidth=2) - ax_nozzle.fill_between(x_mm, 0, y_mm, alpha=0.2, color=COLORS["primary"]) - ax_nozzle.set_xlabel("x (mm)") - ax_nozzle.set_ylabel("r (mm)") - ax_nozzle.set_title(f"Nozzle Contour ({contour.contour_type})") - ax_nozzle.grid(True, alpha=0.3) - ax_nozzle.set_ylim(bottom=0) - - fig.suptitle(f"Engine Design Summary: {name}", fontsize=16, fontweight="bold", y=0.98) - fig.tight_layout(rect=[0, 0, 1, 0.96]) - - return fig - - -# ============================================================================= -# Mass Breakdown Plot -# ============================================================================= - - -@beartype -def plot_mass_breakdown( - masses: dict[str, float], - title: str = "Mass Breakdown", - figsize: tuple[float, float] = (10, 8), -) -> Figure: - """Create a mass breakdown pie chart with bar chart. - - Args: - masses: Dictionary mapping component names to masses (kg) - title: Plot title - figsize: Figure size - - Returns: - matplotlib Figure - """ - _setup_style() - - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize) - - labels = list(masses.keys()) - values = list(masses.values()) - total = sum(values) - - # Color palette - colors = plt.cm.Set2(np.linspace(0, 1, len(labels))) - - # Pie chart - ax1.pie( - values, - labels=labels, - autopct=lambda pct: f"{pct:.1f}%\n({pct*total/100:.0f} kg)", - colors=colors, - startangle=90, - pctdistance=0.75, - ) - ax1.set_title("Distribution") - - # Bar chart - bars = ax2.barh(labels, values, color=colors) - ax2.set_xlabel("Mass (kg)") - ax2.set_title("Component Masses") - - # Add value labels on bars - for bar, val in zip(bars, values, strict=True): - ax2.text( - val + total * 0.01, - bar.get_y() + bar.get_height() / 2, - f"{val:.0f} kg", - va="center", - fontsize=9, - ) - - ax2.set_xlim(0, max(values) * 1.2) - - fig.suptitle(f"{title} (Total: {total:,.0f} kg)", fontsize=14, fontweight="bold") - fig.tight_layout() - - return fig - - -# ============================================================================= -# Cycle Comparison Plots -# ============================================================================= - - -@beartype -def plot_cycle_comparison_bars( - cycle_data: list[dict], - metrics: list[str] | None = None, - figsize: tuple[float, float] = (14, 8), - title: str = "Engine Cycle Comparison", -) -> Figure: - """Create multi-metric bar chart comparing engine cycles. - - Args: - cycle_data: List of dicts with keys 'name' and metric values. - Example: [ - {'name': 'Pressure-Fed', 'net_isp': 320, 'efficiency': 1.0, ...}, - {'name': 'Gas Generator', 'net_isp': 310, 'efficiency': 0.95, ...}, - ] - metrics: List of metrics to plot. Defaults to common cycle metrics. - figsize: Figure size - title: Plot title - - Returns: - matplotlib Figure - """ - _setup_style() - - if metrics is None: - metrics = ["net_isp", "efficiency", "tank_pressure_MPa", "pump_power_kW"] - - # Filter to only metrics that exist in the data - available_metrics = [] - for m in metrics: - if all(m in d for d in cycle_data): - available_metrics.append(m) - - if not available_metrics: - raise ValueError("No valid metrics found in cycle_data") - - n_cycles = len(cycle_data) - n_metrics = len(available_metrics) - - # Metric display names and units - metric_info = { - "net_isp": ("Net Isp", "s"), - "efficiency": ("Cycle Efficiency", "%"), - "tank_pressure_MPa": ("Tank Pressure", "MPa"), - "pump_power_kW": ("Pump Power", "kW"), - "turbine_power_kW": ("Turbine Power", "kW"), - } - - fig, axes = plt.subplots(1, n_metrics, figsize=figsize) - if n_metrics == 1: - axes = [axes] - - cycle_names = [d["name"] for d in cycle_data] - colors = [COLORS["primary"], COLORS["secondary"], COLORS["accent"], "#48A9A6", "#4B3F72"] - - for ax, metric in zip(axes, available_metrics, strict=True): - values = [d.get(metric, 0) for d in cycle_data] - - # Scale efficiency to percentage - if metric == "efficiency": - values = [v * 100 for v in values] - - bars = ax.bar(cycle_names, values, color=colors[:n_cycles], edgecolor="white", linewidth=1.5) - - # Add value labels on bars - for bar, val in zip(bars, values, strict=True): - height = bar.get_height() - ax.annotate( - f"{val:.1f}", - xy=(bar.get_x() + bar.get_width() / 2, height), - xytext=(0, 3), - textcoords="offset points", - ha="center", - va="bottom", - fontsize=10, - fontweight="bold", - ) - - # Axis formatting - display_name, unit = metric_info.get(metric, (metric, "")) - ax.set_ylabel(f"{display_name} ({unit})" if unit else display_name) - ax.set_title(display_name, fontsize=12, fontweight="bold") - ax.tick_params(axis="x", rotation=15) - ax.set_ylim(0, max(values) * 1.2 if max(values) > 0 else 1) - ax.grid(axis="y", alpha=0.3) - - fig.suptitle(title, fontsize=16, fontweight="bold", y=1.02) - fig.tight_layout() - - return fig - - -@beartype -def plot_cycle_radar( - cycle_data: list[dict], - metrics: list[str] | None = None, - figsize: tuple[float, float] = (10, 10), - title: str = "Cycle Comparison Radar", -) -> Figure: - """Create radar/spider chart comparing cycles across normalized dimensions. - - All metrics are normalized to 0-1 scale for comparison. - - Args: - cycle_data: List of dicts with 'name' and metric values - metrics: Metrics to include in radar. Defaults to standard set. - figsize: Figure size - title: Plot title - - Returns: - matplotlib Figure - """ - _setup_style() - - if metrics is None: - metrics = ["net_isp", "efficiency", "simplicity", "tank_mass_factor"] - - # Filter to available metrics - available_metrics = [] - for m in metrics: - if all(m in d for d in cycle_data): - available_metrics.append(m) - - if len(available_metrics) < 3: - raise ValueError("Need at least 3 metrics for radar chart") - - n_metrics = len(available_metrics) - - # Metric display names - metric_names = { - "net_isp": "Performance\n(Isp)", - "efficiency": "Efficiency", - "simplicity": "Simplicity", - "tank_mass_factor": "Low Tank\nPressure", - "reliability": "Reliability", - "trl": "Maturity\n(TRL)", - } - - # Normalize all metrics to 0-1 scale - normalized_data = [] - for d in cycle_data: - norm_d = {"name": d["name"]} - for m in available_metrics: - values = [cd[m] for cd in cycle_data] - min_val, max_val = min(values), max(values) - if max_val > min_val: - norm_d[m] = (d[m] - min_val) / (max_val - min_val) - else: - norm_d[m] = 1.0 - normalized_data.append(norm_d) - - # Setup radar chart - angles = np.linspace(0, 2 * np.pi, n_metrics, endpoint=False).tolist() - angles += angles[:1] # Complete the loop - - fig, ax = plt.subplots(figsize=figsize, subplot_kw=dict(polar=True)) - - colors = [COLORS["primary"], COLORS["secondary"], COLORS["accent"], "#48A9A6", "#4B3F72"] - - for i, d in enumerate(normalized_data): - values = [d[m] for m in available_metrics] - values += values[:1] # Complete the loop - - ax.plot(angles, values, "o-", linewidth=2, color=colors[i % len(colors)], label=d["name"]) - ax.fill(angles, values, alpha=0.25, color=colors[i % len(colors)]) - - # Set labels - labels = [metric_names.get(m, m) for m in available_metrics] - ax.set_xticks(angles[:-1]) - ax.set_xticklabels(labels, fontsize=11) - - # Radial limits - ax.set_ylim(0, 1.1) - ax.set_yticks([0.25, 0.5, 0.75, 1.0]) - ax.set_yticklabels(["25%", "50%", "75%", "100%"], fontsize=8, alpha=0.7) - - ax.legend(loc="upper right", bbox_to_anchor=(1.3, 1.0), fontsize=11) - ax.set_title(title, fontsize=14, fontweight="bold", y=1.08) - - fig.tight_layout() - - return fig - - -@beartype -def plot_cycle_tradeoff( - cycle_data: list[dict], - x_metric: str = "net_isp", - y_metric: str = "efficiency", - size_metric: str | None = None, - figsize: tuple[float, float] = (10, 8), - title: str = "Cycle Trade Space", -) -> Figure: - """Create scatter plot showing cycle trade-offs. - - Plot cycles on 2D trade space with optional bubble size for third dimension. - - Args: - cycle_data: List of dicts with 'name' and metric values - x_metric: Metric for x-axis - y_metric: Metric for y-axis - size_metric: Optional metric for bubble size - figsize: Figure size - title: Plot title - - Returns: - matplotlib Figure - """ - _setup_style() - - # Metric display info - metric_info = { - "net_isp": ("Net Isp", "s"), - "efficiency": ("Cycle Efficiency", ""), - "tank_pressure_MPa": ("Tank Pressure", "MPa"), - "pump_power_kW": ("Pump Power", "kW"), - "simplicity": ("Simplicity Score", ""), - "complexity": ("Complexity Score", ""), - } - - fig, ax = plt.subplots(figsize=figsize) - - x_vals = [d[x_metric] for d in cycle_data] - y_vals = [d[y_metric] for d in cycle_data] - - # Scale efficiency to percentage for display - if y_metric == "efficiency": - y_vals = [v * 100 for v in y_vals] - - colors = [COLORS["primary"], COLORS["secondary"], COLORS["accent"], "#48A9A6", "#4B3F72"] - - if size_metric and all(size_metric in d for d in cycle_data): - sizes = [d[size_metric] for d in cycle_data] - # Normalize sizes for display - max_size = max(sizes) if max(sizes) > 0 else 1 - normalized_sizes = [500 + 1500 * (s / max_size) for s in sizes] - else: - normalized_sizes = [800] * len(cycle_data) - - for i, (x, y, s, d) in enumerate(zip(x_vals, y_vals, normalized_sizes, cycle_data, strict=True)): - ax.scatter( - x, y, s=s, c=colors[i % len(colors)], alpha=0.7, edgecolors="white", linewidth=2, zorder=5 - ) - # Label - ax.annotate( - d["name"], - xy=(x, y), - xytext=(10, 10), - textcoords="offset points", - fontsize=11, - fontweight="bold", - arrowprops=dict(arrowstyle="-", color="gray", alpha=0.5), - ) - - # Axis formatting - x_name, x_unit = metric_info.get(x_metric, (x_metric, "")) - y_name, y_unit = metric_info.get(y_metric, (y_metric, "")) - - ax.set_xlabel(f"{x_name} ({x_unit})" if x_unit else x_name, fontsize=12) - ax.set_ylabel(f"{y_name} ({y_unit})" if y_unit else y_name, fontsize=12) - - # Add quadrant annotations - x_mid = (max(x_vals) + min(x_vals)) / 2 - y_mid = (max(y_vals) + min(y_vals)) / 2 - - ax.axvline(x=x_mid, color="gray", linestyle="--", alpha=0.3) - ax.axhline(y=y_mid, color="gray", linestyle="--", alpha=0.3) - - # Expand limits slightly - x_range = max(x_vals) - min(x_vals) - y_range = max(y_vals) - min(y_vals) - ax.set_xlim(min(x_vals) - 0.1 * x_range, max(x_vals) + 0.15 * x_range) - ax.set_ylim(min(y_vals) - 0.1 * y_range, max(y_vals) + 0.15 * y_range) - - ax.grid(True, alpha=0.3) - ax.set_title(title, fontsize=14, fontweight="bold") - - # Add size legend if applicable - if size_metric and all(size_metric in d for d in cycle_data): - size_name = metric_info.get(size_metric, (size_metric, ""))[0] - ax.text( - 0.02, - 0.02, - f"Bubble size: {size_name}", - transform=ax.transAxes, - fontsize=9, - alpha=0.7, - ) - - fig.tight_layout() - - return fig diff --git a/openrocketengine/propellants.py b/openrocketengine/propellants.py deleted file mode 100644 index 7483a8f..0000000 --- a/openrocketengine/propellants.py +++ /dev/null @@ -1,475 +0,0 @@ -"""Propellant thermochemistry module for OpenRocketEngine. - -This module provides combustion thermochemistry calculations using NASA CEA -via RocketCEA. It computes chamber temperature, molecular weight, gamma, -and other properties needed for rocket engine performance analysis. - -Example: - >>> from openrocketengine.propellants import get_combustion_properties - >>> props = get_combustion_properties( - ... oxidizer="LOX", - ... fuel="RP1", - ... mixture_ratio=2.7, - ... chamber_pressure_pa=7e6, - ... ) - >>> print(f"Tc = {props.chamber_temp_k:.0f} K") -""" - -from dataclasses import dataclass -from typing import Literal - -from beartype import beartype -from rocketcea.cea_obj import CEA_Obj - -# ============================================================================= -# Data Structures -# ============================================================================= - - -@beartype -@dataclass(frozen=True, slots=True) -class CombustionProperties: - """Thermochemical properties from combustion analysis. - - These properties are needed to compute rocket engine performance - using isentropic flow equations. - - Attributes: - chamber_temp_k: Adiabatic flame temperature in chamber [K] - molecular_weight: Mean molecular weight of combustion products [kg/kmol] - gamma: Ratio of specific heats (Cp/Cv) [-] - specific_heat_cp: Specific heat at constant pressure [J/(kg·K)] - characteristic_velocity: Theoretical c* [m/s] - oxidizer: Oxidizer name - fuel: Fuel name - mixture_ratio: Oxidizer-to-fuel mass ratio [-] - chamber_pressure_pa: Chamber pressure [Pa] - source: Data source ("rocketcea" or "database") - """ - - chamber_temp_k: float | int - molecular_weight: float | int - gamma: float | int - specific_heat_cp: float | int - characteristic_velocity: float | int - oxidizer: str - fuel: str - mixture_ratio: float | int - chamber_pressure_pa: float | int - source: str - - -# ============================================================================= -# Propellant Name Mapping -# ============================================================================= - -# Map common names to RocketCEA names -OXIDIZER_NAMES: dict[str, str] = { - "LOX": "LOX", - "LO2": "LOX", - "O2": "LOX", - "OXYGEN": "LOX", - "N2O4": "N2O4", - "NTO": "N2O4", - "N2O": "N2O", - "NITROUS": "N2O", - "NITROUSOXIDE": "N2O", - "H2O2": "H2O2", - "HTP": "H2O2", - "PEROXIDE": "H2O2", - "MON25": "MON25", - "MON3": "MON3", - "IRFNA": "IRFNA", - "RFNA": "IRFNA", - "CLF5": "CLF5", - "F2": "F2", - "FLUORINE": "F2", -} - -FUEL_NAMES: dict[str, str] = { - "LH2": "LH2", - "H2": "LH2", - "HYDROGEN": "LH2", - "RP1": "RP1", - "RP-1": "RP1", - "KEROSENE": "RP1", - "JET-A": "Jet-A", - "JETA": "Jet-A", - "CH4": "CH4", - "METHANE": "CH4", - "LCH4": "CH4", - "C2H5OH": "Ethanol", - "ETHANOL": "Ethanol", - "C3H8O": "IPA", - "IPA": "IPA", - "ISOPROPANOL": "IPA", - "MMH": "MMH", - "UDMH": "UDMH", - "N2H4": "N2H4", - "HYDRAZINE": "N2H4", - "A50": "A-50", - "A-50": "A-50", - "AEROZINE50": "A-50", -} - - -def _normalize_propellant_name(name: str, is_oxidizer: bool) -> str: - """Normalize propellant name to RocketCEA format.""" - normalized = name.upper().replace(" ", "").replace("-", "") - lookup = OXIDIZER_NAMES if is_oxidizer else FUEL_NAMES - - if normalized in lookup: - return lookup[normalized] - - # Try original name (RocketCEA might accept it) - return name - - -# ============================================================================= -# Fallback Database (When CEA Not Available) -# ============================================================================= - -# Tabulated data for common propellant combinations at typical conditions -# Format: (oxidizer, fuel): {MR: (Tc_K, MW, gamma, cstar_m/s)} -# Data at approximately 1000 psia (6.9 MPa) chamber pressure -# Sources: Sutton & Biblarz, various NASA reports - -_PROPELLANT_DATABASE: dict[tuple[str, str], dict[float, tuple[float, float, float, float]]] = { - ("LOX", "LH2"): { - 4.0: (3015, 12.0, 1.20, 2290), - 5.0: (3250, 13.5, 1.18, 2360), - 6.0: (3400, 14.8, 1.16, 2390), - 7.0: (3470, 16.0, 1.15, 2380), - 8.0: (3450, 17.0, 1.14, 2340), - }, - ("LOX", "RP1"): { - 2.0: (3450, 21.5, 1.21, 1750), - 2.3: (3550, 22.5, 1.19, 1780), - 2.5: (3600, 23.0, 1.18, 1790), - 2.7: (3620, 23.3, 1.17, 1800), - 3.0: (3580, 24.0, 1.16, 1780), - }, - ("LOX", "CH4"): { - 2.5: (3400, 19.5, 1.19, 1820), - 3.0: (3530, 20.5, 1.17, 1850), - 3.2: (3560, 21.0, 1.16, 1860), - 3.5: (3570, 21.5, 1.15, 1850), - 4.0: (3520, 22.5, 1.14, 1820), - }, - ("LOX", "Ethanol"): { - 1.0: (2800, 20.0, 1.24, 1650), - 1.3: (3100, 21.0, 1.22, 1720), - 1.5: (3250, 21.5, 1.20, 1750), - 1.8: (3350, 22.0, 1.19, 1760), - 2.0: (3380, 22.5, 1.18, 1750), - }, - ("N2O4", "MMH"): { - 1.5: (3000, 21.0, 1.24, 1680), - 1.8: (3150, 21.5, 1.22, 1720), - 2.0: (3220, 22.0, 1.21, 1730), - 2.2: (3260, 22.5, 1.20, 1730), - 2.5: (3250, 23.0, 1.19, 1710), - }, - ("N2O4", "UDMH"): { - 1.8: (3050, 21.5, 1.23, 1690), - 2.0: (3150, 22.0, 1.22, 1710), - 2.2: (3200, 22.5, 1.21, 1720), - 2.5: (3220, 23.0, 1.20, 1710), - 2.8: (3180, 23.5, 1.19, 1690), - }, - ("N2O4", "A-50"): { - 1.5: (3000, 21.0, 1.24, 1680), - 1.8: (3120, 21.5, 1.22, 1710), - 2.0: (3180, 22.0, 1.21, 1720), - 2.2: (3210, 22.5, 1.20, 1720), - 2.6: (3180, 23.0, 1.19, 1700), - }, - ("N2O", "Ethanol"): { - 3.0: (2800, 24.0, 1.22, 1550), - 4.0: (2950, 25.0, 1.20, 1580), - 5.0: (3000, 26.0, 1.19, 1570), - 6.0: (2980, 27.0, 1.18, 1540), - }, - ("H2O2", "RP1"): { - 6.0: (2700, 22.5, 1.21, 1580), - 7.0: (2750, 23.0, 1.20, 1590), - 7.5: (2760, 23.5, 1.19, 1580), - 8.0: (2750, 24.0, 1.19, 1570), - }, -} - - -def _interpolate_database( - oxidizer: str, fuel: str, mixture_ratio: float -) -> tuple[float, float, float, float] | None: - """Interpolate propellant database for given mixture ratio.""" - key = (oxidizer, fuel) - if key not in _PROPELLANT_DATABASE: - return None - - data = _PROPELLANT_DATABASE[key] - mrs = sorted(data.keys()) - - # Clamp to available range - if mixture_ratio <= mrs[0]: - return data[mrs[0]] - if mixture_ratio >= mrs[-1]: - return data[mrs[-1]] - - # Find bracketing values - for i in range(len(mrs) - 1): - if mrs[i] <= mixture_ratio <= mrs[i + 1]: - mr_low, mr_high = mrs[i], mrs[i + 1] - break - else: - return data[mrs[-1]] - - # Linear interpolation - t = (mixture_ratio - mr_low) / (mr_high - mr_low) - low = data[mr_low] - high = data[mr_high] - - return ( - low[0] + t * (high[0] - low[0]), # Tc - low[1] + t * (high[1] - low[1]), # MW - low[2] + t * (high[2] - low[2]), # gamma - low[3] + t * (high[3] - low[3]), # cstar - ) - - -# ============================================================================= -# RocketCEA Integration -# ============================================================================= - - -def _get_properties_from_cea( - oxidizer: str, - fuel: str, - mixture_ratio: float, - chamber_pressure_pa: float, -) -> CombustionProperties: - """Get combustion properties using RocketCEA.""" - - # Convert pressure to psia (RocketCEA default) - pc_psia = chamber_pressure_pa / 6894.76 - - # Normalize propellant names - ox_name = _normalize_propellant_name(oxidizer, is_oxidizer=True) - fuel_name = _normalize_propellant_name(fuel, is_oxidizer=False) - - # Create CEA object - cea = CEA_Obj(oxName=ox_name, fuelName=fuel_name) - - # Get chamber properties - # Note: RocketCEA returns (Mw, gamma) from get_Chamber_MolWt_gamma - Tc = cea.get_Tcomb(Pc=pc_psia, MR=mixture_ratio) # Chamber temp in R - Tc_K = Tc * 5 / 9 # Convert Rankine to Kelvin - - mw_gamma = cea.get_Chamber_MolWt_gamma(Pc=pc_psia, MR=mixture_ratio, eps=1.0) - MW = mw_gamma[0] # Molecular weight - gamma = mw_gamma[1] # Gamma - - # Get c* in ft/s, convert to m/s - cstar_fts = cea.get_Cstar(Pc=pc_psia, MR=mixture_ratio) - cstar_ms = cstar_fts * 0.3048 - - # Calculate Cp from gamma and MW - R_universal = 8314.46 # J/(kmol·K) - R_specific = R_universal / MW # J/(kg·K) - Cp = gamma * R_specific / (gamma - 1) - - return CombustionProperties( - chamber_temp_k=Tc_K, - molecular_weight=MW, - gamma=gamma, - specific_heat_cp=Cp, - characteristic_velocity=cstar_ms, - oxidizer=oxidizer, - fuel=fuel, - mixture_ratio=mixture_ratio, - chamber_pressure_pa=chamber_pressure_pa, - source="rocketcea", - ) - - -def _get_properties_from_database( - oxidizer: str, - fuel: str, - mixture_ratio: float, - chamber_pressure_pa: float, -) -> CombustionProperties: - """Get combustion properties from built-in database.""" - # Normalize names - ox_name = _normalize_propellant_name(oxidizer, is_oxidizer=True) - fuel_name = _normalize_propellant_name(fuel, is_oxidizer=False) - - result = _interpolate_database(ox_name, fuel_name, mixture_ratio) - - if result is None: - available = list(_PROPELLANT_DATABASE.keys()) - raise ValueError( - f"Propellant combination ({ox_name}, {fuel_name}) not in database. " - f"Available combinations: {available}. " - f"Install RocketCEA for arbitrary propellant combinations: pip install rocketcea" - ) - - Tc_K, MW, gamma, cstar = result - - # Calculate Cp - R_universal = 8314.46 - R_specific = R_universal / MW - Cp = gamma * R_specific / (gamma - 1) - - return CombustionProperties( - chamber_temp_k=Tc_K, - molecular_weight=MW, - gamma=gamma, - specific_heat_cp=Cp, - characteristic_velocity=cstar, - oxidizer=oxidizer, - fuel=fuel, - mixture_ratio=mixture_ratio, - chamber_pressure_pa=chamber_pressure_pa, - source="database", - ) - - -# ============================================================================= -# Public API -# ============================================================================= - - -@beartype -def get_combustion_properties( - oxidizer: str, - fuel: str, - mixture_ratio: float, - chamber_pressure_pa: float, - use_cea: bool = True, -) -> CombustionProperties: - """Get combustion thermochemistry properties for a propellant combination. - - This function returns the thermochemical properties needed for rocket engine - performance calculations. When RocketCEA is installed and use_cea=True, - it uses NASA CEA for accurate equilibrium calculations. Otherwise, it falls - back to a built-in database of common propellant combinations. - - Args: - oxidizer: Oxidizer name (e.g., "LOX", "N2O4", "N2O", "H2O2") - fuel: Fuel name (e.g., "RP1", "LH2", "CH4", "Ethanol", "MMH") - mixture_ratio: Oxidizer-to-fuel mass ratio (O/F) - chamber_pressure_pa: Chamber pressure in Pascals - use_cea: If True and RocketCEA is installed, use CEA. Otherwise use database. - - Returns: - CombustionProperties containing Tc, MW, gamma, Cp, c* - - Raises: - ValueError: If propellant combination is not available in database - and RocketCEA is not installed - - Example: - >>> props = get_combustion_properties( - ... oxidizer="LOX", - ... fuel="RP1", - ... mixture_ratio=2.7, - ... chamber_pressure_pa=7e6, - ... ) - >>> print(f"Tc = {props.chamber_temp_k:.0f} K, gamma = {props.gamma:.3f}") - """ - if use_cea: - return _get_properties_from_cea( - oxidizer, fuel, mixture_ratio, chamber_pressure_pa - ) - else: - return _get_properties_from_database( - oxidizer, fuel, mixture_ratio, chamber_pressure_pa - ) - - -@beartype -def is_cea_available() -> bool: - """Check if RocketCEA is installed and available. - - Returns: - Always True (RocketCEA is a required dependency) - """ - return True - - -@beartype -def list_database_propellants() -> list[tuple[str, str]]: - """List propellant combinations available in the built-in database. - - Returns: - List of (oxidizer, fuel) tuples available without RocketCEA - """ - return list(_PROPELLANT_DATABASE.keys()) - - -@beartype -def get_optimal_mixture_ratio( - oxidizer: str, - fuel: str, - chamber_pressure_pa: float, - expansion_ratio: float = 40.0, - metric: Literal["isp", "cstar", "density_isp"] = "isp", -) -> tuple[float, float]: - """Find the optimal mixture ratio for maximum performance. - - Searches for the mixture ratio that maximizes the specified metric. - Requires RocketCEA for accurate optimization. - - Args: - oxidizer: Oxidizer name - fuel: Fuel name - chamber_pressure_pa: Chamber pressure in Pascals - expansion_ratio: Nozzle expansion ratio for Isp calculation - metric: Optimization target: - - "isp": Maximize specific impulse - - "cstar": Maximize characteristic velocity - - "density_isp": Maximize density * Isp (important for volume-limited vehicles) - - Returns: - Tuple of (optimal_mixture_ratio, maximum_metric_value) - - Raises: - RuntimeError: If RocketCEA is not installed - """ - pc_psia = chamber_pressure_pa / 6894.76 - ox_name = _normalize_propellant_name(oxidizer, is_oxidizer=True) - fuel_name = _normalize_propellant_name(fuel, is_oxidizer=False) - - cea = CEA_Obj(oxName=ox_name, fuelName=fuel_name) - - # Search over mixture ratios - best_mr = 1.0 - best_value = 0.0 - - # Determine search range based on propellant type - if ox_name == "LOX" and fuel_name == "LH2": - mr_range = [x / 10 for x in range(30, 90, 2)] # 3.0 to 9.0 - elif ox_name == "LOX": - mr_range = [x / 10 for x in range(15, 40, 2)] # 1.5 to 4.0 - else: - mr_range = [x / 10 for x in range(10, 50, 2)] # 1.0 to 5.0 - - for mr in mr_range: - try: - if metric == "isp": - value = cea.get_Isp(Pc=pc_psia, MR=mr, eps=expansion_ratio) - elif metric == "cstar": - value = cea.get_Cstar(Pc=pc_psia, MR=mr) - elif metric == "density_isp": - isp = cea.get_Isp(Pc=pc_psia, MR=mr, eps=expansion_ratio) - # Approximate density Isp (would need propellant densities for accuracy) - value = isp # Simplified - use Isp as proxy - - if value > best_value: - best_value = value - best_mr = mr - except Exception: - continue - - return best_mr, best_value - diff --git a/openrocketengine/results.py b/openrocketengine/results.py deleted file mode 100644 index 9cf4974..0000000 --- a/openrocketengine/results.py +++ /dev/null @@ -1,659 +0,0 @@ -"""Results visualization and Pareto front analysis for Rocket. - -This module provides plotting utilities for parametric study and uncertainty -analysis results. Integrates with the analysis module for seamless visualization. - -Example: - >>> from openrocketengine.analysis import ParametricStudy, Range - >>> from openrocketengine.results import plot_1d, plot_2d_contour, plot_pareto - >>> - >>> results = study.run() - >>> fig = plot_1d(results, "chamber_pressure", "isp_vac") - >>> fig = plot_pareto(results, "isp_vac", "thrust_to_weight", maximize=[True, True]) -""" - -from typing import Sequence - -import matplotlib.pyplot as plt -import numpy as np -from beartype import beartype -from matplotlib.figure import Figure -from numpy.typing import NDArray - -from openrocketengine.analysis import StudyResults, UncertaintyResults - - -# ============================================================================= -# Plot Style Configuration -# ============================================================================= - -COLORS = { - "primary": "#2E86AB", - "secondary": "#A23B72", - "accent": "#F18F01", - "feasible": "#2E86AB", - "infeasible": "#CCCCCC", - "pareto": "#E63946", - "grid": "#CCCCCC", - "text": "#333333", -} - - -def _setup_style() -> None: - """Configure matplotlib style for consistent appearance.""" - plt.rcParams.update({ - "font.family": "sans-serif", - "font.sans-serif": ["Helvetica", "Arial", "DejaVu Sans"], - "font.size": 11, - "axes.titlesize": 14, - "axes.labelsize": 12, - "axes.linewidth": 1.2, - "xtick.labelsize": 10, - "ytick.labelsize": 10, - "legend.fontsize": 10, - "figure.titlesize": 16, - "grid.alpha": 0.5, - }) - - -# ============================================================================= -# 1D Parameter Sweep Plots -# ============================================================================= - - -@beartype -def plot_1d( - results: StudyResults, - x_param: str, - y_metric: str, - show_infeasible: bool = True, - figsize: tuple[float, float] = (10, 6), - title: str | None = None, - xlabel: str | None = None, - ylabel: str | None = None, -) -> Figure: - """Plot a 1D parameter sweep. - - Args: - results: StudyResults from ParametricStudy - x_param: Parameter name for x-axis - y_metric: Metric name for y-axis - show_infeasible: Whether to show infeasible points (grayed out) - figsize: Figure size (width, height) - title: Optional plot title - xlabel: Optional x-axis label - ylabel: Optional y-axis label - - Returns: - matplotlib Figure - """ - _setup_style() - - fig, ax = plt.subplots(figsize=figsize) - - x = results.get_parameter(x_param) - y = results.get_metric(y_metric) - - if results.constraints_passed is not None and show_infeasible: - # Plot infeasible points first (gray) - infeasible = ~results.constraints_passed - if np.any(infeasible): - ax.scatter( - x[infeasible], y[infeasible], - c=COLORS["infeasible"], s=50, alpha=0.5, - label="Infeasible", zorder=1, - ) - - # Plot feasible points - feasible = results.constraints_passed - if np.any(feasible): - ax.scatter( - x[feasible], y[feasible], - c=COLORS["primary"], s=80, - label="Feasible", zorder=2, - ) - ax.plot( - x[feasible], y[feasible], - c=COLORS["primary"], alpha=0.5, linewidth=1.5, zorder=1, - ) - else: - ax.scatter(x, y, c=COLORS["primary"], s=80) - ax.plot(x, y, c=COLORS["primary"], alpha=0.5, linewidth=1.5) - - # Mark optimum - if results.constraints_passed is not None: - feasible_mask = results.constraints_passed - if np.any(feasible_mask): - best_idx = np.argmax(y * feasible_mask.astype(float) - (~feasible_mask) * 1e10) - ax.scatter( - [x[best_idx]], [y[best_idx]], - c=COLORS["accent"], s=150, marker="*", - label=f"Best: {y[best_idx]:.3g}", zorder=3, - ) - - ax.set_xlabel(xlabel or x_param) - ax.set_ylabel(ylabel or y_metric) - ax.set_title(title or f"{y_metric} vs {x_param}") - ax.grid(True, alpha=0.3) - ax.legend() - - fig.tight_layout() - return fig - - -@beartype -def plot_1d_multi( - results: StudyResults, - x_param: str, - y_metrics: list[str], - figsize: tuple[float, float] = (12, 6), - title: str | None = None, - xlabel: str | None = None, - normalize: bool = False, -) -> Figure: - """Plot multiple metrics vs a single parameter. - - Args: - results: StudyResults from ParametricStudy - x_param: Parameter name for x-axis - y_metrics: List of metric names to plot - figsize: Figure size - title: Optional title - xlabel: Optional x-axis label - normalize: If True, normalize all metrics to [0, 1] - - Returns: - matplotlib Figure - """ - _setup_style() - - fig, ax = plt.subplots(figsize=figsize) - - x = results.get_parameter(x_param) - colors = plt.cm.tab10(np.linspace(0, 1, len(y_metrics))) - - for i, metric in enumerate(y_metrics): - y = results.get_metric(metric) - if normalize: - y = (y - np.nanmin(y)) / (np.nanmax(y) - np.nanmin(y) + 1e-10) - - ax.plot(x, y, color=colors[i], linewidth=2, label=metric, marker="o", markersize=4) - - ax.set_xlabel(xlabel or x_param) - ax.set_ylabel("Normalized Value" if normalize else "Metric Value") - ax.set_title(title or f"Metrics vs {x_param}") - ax.grid(True, alpha=0.3) - ax.legend(loc="best") - - fig.tight_layout() - return fig - - -# ============================================================================= -# 2D Contour Plots -# ============================================================================= - - -@beartype -def plot_2d_contour( - results: StudyResults, - x_param: str, - y_param: str, - z_metric: str, - figsize: tuple[float, float] = (10, 8), - title: str | None = None, - levels: int = 20, - show_points: bool = True, - show_infeasible: bool = True, -) -> Figure: - """Plot a 2D contour for two-parameter sweeps. - - Args: - results: StudyResults from ParametricStudy (must be 2D grid) - x_param: Parameter for x-axis - y_param: Parameter for y-axis - z_metric: Metric for contour values - figsize: Figure size - title: Optional title - levels: Number of contour levels - show_points: Show scatter points at evaluated locations - show_infeasible: Mark infeasible regions - - Returns: - matplotlib Figure - """ - _setup_style() - - x = results.get_parameter(x_param) - y = results.get_parameter(y_param) - z = results.get_metric(z_metric) - - # Get unique values to determine grid shape - x_unique = np.unique(x) - y_unique = np.unique(y) - - if len(x_unique) * len(y_unique) != len(x): - raise ValueError( - f"Data is not a complete 2D grid. Got {len(x)} points, " - f"expected {len(x_unique)} x {len(y_unique)} = {len(x_unique) * len(y_unique)}" - ) - - # Reshape to 2D grid - nx, ny = len(x_unique), len(y_unique) - X = x.reshape(ny, nx) - Y = y.reshape(ny, nx) - Z = z.reshape(ny, nx) - - fig, ax = plt.subplots(figsize=figsize) - - # Contour plot - contour = ax.contourf(X, Y, Z, levels=levels, cmap="viridis") - ax.contour(X, Y, Z, levels=levels, colors="white", alpha=0.3, linewidths=0.5) - - # Colorbar - cbar = fig.colorbar(contour, ax=ax, label=z_metric) - - # Show evaluated points - if show_points: - ax.scatter(x, y, c="white", s=10, alpha=0.5, edgecolors="black", linewidths=0.5) - - # Mark infeasible regions - if show_infeasible and results.constraints_passed is not None: - infeasible = ~results.constraints_passed - if np.any(infeasible): - ax.scatter( - x[infeasible], y[infeasible], - c="red", s=30, marker="x", alpha=0.7, - label="Infeasible", - ) - ax.legend() - - ax.set_xlabel(x_param) - ax.set_ylabel(y_param) - ax.set_title(title or f"{z_metric}") - - fig.tight_layout() - return fig - - -# ============================================================================= -# Pareto Front Analysis -# ============================================================================= - - -def _compute_pareto_front( - objectives: NDArray[np.float64], - maximize: Sequence[bool], -) -> NDArray[np.bool_]: - """Compute Pareto-optimal points. - - Args: - objectives: Array of shape (n_points, n_objectives) - maximize: List of booleans indicating whether to maximize each objective - - Returns: - Boolean array indicating Pareto-optimal points - """ - n_points = objectives.shape[0] - is_pareto = np.ones(n_points, dtype=bool) - - # Flip signs for maximization objectives - obj = objectives.copy() - for i, is_max in enumerate(maximize): - if is_max: - obj[:, i] = -obj[:, i] - - for i in range(n_points): - if not is_pareto[i]: - continue - - # Check if any other point dominates this one - for j in range(n_points): - if i == j or not is_pareto[j]: - continue - - # j dominates i if j is <= in all objectives and < in at least one - dominates = np.all(obj[j] <= obj[i]) and np.any(obj[j] < obj[i]) - if dominates: - is_pareto[i] = False - break - - return is_pareto - - -@beartype -def plot_pareto( - results: StudyResults, - x_metric: str, - y_metric: str, - maximize: tuple[bool, bool] = (True, True), - feasible_only: bool = True, - figsize: tuple[float, float] = (10, 8), - title: str | None = None, - show_dominated: bool = True, -) -> Figure: - """Plot Pareto front for two objectives. - - Args: - results: StudyResults from ParametricStudy - x_metric: First objective (x-axis) - y_metric: Second objective (y-axis) - maximize: Tuple of booleans for each objective (True = maximize) - feasible_only: Only consider feasible points - figsize: Figure size - title: Optional title - show_dominated: Show dominated points in gray - - Returns: - matplotlib Figure - """ - _setup_style() - - # Get metrics - x = results.get_metric(x_metric) - y = results.get_metric(y_metric) - - # Filter to feasible if requested - if feasible_only and results.constraints_passed is not None: - mask = results.constraints_passed - else: - mask = np.ones(len(x), dtype=bool) - - # Remove NaN values - valid = mask & ~np.isnan(x) & ~np.isnan(y) - x_valid = x[valid] - y_valid = y[valid] - - if len(x_valid) == 0: - raise ValueError("No valid data points for Pareto analysis") - - # Compute Pareto front - objectives = np.column_stack([x_valid, y_valid]) - is_pareto = _compute_pareto_front(objectives, maximize) - - fig, ax = plt.subplots(figsize=figsize) - - # Plot dominated points - if show_dominated: - dominated = ~is_pareto - ax.scatter( - x_valid[dominated], y_valid[dominated], - c=COLORS["infeasible"], s=50, alpha=0.5, - label="Dominated", - ) - - # Plot Pareto front points - ax.scatter( - x_valid[is_pareto], y_valid[is_pareto], - c=COLORS["pareto"], s=100, - label=f"Pareto Front ({np.sum(is_pareto)} points)", - zorder=3, - ) - - # Connect Pareto points with line (sorted) - pareto_x = x_valid[is_pareto] - pareto_y = y_valid[is_pareto] - sort_idx = np.argsort(pareto_x) - ax.plot( - pareto_x[sort_idx], pareto_y[sort_idx], - c=COLORS["pareto"], linewidth=2, alpha=0.7, - linestyle="--", - ) - - # Add direction arrows - x_dir = "→" if maximize[0] else "←" - y_dir = "↑" if maximize[1] else "↓" - ax.annotate( - f"Better {x_dir}", - xy=(0.95, 0.02), xycoords="axes fraction", - ha="right", fontsize=10, color=COLORS["text"], - ) - ax.annotate( - f"Better {y_dir}", - xy=(0.02, 0.95), xycoords="axes fraction", - ha="left", fontsize=10, color=COLORS["text"], rotation=90, - ) - - ax.set_xlabel(x_metric) - ax.set_ylabel(y_metric) - ax.set_title(title or f"Pareto Front: {x_metric} vs {y_metric}") - ax.grid(True, alpha=0.3) - ax.legend() - - fig.tight_layout() - return fig - - -@beartype -def get_pareto_points( - results: StudyResults, - metrics: list[str], - maximize: list[bool], - feasible_only: bool = True, -) -> tuple[list[int], NDArray[np.float64]]: - """Get indices and values of Pareto-optimal points. - - Args: - results: StudyResults from ParametricStudy - metrics: List of objective metric names - maximize: List of booleans for each metric - feasible_only: Only consider feasible points - - Returns: - Tuple of (indices in original results, objective values array) - """ - # Get metrics - obj_arrays = [results.get_metric(m) for m in metrics] - objectives = np.column_stack(obj_arrays) - - # Filter to feasible - if feasible_only and results.constraints_passed is not None: - mask = results.constraints_passed - else: - mask = np.ones(objectives.shape[0], dtype=bool) - - # Remove NaN - valid = mask & ~np.any(np.isnan(objectives), axis=1) - valid_indices = np.where(valid)[0] - objectives_valid = objectives[valid] - - # Compute Pareto front - is_pareto = _compute_pareto_front(objectives_valid, maximize) - - pareto_indices = valid_indices[is_pareto].tolist() - pareto_values = objectives_valid[is_pareto] - - return pareto_indices, pareto_values - - -# ============================================================================= -# Uncertainty Visualization -# ============================================================================= - - -@beartype -def plot_histogram( - results: UncertaintyResults, - metric: str, - bins: int = 50, - show_ci: bool = True, - ci_level: float = 0.95, - figsize: tuple[float, float] = (10, 6), - title: str | None = None, - feasible_only: bool = False, -) -> Figure: - """Plot histogram of a metric from uncertainty analysis. - - Args: - results: UncertaintyResults from UncertaintyAnalysis - metric: Metric name to plot - bins: Number of histogram bins - show_ci: Show confidence interval lines - ci_level: Confidence level for CI lines - figsize: Figure size - title: Optional title - feasible_only: Only include feasible samples - - Returns: - matplotlib Figure - """ - _setup_style() - - values = results.metrics[metric] - if feasible_only: - values = values[results.constraints_passed] - - # Remove NaN - values = values[~np.isnan(values)] - - fig, ax = plt.subplots(figsize=figsize) - - # Histogram - ax.hist(values, bins=bins, color=COLORS["primary"], alpha=0.7, edgecolor="white") - - # Statistics - mean = np.mean(values) - std = np.std(values) - - ax.axvline(mean, color=COLORS["accent"], linewidth=2, label=f"Mean: {mean:.4g}") - - if show_ci: - ci = results.confidence_interval(metric, ci_level, feasible_only) - ax.axvline(ci[0], color=COLORS["secondary"], linewidth=1.5, linestyle="--", - label=f"{ci_level*100:.0f}% CI: [{ci[0]:.4g}, {ci[1]:.4g}]") - ax.axvline(ci[1], color=COLORS["secondary"], linewidth=1.5, linestyle="--") - - ax.set_xlabel(metric) - ax.set_ylabel("Frequency") - ax.set_title(title or f"Distribution of {metric}") - ax.legend() - ax.grid(True, alpha=0.3, axis="y") - - # Add text box with statistics - stats_text = f"μ = {mean:.4g}\nσ = {std:.4g}\nn = {len(values)}" - ax.text( - 0.95, 0.95, stats_text, - transform=ax.transAxes, ha="right", va="top", - fontsize=10, fontfamily="monospace", - bbox=dict(boxstyle="round", facecolor="white", alpha=0.8), - ) - - fig.tight_layout() - return fig - - -@beartype -def plot_correlation( - results: UncertaintyResults, - x_param: str, - y_metric: str, - figsize: tuple[float, float] = (10, 6), - title: str | None = None, -) -> Figure: - """Plot correlation between input parameter and output metric. - - Args: - results: UncertaintyResults from UncertaintyAnalysis - x_param: Input parameter name (from samples) - y_metric: Output metric name - figsize: Figure size - title: Optional title - - Returns: - matplotlib Figure - """ - _setup_style() - - if x_param not in results.samples: - raise ValueError(f"Parameter '{x_param}' not in samples. Available: {list(results.samples.keys())}") - - x = results.samples[x_param] - y = results.metrics[y_metric] - - # Remove NaN pairs - valid = ~(np.isnan(x) | np.isnan(y)) - x = x[valid] - y = y[valid] - - fig, ax = plt.subplots(figsize=figsize) - - ax.scatter(x, y, c=COLORS["primary"], alpha=0.3, s=20) - - # Compute correlation - corr = np.corrcoef(x, y)[0, 1] - - # Add trend line - z = np.polyfit(x, y, 1) - p = np.poly1d(z) - x_line = np.linspace(x.min(), x.max(), 100) - ax.plot(x_line, p(x_line), c=COLORS["accent"], linewidth=2, - label=f"Trend (r = {corr:.3f})") - - ax.set_xlabel(x_param) - ax.set_ylabel(y_metric) - ax.set_title(title or f"Correlation: {x_param} vs {y_metric}") - ax.legend() - ax.grid(True, alpha=0.3) - - fig.tight_layout() - return fig - - -@beartype -def plot_tornado( - results: UncertaintyResults, - metric: str, - parameters: list[str] | None = None, - figsize: tuple[float, float] = (10, 6), - title: str | None = None, -) -> Figure: - """Plot tornado chart showing sensitivity of metric to input parameters. - - Shows which parameters have the strongest influence on the metric. - - Args: - results: UncertaintyResults from UncertaintyAnalysis - metric: Metric to analyze - parameters: List of parameters to include (None = all) - figsize: Figure size - title: Optional title - - Returns: - matplotlib Figure - """ - _setup_style() - - if parameters is None: - parameters = list(results.samples.keys()) - - y_values = results.metrics[metric] - correlations: dict[str, float] = {} - - for param in parameters: - x_values = results.samples[param] - valid = ~(np.isnan(x_values) | np.isnan(y_values)) - if np.sum(valid) > 2: - corr = np.corrcoef(x_values[valid], y_values[valid])[0, 1] - correlations[param] = corr - - # Sort by absolute correlation - sorted_params = sorted(correlations.keys(), key=lambda p: abs(correlations[p])) - sorted_corrs = [correlations[p] for p in sorted_params] - - fig, ax = plt.subplots(figsize=figsize) - - y_pos = np.arange(len(sorted_params)) - colors = [COLORS["primary"] if c >= 0 else COLORS["secondary"] for c in sorted_corrs] - - ax.barh(y_pos, sorted_corrs, color=colors, alpha=0.8, edgecolor="white") - ax.set_yticks(y_pos) - ax.set_yticklabels(sorted_params) - ax.set_xlabel("Correlation Coefficient") - ax.set_title(title or f"Sensitivity of {metric}") - ax.axvline(0, color="black", linewidth=0.5) - ax.set_xlim(-1, 1) - ax.grid(True, alpha=0.3, axis="x") - - fig.tight_layout() - return fig - diff --git a/openrocketengine/system.py b/openrocketengine/system.py deleted file mode 100644 index 8f33eda..0000000 --- a/openrocketengine/system.py +++ /dev/null @@ -1,245 +0,0 @@ -"""High-level system design API for Rocket. - -This module provides the main entry point for complete engine system design, -integrating: -- Engine performance and geometry -- Cycle analysis (pressure-fed, gas-generator, etc.) -- Thermal/cooling feasibility - -Example: - >>> from openrocketengine import EngineInputs - >>> from openrocketengine.system import design_engine_system - >>> from openrocketengine.cycles import GasGeneratorCycle - >>> from openrocketengine.units import kilonewtons, megapascals, kelvin - >>> - >>> inputs = EngineInputs.from_propellants( - ... oxidizer="LOX", - ... fuel="CH4", - ... thrust=kilonewtons(2000), - ... chamber_pressure=megapascals(30), - ... ) - >>> - >>> result = design_engine_system( - ... inputs=inputs, - ... cycle=GasGeneratorCycle(turbine_inlet_temp=kelvin(900)), - ... check_cooling=True, - ... ) - >>> - >>> print(f"Net Isp: {result.cycle.net_isp.value:.1f} s") - >>> print(f"Cooling feasible: {result.cooling.feasible}") -""" - -from dataclasses import dataclass -from typing import Any - -from beartype import beartype - -from openrocketengine.engine import ( - EngineGeometry, - EngineInputs, - EnginePerformance, - compute_geometry, - compute_performance, -) -from openrocketengine.cycles.base import CycleConfiguration, CyclePerformance, analyze_cycle -from openrocketengine.thermal.regenerative import CoolingFeasibility, check_cooling_feasibility -from openrocketengine.units import kelvin - - -@beartype -@dataclass(frozen=True) -class EngineSystemResult: - """Complete engine system design result. - - Contains all analysis results from engine performance through - cycle analysis and thermal assessment. - - Attributes: - inputs: Original engine inputs - performance: Ideal engine performance (before cycle losses) - geometry: Engine geometry - cycle: Cycle analysis results (if cycle provided) - cooling: Cooling feasibility results (if check_cooling=True) - feasible: Overall feasibility (cycle closes AND cooling ok) - warnings: Combined warnings from all analyses - """ - - inputs: EngineInputs - performance: EnginePerformance - geometry: EngineGeometry - cycle: CyclePerformance | None - cooling: CoolingFeasibility | None - feasible: bool - warnings: list[str] - - -@beartype -def design_engine_system( - inputs: EngineInputs, - cycle: CycleConfiguration | None = None, - check_cooling: bool = True, - coolant: str | None = None, - max_wall_temp: Any | None = None, -) -> EngineSystemResult: - """Design a complete engine system with cycle and thermal analysis. - - This is the main entry point for rocket engine system design. It: - 1. Computes ideal engine performance and geometry - 2. Analyzes the engine cycle (if specified) - 3. Checks cooling feasibility (if requested) - 4. Returns a comprehensive result with all analyses - - Args: - inputs: Engine input parameters (from EngineInputs.from_propellants or manual) - cycle: Engine cycle configuration (PressureFedCycle, GasGeneratorCycle, etc.) - If None, only basic performance is computed. - check_cooling: Whether to perform cooling feasibility analysis - coolant: Coolant name for cooling analysis. If None, uses fuel. - max_wall_temp: Maximum allowed wall temperature [K]. If None, uses defaults. - - Returns: - EngineSystemResult with all analysis results - - Example: - >>> # Basic design without cycle analysis - >>> result = design_engine_system(inputs) - >>> print(f"Isp: {result.performance.isp.value:.1f} s") - >>> - >>> # Full system design with gas generator cycle - >>> from openrocketengine.cycles import GasGeneratorCycle - >>> result = design_engine_system( - ... inputs=inputs, - ... cycle=GasGeneratorCycle(turbine_inlet_temp=kelvin(900)), - ... check_cooling=True, - ... ) - """ - all_warnings: list[str] = [] - - # Step 1: Compute basic engine performance and geometry - performance = compute_performance(inputs) - geometry = compute_geometry(inputs, performance) - - # Step 2: Cycle analysis (if cycle provided) - cycle_result: CyclePerformance | None = None - if cycle is not None: - cycle_result = analyze_cycle(inputs, performance, geometry, cycle) - all_warnings.extend(cycle_result.warnings) - - # Step 3: Cooling feasibility (if requested) - cooling_result: CoolingFeasibility | None = None - if check_cooling: - # Determine coolant (default to fuel) - if coolant is None: - # Try to infer fuel from engine name - coolant = _infer_coolant(inputs) - - cooling_result = check_cooling_feasibility( - inputs=inputs, - performance=performance, - geometry=geometry, - coolant=coolant, - max_wall_temp=max_wall_temp, - ) - all_warnings.extend(cooling_result.warnings) - - # Determine overall feasibility - feasible = True - if cycle_result is not None and not cycle_result.feasible: - feasible = False - if cooling_result is not None and not cooling_result.feasible: - feasible = False - - return EngineSystemResult( - inputs=inputs, - performance=performance, - geometry=geometry, - cycle=cycle_result, - cooling=cooling_result, - feasible=feasible, - warnings=all_warnings, - ) - - -def _infer_coolant(inputs: EngineInputs) -> str: - """Infer coolant from engine inputs.""" - if inputs.name: - name_upper = inputs.name.upper() - if "CH4" in name_upper or "METHANE" in name_upper or "METHALOX" in name_upper: - return "CH4" - elif "LH2" in name_upper or "HYDROGEN" in name_upper or "HYDROLOX" in name_upper: - return "LH2" - elif "RP1" in name_upper or "KEROSENE" in name_upper or "KEROLOX" in name_upper: - return "RP1" - elif "ETHANOL" in name_upper: - return "Ethanol" - - # Default to RP-1 - return "RP1" - - -@beartype -def format_system_summary(result: EngineSystemResult) -> str: - """Format complete system design results as readable string. - - Args: - result: EngineSystemResult from design_engine_system() - - Returns: - Formatted multi-line string summary - """ - name = result.inputs.name or "Unnamed Engine" - status = "✓ FEASIBLE" if result.feasible else "✗ NOT FEASIBLE" - - lines = [ - "=" * 70, - f"ENGINE SYSTEM DESIGN: {name}", - f"Overall Status: {status}", - "=" * 70, - "", - "PERFORMANCE (Ideal):", - f" Thrust (SL): {result.inputs.thrust.to('kN').value:.1f} kN", - f" Isp (SL): {result.performance.isp.value:.1f} s", - f" Isp (Vac): {result.performance.isp_vac.value:.1f} s", - f" C*: {result.performance.cstar.value:.0f} m/s", - f" Mass Flow: {result.performance.mdot.value:.2f} kg/s", - "", - "GEOMETRY:", - f" Throat Dia: {result.geometry.throat_diameter.to('m').value*100:.1f} cm", - f" Exit Dia: {result.geometry.exit_diameter.to('m').value*100:.1f} cm", - f" Chamber Dia: {result.geometry.chamber_diameter.to('m').value*100:.1f} cm", - f" Expansion Ratio: {result.geometry.expansion_ratio:.1f}", - ] - - if result.cycle is not None: - lines.extend([ - "", - f"CYCLE ({result.cycle.cycle_type.name}):", - f" Net Isp: {result.cycle.net_isp.value:.1f} s", - f" Cycle Efficiency:{result.cycle.cycle_efficiency*100:.1f}%", - f" Turbine Power: {result.cycle.turbine_power.value/1000:.0f} kW", - f" GG/Turbine Flow: {result.cycle.turbine_mass_flow.value:.2f} kg/s", - f" Tank P (Ox): {result.cycle.tank_pressure_ox.to('bar').value:.1f} bar", - f" Tank P (Fuel): {result.cycle.tank_pressure_fuel.to('bar').value:.1f} bar", - ]) - - if result.cooling is not None: - lines.extend([ - "", - "COOLING:", - f" Throat Heat Flux:{result.cooling.throat_heat_flux.value/1e6:.1f} MW/m²", - f" Max Wall Temp: {result.cooling.max_wall_temp.value:.0f} K", - f" Allowed Temp: {result.cooling.max_allowed_temp.value:.0f} K", - f" Coolant ΔT: {result.cooling.coolant_temp_rise.value:.0f} K", - f" Flow Margin: {result.cooling.flow_margin:.2f}x", - f" Pressure Drop: {result.cooling.pressure_drop.to('bar').value:.1f} bar", - ]) - - if result.warnings: - lines.extend(["", "WARNINGS:"]) - for warning in result.warnings: - lines.append(f" ⚠ {warning}") - - lines.append("=" * 70) - - return "\n".join(lines) - diff --git a/openrocketengine/tanks.py b/openrocketengine/tanks.py deleted file mode 100644 index 9a241cc..0000000 --- a/openrocketengine/tanks.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Tank sizing and propellant utilities for OpenRocketEngine. - -This module provides propellant density data and tank sizing utilities. -""" - -from beartype import beartype - -# Propellant densities at ~1 atm and typical storage temperatures [kg/m³] -PROPELLANT_DENSITIES = { - # Oxidizers - "LOX": 1141.0, # Liquid oxygen @ 90K - "N2O4": 1440.0, # Nitrogen tetroxide - "N2O": 1220.0, # Nitrous oxide @ 0°C - "H2O2": 1450.0, # High-test hydrogen peroxide (90%) - "IRFNA": 1550.0, # Inhibited red fuming nitric acid - # Fuels - "LH2": 70.8, # Liquid hydrogen @ 20K - "RP1": 810.0, # Kerosene (RP-1) - "CH4": 422.8, # Liquid methane @ 111K - "LCH4": 422.8, # Alias for liquid methane - "ETHANOL": 789.0, # Ethanol - "MMH": 880.0, # Monomethylhydrazine - "UDMH": 791.0, # Unsymmetrical dimethylhydrazine - "N2H4": 1021.0, # Hydrazine - "METHANOL": 792.0, # Methanol - "ISOPROPANOL": 786.0, # Isopropyl alcohol - "JET-A": 820.0, # Jet fuel -} - - -@beartype -def get_propellant_density(propellant: str) -> float: - """Get the density of a propellant in kg/m³. - - Args: - propellant: Propellant name (e.g., "LOX", "CH4", "RP1") - - Returns: - Density in kg/m³ - - Raises: - ValueError: If propellant is not found in database - """ - propellant_upper = propellant.upper() - - if propellant_upper in PROPELLANT_DENSITIES: - return PROPELLANT_DENSITIES[propellant_upper] - - # Try common aliases - aliases = { - "O2": "LOX", - "OXYGEN": "LOX", - "METHANE": "CH4", - "KEROSENE": "RP1", - "HYDROGEN": "LH2", - "H2": "LH2", - } - - if propellant_upper in aliases: - return PROPELLANT_DENSITIES[aliases[propellant_upper]] - - available = ", ".join(sorted(PROPELLANT_DENSITIES.keys())) - raise ValueError( - f"Unknown propellant: {propellant}. Available: {available}" - ) - - -@beartype -def list_propellants() -> list[str]: - """List all available propellants in the database. - - Returns: - List of propellant names - """ - return sorted(PROPELLANT_DENSITIES.keys()) - diff --git a/openrocketengine/thermal/__init__.py b/openrocketengine/thermal/__init__.py deleted file mode 100644 index a34ac5c..0000000 --- a/openrocketengine/thermal/__init__.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Thermal analysis module for Rocket. - -This module provides tools for analyzing thermal loads on rocket engine -components and evaluating cooling system feasibility. - -Key capabilities: -- Heat flux estimation using Bartz correlation -- Regenerative cooling feasibility screening -- Wall temperature prediction -- Coolant property database - -Example: - >>> from openrocketengine import EngineInputs, design_engine - >>> from openrocketengine.thermal import estimate_heat_flux, check_cooling_feasibility - >>> - >>> inputs = EngineInputs.from_propellants("LOX", "CH4", ...) - >>> performance, geometry = design_engine(inputs) - >>> - >>> q_throat = estimate_heat_flux(inputs, performance, geometry, location="throat") - >>> print(f"Throat heat flux: {q_throat.value/1e6:.1f} MW/m²") - >>> - >>> cooling = check_cooling_feasibility( - ... inputs, performance, geometry, - ... coolant="CH4", - ... max_wall_temp=kelvin(800), - ... ) - >>> print(f"Cooling feasible: {cooling.feasible}") -""" - -from openrocketengine.thermal.heat_flux import ( - adiabatic_wall_temperature, - bartz_heat_flux, - estimate_heat_flux, - heat_flux_profile, - recovery_factor, -) -from openrocketengine.thermal.regenerative import ( - CoolingFeasibility, - CoolantProperties, - check_cooling_feasibility, - get_coolant_properties, -) - -__all__ = [ - # Heat flux estimation - "adiabatic_wall_temperature", - "bartz_heat_flux", - "estimate_heat_flux", - "heat_flux_profile", - "recovery_factor", - # Regenerative cooling - "CoolingFeasibility", - "CoolantProperties", - "check_cooling_feasibility", - "get_coolant_properties", -] - diff --git a/openrocketengine/thermal/heat_flux.py b/openrocketengine/thermal/heat_flux.py deleted file mode 100644 index c4f09ec..0000000 --- a/openrocketengine/thermal/heat_flux.py +++ /dev/null @@ -1,306 +0,0 @@ -"""Heat flux estimation for rocket engine combustion chambers and nozzles. - -This module implements the Bartz correlation and related methods for -estimating convective heat transfer in rocket engines. - -The Bartz correlation is the industry-standard method for preliminary -heat flux estimation, derived from turbulent pipe flow correlations -modified for rocket engine conditions. - -References: - - Bartz, D.R., "A Simple Equation for Rapid Estimation of Rocket - Nozzle Convective Heat Transfer Coefficients", Jet Propulsion, 1957 - - Huzel & Huang, "Modern Engineering for Design of Liquid-Propellant - Rocket Engines", Chapter 4 -""" - -import math - -import numpy as np -from beartype import beartype - -from openrocketengine.engine import EngineGeometry, EngineInputs, EnginePerformance -from openrocketengine.units import Quantity, kelvin - - -@beartype -def recovery_factor(prandtl: float, laminar: bool = False) -> float: - """Calculate the recovery factor for adiabatic wall temperature. - - The recovery factor accounts for the difference between the - stagnation temperature and the actual adiabatic wall temperature - due to boundary layer effects. - - Args: - prandtl: Prandtl number of the gas [-] - laminar: If True, use laminar correlation; else turbulent - - Returns: - Recovery factor r [-], typically 0.85-0.92 for turbulent flow - """ - if laminar: - return math.sqrt(prandtl) # r = Pr^0.5 for laminar - else: - return prandtl ** (1/3) # r = Pr^(1/3) for turbulent - - -@beartype -def adiabatic_wall_temperature( - stagnation_temp: Quantity, - mach: float, - gamma: float, - recovery_factor: float, -) -> Quantity: - """Calculate adiabatic wall temperature. - - The adiabatic wall temperature is the temperature the wall would - reach if there were no heat transfer (perfectly insulated wall). - It's used as the driving temperature difference for heat flux. - - T_aw = T_0 * [1 + r * (gamma-1)/2 * M^2] / [1 + (gamma-1)/2 * M^2] - - Args: - stagnation_temp: Chamber/stagnation temperature [K] - mach: Local Mach number [-] - gamma: Ratio of specific heats [-] - recovery_factor: Recovery factor r [-] - - Returns: - Adiabatic wall temperature [K] - """ - T0 = stagnation_temp.to("K").value - gm1 = gamma - 1 - - # Temperature ratio T/T0 from isentropic relations - T_ratio = 1 / (1 + gm1/2 * mach**2) - - # Static temperature - T_static = T0 * T_ratio - - # Adiabatic wall temperature - # T_aw = T_static + r * (T0 - T_static) - T_aw = T_static + recovery_factor * (T0 - T_static) - - return kelvin(T_aw) - - -@beartype -def bartz_heat_flux( - chamber_pressure: Quantity, - chamber_temp: Quantity, - throat_diameter: Quantity, - local_diameter: Quantity, - characteristic_velocity: Quantity, - gamma: float, - molecular_weight: float, - local_mach: float, - wall_temp: Quantity | None = None, -) -> Quantity: - """Calculate convective heat flux using the Bartz correlation. - - The Bartz equation estimates the convective heat transfer coefficient: - - h = (0.026/D_t^0.2) * (mu^0.2 * cp / Pr^0.6) * (p_c / c*)^0.8 * - (D_t/R_c)^0.1 * (A_t/A)^0.9 * sigma - - where sigma is a correction factor for property variations. - - Args: - chamber_pressure: Chamber pressure [Pa] - chamber_temp: Chamber temperature [K] - throat_diameter: Throat diameter [m] - local_diameter: Local diameter at evaluation point [m] - characteristic_velocity: c* [m/s] - gamma: Ratio of specific heats [-] - molecular_weight: Molecular weight [kg/kmol] - local_mach: Local Mach number [-] - wall_temp: Wall temperature [K]. If None, estimates at 600K. - - Returns: - Heat flux [W/m²] - """ - # Extract values in SI - pc = chamber_pressure.to("Pa").value - Tc = chamber_temp.to("K").value - Dt = throat_diameter.to("m").value - D = local_diameter.to("m").value - cstar = characteristic_velocity.to("m/s").value - MW = molecular_weight - - # Wall temperature (estimate if not provided) - Tw = wall_temp.to("K").value if wall_temp else 600.0 - - # Gas properties - R_specific = 8314.46 / MW # J/(kg·K) - cp = gamma * R_specific / (gamma - 1) # J/(kg·K) - - # Estimate viscosity using Sutherland's law - # Reference: air at 273K, mu0 = 1.71e-5 Pa·s - # For combustion products, use higher reference - mu_ref = 4e-5 # Pa·s at Tc_ref - Tc_ref = 3000 # K - S = 200 # Sutherland constant (approximate for combustion products) - - mu = mu_ref * (Tc / Tc_ref) ** 1.5 * (Tc_ref + S) / (Tc + S) - - # Prandtl number - # Pr = mu * cp / k, approximate k from cp and Pr ~ 0.7-0.9 - Pr = 0.8 # Typical for combustion products - - # Area ratio - area_ratio = (D / Dt) ** 2 - - # Sigma correction factor for property variation across boundary layer - # sigma = 1 / [(Tw/Tc * (1 + (gamma-1)/2 * M^2) + 0.5)^0.68 * - # (1 + (gamma-1)/2 * M^2)^0.12] - gm1 = gamma - 1 - temp_factor = Tw / Tc * (1 + gm1/2 * local_mach**2) + 0.5 - sigma = 1 / (temp_factor ** 0.68 * (1 + gm1/2 * local_mach**2) ** 0.12) - - # Bartz correlation for heat transfer coefficient - # h = (0.026 / Dt^0.2) * (mu^0.2 * cp / Pr^0.6) * (pc/cstar)^0.8 * - # (Dt/Dt)^0.1 * (At/A)^0.9 * sigma - h = (0.026 / Dt**0.2) * (mu**0.2 * cp / Pr**0.6) * \ - (pc / cstar)**0.8 * (1/area_ratio)**0.9 * sigma - - # Calculate adiabatic wall temperature - r = recovery_factor(Pr) - T_aw = Tc * (1 + r * gm1/2 * local_mach**2) / (1 + gm1/2 * local_mach**2) - - # Heat flux - q = h * (T_aw - Tw) - - return Quantity(q, "Pa", "pressure") # W/m² = Pa·m/s, using Pa as proxy - - -@beartype -def estimate_heat_flux( - inputs: EngineInputs, - performance: EnginePerformance, - geometry: EngineGeometry, - location: str = "throat", - wall_temp: Quantity | None = None, -) -> Quantity: - """Estimate heat flux at a specific location in the engine. - - Provides a simplified interface to the Bartz correlation. - - Args: - inputs: Engine input parameters - performance: Computed engine performance - geometry: Computed engine geometry - location: Location to evaluate: "throat", "chamber", or "exit" - wall_temp: Wall temperature [K]. If None, estimates based on location. - - Returns: - Heat flux [W/m²] - """ - # Determine local conditions based on location - if location == "throat": - local_diameter = geometry.throat_diameter - local_mach = 1.0 - default_wall_temp = 700 # K, hot at throat - elif location == "chamber": - local_diameter = geometry.chamber_diameter - local_mach = 0.1 # Low Mach in chamber - default_wall_temp = 600 # K - elif location == "exit": - local_diameter = geometry.exit_diameter - local_mach = performance.exit_mach - default_wall_temp = 400 # K, cooler at exit - else: - raise ValueError(f"Unknown location: {location}. Use 'throat', 'chamber', or 'exit'") - - if wall_temp is None: - wall_temp = kelvin(default_wall_temp) - - return bartz_heat_flux( - chamber_pressure=inputs.chamber_pressure, - chamber_temp=inputs.chamber_temp, - throat_diameter=geometry.throat_diameter, - local_diameter=local_diameter, - characteristic_velocity=performance.cstar, - gamma=inputs.gamma, - molecular_weight=inputs.molecular_weight, - local_mach=local_mach, - wall_temp=wall_temp, - ) - - -@beartype -def heat_flux_profile( - inputs: EngineInputs, - performance: EnginePerformance, - geometry: EngineGeometry, - n_points: int = 50, - wall_temp: Quantity | None = None, -) -> tuple[list[float], list[float]]: - """Calculate heat flux profile along the engine. - - Returns heat flux from chamber through throat to exit. - - Args: - inputs: Engine input parameters - performance: Computed engine performance - geometry: Computed engine geometry - n_points: Number of points in profile - wall_temp: Wall temperature (constant along length) - - Returns: - Tuple of (x_positions, heat_fluxes) where x is normalized (0=chamber, 1=exit) - """ - # Get key dimensions - Dc = geometry.chamber_diameter.to("m").value - Dt = geometry.throat_diameter.to("m").value - De = geometry.exit_diameter.to("m").value - - # Generate normalized positions - x_norm = np.linspace(0, 1, n_points) - - # Estimate local diameter and Mach number along engine - # Simplified: linear convergent, bell divergent - diameters = [] - machs = [] - - for x in x_norm: - if x < 0.3: - # Chamber region - D = Dc - M = 0.1 + x * 0.3 # Low, slowly increasing - elif x < 0.5: - # Convergent section - frac = (x - 0.3) / 0.2 - D = Dc - frac * (Dc - Dt) - M = 0.1 + frac * 0.9 - elif x < 0.55: - # Throat region - D = Dt - M = 1.0 - else: - # Divergent section - frac = (x - 0.55) / 0.45 - D = Dt + frac * (De - Dt) - # Simple approximation for supersonic Mach - M = 1.0 + frac * (performance.exit_mach - 1.0) - - diameters.append(D) - machs.append(max(0.1, M)) - - # Calculate heat flux at each point - heat_fluxes = [] - for D, M in zip(diameters, machs, strict=True): - q = bartz_heat_flux( - chamber_pressure=inputs.chamber_pressure, - chamber_temp=inputs.chamber_temp, - throat_diameter=geometry.throat_diameter, - local_diameter=Quantity(D, "m", "length"), - characteristic_velocity=performance.cstar, - gamma=inputs.gamma, - molecular_weight=inputs.molecular_weight, - local_mach=M, - wall_temp=wall_temp or kelvin(600), - ) - heat_fluxes.append(q.value) - - return list(x_norm), heat_fluxes - diff --git a/openrocketengine/thermal/regenerative.py b/openrocketengine/thermal/regenerative.py deleted file mode 100644 index 820454f..0000000 --- a/openrocketengine/thermal/regenerative.py +++ /dev/null @@ -1,452 +0,0 @@ -"""Regenerative cooling feasibility analysis. - -Regenerative cooling uses the fuel (or oxidizer) as a coolant, flowing -through channels in the chamber/nozzle wall before injection. This is -the most common cooling method for high-performance rocket engines. - -This module provides screening-level analysis to determine if a given -engine design can be regeneratively cooled within material limits. - -Key considerations: -- Heat flux at the throat (highest) -- Coolant heat capacity and flow rate -- Wall material temperature limits -- Coolant-side pressure drop - -References: - - Huzel & Huang, Chapter 4 - - Sutton & Biblarz, Chapter 8 -""" - -import math -from dataclasses import dataclass - -from beartype import beartype - -from openrocketengine.engine import EngineGeometry, EngineInputs, EnginePerformance -from openrocketengine.thermal.heat_flux import estimate_heat_flux -from openrocketengine.units import Quantity, kelvin, pascals - - -# ============================================================================= -# Coolant Properties Database -# ============================================================================= - - -@beartype -@dataclass(frozen=True, slots=True) -class CoolantProperties: - """Thermophysical properties of a coolant. - - Properties are approximate values at typical operating conditions. - For detailed design, use property tables or CoolProp. - - Attributes: - name: Coolant name - density: Liquid density [kg/m³] - specific_heat: Specific heat capacity [J/(kg·K)] - thermal_conductivity: Thermal conductivity [W/(m·K)] - viscosity: Dynamic viscosity [Pa·s] - boiling_point: Boiling point at 1 atm [K] - max_temp: Maximum recommended temperature before decomposition [K] - """ - - name: str - density: float - specific_heat: float - thermal_conductivity: float - viscosity: float - boiling_point: float - max_temp: float - - -# Coolant property database -# Values are approximate at typical inlet conditions -COOLANT_DATABASE: dict[str, CoolantProperties] = { - "RP1": CoolantProperties( - name="RP-1 (Kerosene)", - density=810.0, - specific_heat=2000.0, - thermal_conductivity=0.12, - viscosity=0.0015, - boiling_point=490.0, - max_temp=600.0, # Coking limit - ), - "CH4": CoolantProperties( - name="Liquid Methane", - density=422.0, - specific_heat=3500.0, - thermal_conductivity=0.19, - viscosity=0.00012, - boiling_point=111.0, - max_temp=500.0, # Before significant decomposition - ), - "LH2": CoolantProperties( - name="Liquid Hydrogen", - density=70.8, - specific_heat=14300.0, - thermal_conductivity=0.10, - viscosity=0.000013, - boiling_point=20.0, - max_temp=300.0, # Stays liquid/supercritical - ), - "Ethanol": CoolantProperties( - name="Ethanol", - density=789.0, - specific_heat=2440.0, - thermal_conductivity=0.17, - viscosity=0.0011, - boiling_point=351.0, - max_temp=500.0, - ), - "N2O4": CoolantProperties( - name="Nitrogen Tetroxide", - density=1450.0, - specific_heat=1560.0, - thermal_conductivity=0.12, - viscosity=0.0004, - boiling_point=294.0, - max_temp=400.0, - ), - "MMH": CoolantProperties( - name="Monomethylhydrazine", - density=878.0, - specific_heat=2920.0, - thermal_conductivity=0.22, - viscosity=0.0008, - boiling_point=360.0, - max_temp=450.0, - ), -} - - -@beartype -def get_coolant_properties(coolant: str) -> CoolantProperties: - """Get properties for a coolant. - - Args: - coolant: Coolant name (e.g., "RP1", "CH4", "LH2") - - Returns: - CoolantProperties for the coolant - - Raises: - ValueError: If coolant not found in database - """ - # Normalize name - name = coolant.upper().replace("-", "").replace(" ", "") - - for key, props in COOLANT_DATABASE.items(): - if key.upper() == name: - return props - - available = list(COOLANT_DATABASE.keys()) - raise ValueError(f"Unknown coolant '{coolant}'. Available: {available}") - - -# ============================================================================= -# Cooling Feasibility Analysis -# ============================================================================= - - -@beartype -@dataclass(frozen=True, slots=True) -class CoolingFeasibility: - """Results of regenerative cooling feasibility analysis. - - Attributes: - feasible: Whether cooling is feasible within constraints - max_wall_temp: Maximum predicted wall temperature [K] - max_allowed_temp: Maximum allowed wall temperature [K] - coolant_temp_rise: Temperature rise of coolant [K] - coolant_outlet_temp: Predicted coolant outlet temperature [K] - throat_heat_flux: Heat flux at throat [W/m²] - total_heat_load: Total heat load to coolant [W] - required_coolant_flow: Minimum required coolant flow [kg/s] - available_coolant_flow: Available coolant flow (fuel or ox) [kg/s] - flow_margin: Ratio of available/required flow [-] - pressure_drop: Estimated coolant-side pressure drop [Pa] - channel_velocity: Estimated coolant velocity in channels [m/s] - warnings: List of warnings or concerns - """ - - feasible: bool - max_wall_temp: Quantity - max_allowed_temp: Quantity - coolant_temp_rise: Quantity - coolant_outlet_temp: Quantity - throat_heat_flux: Quantity - total_heat_load: Quantity - required_coolant_flow: Quantity - available_coolant_flow: Quantity - flow_margin: float - pressure_drop: Quantity - channel_velocity: float - warnings: list[str] - - -@beartype -def check_cooling_feasibility( - inputs: EngineInputs, - performance: EnginePerformance, - geometry: EngineGeometry, - coolant: str, - coolant_inlet_temp: Quantity | None = None, - max_wall_temp: Quantity | None = None, - wall_material: str = "copper_alloy", - num_channels: int | None = None, - channel_aspect_ratio: float = 3.0, -) -> CoolingFeasibility: - """Check if regenerative cooling is feasible for this engine design. - - This is a screening-level analysis that estimates whether the engine - can be cooled within material limits using the available coolant flow. - - Args: - inputs: Engine input parameters - performance: Computed engine performance - geometry: Computed engine geometry - coolant: Coolant name (typically the fuel: "RP1", "CH4", "LH2") - coolant_inlet_temp: Coolant inlet temperature [K]. Defaults to storage temp. - max_wall_temp: Maximum allowed wall temperature [K]. Defaults based on material. - wall_material: Wall material for thermal limits - num_channels: Number of cooling channels. If None, estimated. - channel_aspect_ratio: Channel height/width ratio - - Returns: - CoolingFeasibility assessment - """ - warnings: list[str] = [] - - # Get coolant properties - try: - coolant_props = get_coolant_properties(coolant) - except ValueError: - # Use generic properties if unknown - coolant_props = CoolantProperties( - name=coolant, - density=800.0, - specific_heat=2000.0, - thermal_conductivity=0.15, - viscosity=0.001, - boiling_point=300.0, - max_temp=500.0, - ) - warnings.append(f"Unknown coolant '{coolant}', using generic properties") - - # Set default inlet temperature (storage temperature) - if coolant_inlet_temp is None: - # Cryogenic propellants near boiling point, storables at ~300K - if coolant.upper() in ["LH2", "CH4", "LOX"]: - T_inlet = coolant_props.boiling_point + 10 # Just above boiling - else: - T_inlet = 300.0 # Room temperature - else: - T_inlet = coolant_inlet_temp.to("K").value - - # Set default max wall temperature based on material - if max_wall_temp is None: - wall_temps = { - "copper_alloy": 800, # GRCop-84, NARloy-Z - "nickel_alloy": 1000, # Inconel 718 - "steel": 700, # Stainless steel - "niobium": 1500, # Refractory metal - } - T_wall_max = wall_temps.get(wall_material, 800) - else: - T_wall_max = max_wall_temp.to("K").value - - # Estimate heat flux at throat (worst case) - q_throat = estimate_heat_flux( - inputs, performance, geometry, - location="throat", - wall_temp=kelvin(T_wall_max * 0.9), # Assume wall near limit - ) - - # Also get chamber and exit heat fluxes for total heat load - q_chamber = estimate_heat_flux(inputs, performance, geometry, location="chamber") - q_exit = estimate_heat_flux(inputs, performance, geometry, location="exit") - - # Estimate total heat load - # Simplified: use average heat flux × total surface area - Dt = geometry.throat_diameter.to("m").value - Dc = geometry.chamber_diameter.to("m").value - De = geometry.exit_diameter.to("m").value - Lc = geometry.chamber_length.to("m").value - Ln = geometry.nozzle_length.to("m").value - - # Surface areas (approximate) - A_chamber = math.pi * Dc * Lc - A_convergent = math.pi * (Dc + Dt) / 2 * (Dc - Dt) / (2 * math.tan(math.radians(45))) * 0.5 - A_throat = math.pi * Dt * Dt * 0.1 # Small throat region - A_divergent = math.pi * (Dt + De) / 2 * Ln * 0.7 # Approximate bell surface - - # Average heat fluxes for each region (throat is highest) - q_avg_chamber = q_chamber.value * 0.3 # Lower than throat - q_avg_convergent = (q_chamber.value + q_throat.value) / 2 - q_avg_throat = q_throat.value - q_avg_divergent = (q_throat.value + q_exit.value) / 2 - - # Total heat load - Q_total = ( - q_avg_chamber * A_chamber + - q_avg_convergent * A_convergent + - q_avg_throat * A_throat + - q_avg_divergent * A_divergent - ) - - # Available coolant flow (assume fuel is coolant) - mdot_coolant_available = performance.mdot_fuel.to("kg/s").value - - # Required coolant flow to absorb heat without exceeding temperature limit - # Q = mdot * cp * delta_T - # delta_T_max = T_coolant_max - T_inlet - T_coolant_max = min(coolant_props.max_temp, T_wall_max - 100) # Stay below wall - delta_T_max = T_coolant_max - T_inlet - - if delta_T_max <= 0: - warnings.append("Coolant inlet temperature exceeds maximum allowable") - delta_T_max = 100 # Use minimum for calculation - - mdot_coolant_required = Q_total / (coolant_props.specific_heat * delta_T_max) - - # Flow margin - flow_margin = mdot_coolant_available / mdot_coolant_required if mdot_coolant_required > 0 else float('inf') - - # Actual temperature rise with available flow - if mdot_coolant_available > 0: - delta_T_actual = Q_total / (mdot_coolant_available * coolant_props.specific_heat) - else: - delta_T_actual = float('inf') - - T_coolant_out = T_inlet + delta_T_actual - - # Estimate wall temperature - # T_wall = T_coolant + Q/(h_coolant * A) - # For screening, use correlation: T_wall ~ T_coolant + q * (t_wall / k_wall + 1/h_coolant) - # Simplified: wall runs ~100-200K above coolant - T_wall_estimate = T_coolant_out + 150 # K above coolant - - # Pressure drop estimation - # Number of channels (estimate if not provided) - if num_channels is None: - # Roughly 1 channel per mm of circumference at throat - num_channels = max(20, int(math.pi * Dt * 1000)) - - # Channel dimensions (rough estimate) - channel_width = (math.pi * Dt) / num_channels * 0.6 # 60% channel, 40% rib - channel_height = channel_width * channel_aspect_ratio - channel_area = channel_width * channel_height - - # Coolant velocity - total_channel_area = num_channels * channel_area - v_coolant = mdot_coolant_available / (coolant_props.density * total_channel_area) - - # Pressure drop (Darcy-Weisbach approximation) - # ΔP = f * (L/D_h) * (ρ * v²/2) - D_h = 4 * channel_area / (2 * (channel_width + channel_height)) # Hydraulic diameter - Re = coolant_props.density * v_coolant * D_h / coolant_props.viscosity - f = 0.316 / Re**0.25 if Re > 2300 else 64 / max(Re, 100) # Friction factor - - L_total = Lc + Ln # Total cooled length - dp = f * (L_total / D_h) * (coolant_props.density * v_coolant**2 / 2) - - # Add losses for bends, manifolds - dp *= 1.5 - - # Feasibility assessment - feasible = True - - if T_wall_estimate > T_wall_max: - feasible = False - warnings.append( - f"Estimated wall temp {T_wall_estimate:.0f}K exceeds limit {T_wall_max:.0f}K" - ) - - if flow_margin < 1.0: - feasible = False - warnings.append( - f"Insufficient coolant flow: need {mdot_coolant_required:.2f} kg/s, " - f"have {mdot_coolant_available:.2f} kg/s" - ) - - if T_coolant_out > coolant_props.max_temp: - warnings.append( - f"Coolant outlet temp {T_coolant_out:.0f}K exceeds max {coolant_props.max_temp:.0f}K" - ) - - if v_coolant > 50: - warnings.append(f"High coolant velocity {v_coolant:.1f} m/s may cause erosion") - - if dp > 5e6: - warnings.append(f"High pressure drop {dp/1e6:.1f} MPa") - - # Heat flux at throat check - if q_throat.value > 50e6: # > 50 MW/m² - warnings.append( - f"Very high throat heat flux {q_throat.value/1e6:.1f} MW/m² - " - "film cooling may be needed" - ) - - return CoolingFeasibility( - feasible=feasible, - max_wall_temp=kelvin(T_wall_estimate), - max_allowed_temp=kelvin(T_wall_max), - coolant_temp_rise=kelvin(delta_T_actual), - coolant_outlet_temp=kelvin(T_coolant_out), - throat_heat_flux=q_throat, - total_heat_load=Quantity(Q_total, "N", "force"), # W, using N as proxy - required_coolant_flow=Quantity(mdot_coolant_required, "kg/s", "mass_flow"), - available_coolant_flow=Quantity(mdot_coolant_available, "kg/s", "mass_flow"), - flow_margin=flow_margin, - pressure_drop=pascals(dp), - channel_velocity=v_coolant, - warnings=warnings, - ) - - -@beartype -def format_cooling_summary(result: CoolingFeasibility) -> str: - """Format cooling feasibility results as readable string. - - Args: - result: CoolingFeasibility from check_cooling_feasibility() - - Returns: - Formatted multi-line string - """ - status = "✓ FEASIBLE" if result.feasible else "✗ NOT FEASIBLE" - - lines = [ - f"{'=' * 60}", - f"REGENERATIVE COOLING ANALYSIS", - f"Status: {status}", - f"{'=' * 60}", - "", - "THERMAL:", - f" Throat Heat Flux: {result.throat_heat_flux.value/1e6:.1f} MW/m²", - f" Total Heat Load: {result.total_heat_load.value/1e6:.2f} MW", - f" Max Wall Temp: {result.max_wall_temp.value:.0f} K", - f" Allowed Wall Temp: {result.max_allowed_temp.value:.0f} K", - "", - "COOLANT:", - f" Temperature Rise: {result.coolant_temp_rise.value:.0f} K", - f" Outlet Temperature: {result.coolant_outlet_temp.value:.0f} K", - f" Required Flow: {result.required_coolant_flow.value:.2f} kg/s", - f" Available Flow: {result.available_coolant_flow.value:.2f} kg/s", - f" Flow Margin: {result.flow_margin:.2f}x", - "", - "HYDRAULICS:", - f" Channel Velocity: {result.channel_velocity:.1f} m/s", - f" Pressure Drop: {result.pressure_drop.to('bar').value:.1f} bar", - ] - - if result.warnings: - lines.extend(["", "WARNINGS:"]) - for warning in result.warnings: - lines.append(f" ⚠ {warning}") - - lines.append(f"{'=' * 60}") - - return "\n".join(lines) - diff --git a/openrocketengine/units.py b/openrocketengine/units.py deleted file mode 100644 index 02af45d..0000000 --- a/openrocketengine/units.py +++ /dev/null @@ -1,651 +0,0 @@ -"""Units module for OpenRocketEngine. - -Provides a Quantity class for type-safe physical quantities with unit conversion. -All physical values in the library should use Quantity, never bare floats. - -Design principles: -- Explicit over implicit: all conversions require calling .to() -- Type safe: beartype checks at runtime -- Immutable: frozen dataclasses prevent accidental mutation -- No magic: clear, predictable behavior -""" - -import math -from dataclasses import dataclass - -from beartype import beartype - -# ============================================================================= -# Dimension and Unit Definitions -# ============================================================================= - -# Dimensions represent physical quantities (length, mass, time, etc.) -# Each dimension has a base SI unit - -DIMENSIONS = { - "length": "m", - "mass": "kg", - "time": "s", - "temperature": "K", - "force": "N", - "pressure": "Pa", - "velocity": "m/s", - "area": "m^2", - "volume": "m^3", - "mass_flow": "kg/s", - "density": "kg/m^3", - "specific_impulse": "s", - "dimensionless": "1", -} - -# Conversion factors TO base SI unit -# e.g., 1 ft = 0.3048 m, so CONVERSIONS["ft"] = 0.3048 -CONVERSIONS: dict[str, tuple[float, str]] = { - # Length - "m": (1.0, "length"), - "cm": (0.01, "length"), - "mm": (0.001, "length"), - "km": (1000.0, "length"), - "ft": (0.3048, "length"), - "in": (0.0254, "length"), - "inch": (0.0254, "length"), - "inches": (0.0254, "length"), - # Mass - "kg": (1.0, "mass"), - "g": (0.001, "mass"), - "lbm": (0.453592, "mass"), - "slug": (14.5939, "mass"), - # Time - "s": (1.0, "time"), - "ms": (0.001, "time"), - "min": (60.0, "time"), - "hr": (3600.0, "time"), - # Temperature (special - requires offset handling) - "K": (1.0, "temperature"), - "R": (5 / 9, "temperature"), # Rankine to Kelvin (multiply only, offset handled separately) - # Force - "N": (1.0, "force"), - "kN": (1000.0, "force"), - "MN": (1e6, "force"), - "lbf": (4.44822, "force"), - "kgf": (9.80665, "force"), - # Pressure - "Pa": (1.0, "pressure"), - "kPa": (1000.0, "pressure"), - "MPa": (1e6, "pressure"), - "bar": (1e5, "pressure"), - "atm": (101325.0, "pressure"), - "psi": (6894.76, "pressure"), - "psia": (6894.76, "pressure"), - # Velocity - "m/s": (1.0, "velocity"), - "km/s": (1000.0, "velocity"), - "ft/s": (0.3048, "velocity"), - # Area - "m^2": (1.0, "area"), - "cm^2": (1e-4, "area"), - "mm^2": (1e-6, "area"), - "ft^2": (0.092903, "area"), - "in^2": (0.00064516, "area"), - # Volume - "m^3": (1.0, "volume"), - "L": (0.001, "volume"), - "cm^3": (1e-6, "volume"), - "ft^3": (0.0283168, "volume"), - "in^3": (1.6387e-5, "volume"), - # Mass flow rate - "kg/s": (1.0, "mass_flow"), - "lbm/s": (0.453592, "mass_flow"), - # Density - "kg/m^3": (1.0, "density"), - "lbm/ft^3": (16.0185, "density"), - # Power - "W": (1.0, "power"), - "kW": (1000.0, "power"), - "MW": (1e6, "power"), - "hp": (745.7, "power"), # Mechanical horsepower - # Specific impulse (time dimension but special meaning) - # Note: Isp in seconds is the same in SI and Imperial - # Dimensionless - "1": (1.0, "dimensionless"), - "": (1.0, "dimensionless"), -} - - -def _get_dimension(unit: str) -> str: - """Get the dimension for a unit string.""" - if unit not in CONVERSIONS: - raise ValueError(f"Unknown unit: {unit!r}") - return CONVERSIONS[unit][1] - - -def _get_conversion_factor(unit: str) -> float: - """Get the conversion factor to SI base unit.""" - if unit not in CONVERSIONS: - raise ValueError(f"Unknown unit: {unit!r}") - return CONVERSIONS[unit][0] - - -def _convert(value: float, from_unit: str, to_unit: str) -> float: - """Convert a value between units of the same dimension.""" - from_dim = _get_dimension(from_unit) - to_dim = _get_dimension(to_unit) - - if from_dim != to_dim: - raise ValueError( - f"Cannot convert between different dimensions: {from_dim} and {to_dim}" - ) - - # Convert to SI base, then to target - si_value = value * _get_conversion_factor(from_unit) - return si_value / _get_conversion_factor(to_unit) - - -# ============================================================================= -# Quantity Class -# ============================================================================= - - -@beartype -@dataclass(frozen=True, slots=True) -class Quantity: - """A physical quantity with value, unit, and dimension. - - Quantities are immutable and support arithmetic operations that respect - dimensional analysis. - - Examples: - >>> thrust = Quantity(50000, "N", "force") - >>> thrust_lbf = thrust.to("lbf") - >>> print(thrust_lbf) - Quantity(11240.45 lbf) - - >>> length = meters(2.5) - >>> area = length * length # Returns Quantity with area dimension - """ - - value: float | int - unit: str - dimension: str - - def __post_init__(self) -> None: - """Validate that unit matches dimension.""" - if self.unit not in CONVERSIONS: - raise ValueError(f"Unknown unit: {self.unit!r}") - expected_dim = CONVERSIONS[self.unit][1] - if self.dimension != expected_dim: - raise ValueError( - f"Unit {self.unit!r} has dimension {expected_dim!r}, " - f"but {self.dimension!r} was specified" - ) - - def to(self, target_unit: str) -> "Quantity": - """Convert to a different unit of the same dimension. - - Args: - target_unit: The unit to convert to - - Returns: - A new Quantity with the converted value and new unit - - Raises: - ValueError: If target_unit is incompatible dimension - """ - new_value = _convert(self.value, self.unit, target_unit) - return Quantity(new_value, target_unit, self.dimension) - - def to_si(self) -> "Quantity": - """Convert to SI base unit for this dimension.""" - si_unit = DIMENSIONS[self.dimension] - return self.to(si_unit) - - @property - def si_value(self) -> float: - """Get the value in SI base units without creating new Quantity.""" - return self.value * _get_conversion_factor(self.unit) - - def __repr__(self) -> str: - return f"Quantity({self.value:.6g} {self.unit})" - - def __str__(self) -> str: - return f"{self.value:.6g} {self.unit}" - - # ------------------------------------------------------------------------- - # Arithmetic Operations - # ------------------------------------------------------------------------- - - def __add__(self, other: "Quantity") -> "Quantity": - """Add two quantities of the same dimension.""" - if not isinstance(other, Quantity): - raise TypeError(f"Cannot add Quantity and {type(other).__name__}") - if self.dimension != other.dimension: - raise ValueError( - f"Cannot add quantities with different dimensions: " - f"{self.dimension} and {other.dimension}" - ) - # Convert other to same unit as self, then add - other_converted = other.to(self.unit) - return Quantity(self.value + other_converted.value, self.unit, self.dimension) - - def __sub__(self, other: "Quantity") -> "Quantity": - """Subtract two quantities of the same dimension.""" - if not isinstance(other, Quantity): - raise TypeError(f"Cannot subtract Quantity and {type(other).__name__}") - if self.dimension != other.dimension: - raise ValueError( - f"Cannot subtract quantities with different dimensions: " - f"{self.dimension} and {other.dimension}" - ) - other_converted = other.to(self.unit) - return Quantity(self.value - other_converted.value, self.unit, self.dimension) - - def __mul__(self, other: "Quantity | float | int") -> "Quantity": - """Multiply by a scalar or another quantity.""" - if isinstance(other, (int, float)): - return Quantity(self.value * other, self.unit, self.dimension) - if isinstance(other, Quantity): - # Dimensional multiplication - result dimension depends on operands - new_dim, new_unit = _multiply_dimensions( - self.dimension, self.unit, other.dimension, other.unit - ) - new_value = self.si_value * other.si_value - # Convert back from SI to the derived unit - new_value = new_value / _get_conversion_factor(new_unit) - return Quantity(new_value, new_unit, new_dim) - raise TypeError(f"Cannot multiply Quantity by {type(other).__name__}") - - def __rmul__(self, other: float | int) -> "Quantity": - """Right multiply by scalar.""" - if isinstance(other, (int, float)): - return Quantity(self.value * other, self.unit, self.dimension) - raise TypeError(f"Cannot multiply {type(other).__name__} by Quantity") - - def __truediv__(self, other: "Quantity | float | int") -> "Quantity": - """Divide by a scalar or another quantity.""" - if isinstance(other, (int, float)): - return Quantity(self.value / other, self.unit, self.dimension) - if isinstance(other, Quantity): - new_dim, new_unit = _divide_dimensions( - self.dimension, self.unit, other.dimension, other.unit - ) - new_value = self.si_value / other.si_value - new_value = new_value / _get_conversion_factor(new_unit) - return Quantity(new_value, new_unit, new_dim) - raise TypeError(f"Cannot divide Quantity by {type(other).__name__}") - - def __rtruediv__(self, other: float | int) -> "Quantity": - """Right division (scalar / Quantity) - returns inverse dimension.""" - if isinstance(other, (int, float)): - # This would create an inverse dimension which we don't fully support - # For now, raise an error - raise TypeError( - "Division of scalar by Quantity not supported. " - "Use explicit inverse units instead." - ) - raise TypeError(f"Cannot divide {type(other).__name__} by Quantity") - - def __neg__(self) -> "Quantity": - """Negate the quantity.""" - return Quantity(-self.value, self.unit, self.dimension) - - def __pos__(self) -> "Quantity": - """Positive (returns copy).""" - return Quantity(self.value, self.unit, self.dimension) - - def __abs__(self) -> "Quantity": - """Absolute value.""" - return Quantity(abs(self.value), self.unit, self.dimension) - - # ------------------------------------------------------------------------- - # Comparison Operations - # ------------------------------------------------------------------------- - - def __eq__(self, other: object) -> bool: - """Check equality (compares SI values for same dimension).""" - if not isinstance(other, Quantity): - return NotImplemented - if self.dimension != other.dimension: - return False - # Compare in SI units to handle unit differences - return math.isclose(self.si_value, other.si_value, rel_tol=1e-9) - - def __lt__(self, other: "Quantity") -> bool: - if not isinstance(other, Quantity): - raise TypeError(f"Cannot compare Quantity with {type(other).__name__}") - if self.dimension != other.dimension: - raise ValueError("Cannot compare quantities with different dimensions") - return self.si_value < other.si_value - - def __le__(self, other: "Quantity") -> bool: - return self == other or self < other - - def __gt__(self, other: "Quantity") -> bool: - if not isinstance(other, Quantity): - raise TypeError(f"Cannot compare Quantity with {type(other).__name__}") - if self.dimension != other.dimension: - raise ValueError("Cannot compare quantities with different dimensions") - return self.si_value > other.si_value - - def __ge__(self, other: "Quantity") -> bool: - return self == other or self > other - - def __hash__(self) -> int: - """Hash based on SI value and dimension for consistency.""" - return hash((round(self.si_value, 9), self.dimension)) - - -# ============================================================================= -# Dimension Algebra -# ============================================================================= - -# Multiplication table for dimensions -_MULT_TABLE: dict[tuple[str, str], str] = { - ("length", "length"): "area", - ("area", "length"): "volume", - ("length", "area"): "volume", - ("velocity", "time"): "length", - ("mass", "velocity"): "force", # Approximation: momentum - ("force", "length"): "force", # Work/energy - simplified - ("pressure", "area"): "force", - ("mass_flow", "velocity"): "force", - ("mass_flow", "time"): "mass", - ("density", "volume"): "mass", - ("dimensionless", "length"): "length", - ("dimensionless", "mass"): "mass", - ("dimensionless", "force"): "force", - ("dimensionless", "pressure"): "pressure", - ("dimensionless", "velocity"): "velocity", - ("dimensionless", "area"): "area", - ("dimensionless", "volume"): "volume", - ("dimensionless", "time"): "time", - ("dimensionless", "temperature"): "temperature", - ("dimensionless", "mass_flow"): "mass_flow", - ("dimensionless", "density"): "density", - ("dimensionless", "dimensionless"): "dimensionless", -} - -# Division table for dimensions -_DIV_TABLE: dict[tuple[str, str], str] = { - ("area", "length"): "length", - ("volume", "length"): "area", - ("volume", "area"): "length", - ("length", "time"): "velocity", - ("velocity", "time"): "velocity", # acceleration - simplified - ("mass", "volume"): "density", - ("mass", "time"): "mass_flow", - ("force", "area"): "pressure", - ("force", "mass"): "velocity", # acceleration simplified - ("force", "pressure"): "area", - ("force", "velocity"): "mass_flow", - ("length", "length"): "dimensionless", - ("mass", "mass"): "dimensionless", - ("force", "force"): "dimensionless", - ("pressure", "pressure"): "dimensionless", - ("area", "area"): "dimensionless", - ("volume", "volume"): "dimensionless", - ("velocity", "velocity"): "dimensionless", - ("time", "time"): "dimensionless", - ("dimensionless", "dimensionless"): "dimensionless", -} - - -def _multiply_dimensions( - dim1: str, unit1: str, dim2: str, unit2: str -) -> tuple[str, str]: - """Determine result dimension and unit for multiplication.""" - # Check both orderings - key = (dim1, dim2) - if key in _MULT_TABLE: - result_dim = _MULT_TABLE[key] - elif (dim2, dim1) in _MULT_TABLE: - result_dim = _MULT_TABLE[(dim2, dim1)] - else: - raise ValueError( - f"Multiplication of {dim1} and {dim2} not supported. " - "Result dimension is ambiguous." - ) - - result_unit = DIMENSIONS[result_dim] - return result_dim, result_unit - - -def _divide_dimensions(dim1: str, unit1: str, dim2: str, unit2: str) -> tuple[str, str]: - """Determine result dimension and unit for division.""" - key = (dim1, dim2) - if key in _DIV_TABLE: - result_dim = _DIV_TABLE[key] - else: - raise ValueError( - f"Division of {dim1} by {dim2} not supported. " - "Result dimension is ambiguous." - ) - - result_unit = DIMENSIONS[result_dim] - return result_dim, result_unit - - -# ============================================================================= -# Factory Functions - Clear, Explicit Quantity Creation -# ============================================================================= - - -@beartype -def meters(value: float | int) -> Quantity: - """Create a length quantity in meters.""" - return Quantity(value, "m", "length") - - -@beartype -def centimeters(value: float | int) -> Quantity: - """Create a length quantity in centimeters.""" - return Quantity(value, "cm", "length") - - -@beartype -def millimeters(value: float | int) -> Quantity: - """Create a length quantity in millimeters.""" - return Quantity(value, "mm", "length") - - -@beartype -def feet(value: float | int) -> Quantity: - """Create a length quantity in feet.""" - return Quantity(value, "ft", "length") - - -@beartype -def inches(value: float | int) -> Quantity: - """Create a length quantity in inches.""" - return Quantity(value, "in", "length") - - -@beartype -def kilograms(value: float | int) -> Quantity: - """Create a mass quantity in kilograms.""" - return Quantity(value, "kg", "mass") - - -@beartype -def pounds_mass(value: float | int) -> Quantity: - """Create a mass quantity in pounds-mass.""" - return Quantity(value, "lbm", "mass") - - -@beartype -def seconds(value: float | int) -> Quantity: - """Create a time quantity in seconds.""" - return Quantity(value, "s", "time") - - -@beartype -def kelvin(value: float | int) -> Quantity: - """Create a temperature quantity in Kelvin.""" - return Quantity(value, "K", "temperature") - - -@beartype -def rankine(value: float | int) -> Quantity: - """Create a temperature quantity in Rankine.""" - return Quantity(value, "R", "temperature") - - -@beartype -def newtons(value: float | int) -> Quantity: - """Create a force quantity in Newtons.""" - return Quantity(value, "N", "force") - - -@beartype -def kilonewtons(value: float | int) -> Quantity: - """Create a force quantity in kilonewtons.""" - return Quantity(value, "kN", "force") - - -@beartype -def pounds_force(value: float | int) -> Quantity: - """Create a force quantity in pounds-force.""" - return Quantity(value, "lbf", "force") - - -@beartype -def pascals(value: float | int) -> Quantity: - """Create a pressure quantity in Pascals.""" - return Quantity(value, "Pa", "pressure") - - -@beartype -def kilopascals(value: float | int) -> Quantity: - """Create a pressure quantity in kilopascals.""" - return Quantity(value, "kPa", "pressure") - - -@beartype -def megapascals(value: float | int) -> Quantity: - """Create a pressure quantity in megapascals.""" - return Quantity(value, "MPa", "pressure") - - -@beartype -def bar(value: float | int) -> Quantity: - """Create a pressure quantity in bar.""" - return Quantity(value, "bar", "pressure") - - -@beartype -def atmospheres(value: float | int) -> Quantity: - """Create a pressure quantity in atmospheres.""" - return Quantity(value, "atm", "pressure") - - -@beartype -def psi(value: float | int) -> Quantity: - """Create a pressure quantity in psi.""" - return Quantity(value, "psi", "pressure") - - -@beartype -def meters_per_second(value: float | int) -> Quantity: - """Create a velocity quantity in m/s.""" - return Quantity(value, "m/s", "velocity") - - -@beartype -def feet_per_second(value: float | int) -> Quantity: - """Create a velocity quantity in ft/s.""" - return Quantity(value, "ft/s", "velocity") - - -@beartype -def square_meters(value: float | int) -> Quantity: - """Create an area quantity in m^2.""" - return Quantity(value, "m^2", "area") - - -@beartype -def square_centimeters(value: float | int) -> Quantity: - """Create an area quantity in cm^2.""" - return Quantity(value, "cm^2", "area") - - -@beartype -def square_inches(value: float | int) -> Quantity: - """Create an area quantity in in^2.""" - return Quantity(value, "in^2", "area") - - -@beartype -def cubic_meters(value: float | int) -> Quantity: - """Create a volume quantity in m^3.""" - return Quantity(value, "m^3", "volume") - - -@beartype -def liters(value: float | int) -> Quantity: - """Create a volume quantity in liters.""" - return Quantity(value, "L", "volume") - - -@beartype -def kg_per_second(value: float | int) -> Quantity: - """Create a mass flow rate quantity in kg/s.""" - return Quantity(value, "kg/s", "mass_flow") - - -@beartype -def lbm_per_second(value: float | int) -> Quantity: - """Create a mass flow rate quantity in lbm/s.""" - return Quantity(value, "lbm/s", "mass_flow") - - -@beartype -def kg_per_cubic_meter(value: float | int) -> Quantity: - """Create a density quantity in kg/m^3.""" - return Quantity(value, "kg/m^3", "density") - - -@beartype -def dimensionless(value: float | int) -> Quantity: - """Create a dimensionless quantity.""" - return Quantity(value, "1", "dimensionless") - - -@beartype -def watts(value: float | int) -> Quantity: - """Create a power quantity in Watts.""" - return Quantity(value, "W", "power") - - -@beartype -def kilowatts(value: float | int) -> Quantity: - """Create a power quantity in kilowatts.""" - return Quantity(value, "kW", "power") - - -@beartype -def megawatts(value: float | int) -> Quantity: - """Create a power quantity in megawatts.""" - return Quantity(value, "MW", "power") - - -@beartype -def horsepower(value: float | int) -> Quantity: - """Create a power quantity in horsepower.""" - return Quantity(value, "hp", "power") - - -# ============================================================================= -# Constants -# ============================================================================= - -# Standard gravity -G0_SI = meters_per_second(9.80665) -G0_IMP = feet_per_second(32.174) - -# Standard atmospheric pressure -ATM_SI = pascals(101325.0) -ATM_IMP = psi(14.696) - -# Universal gas constant -R_UNIVERSAL_SI = 8314.46 # J/(kmol·K) - stored as float, used in calculations -R_UNIVERSAL_IMP = 1545.35 # ft·lbf/(lbmol·R) - diff --git a/tests/test_engine.py b/tests/test_engine.py index 2f6cb5c..3d5d8d3 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2,7 +2,7 @@ import pytest -from openrocketengine.engine import ( +from rocket.engine import ( EngineGeometry, EngineInputs, EnginePerformance, @@ -14,7 +14,7 @@ isp_at_altitude, thrust_at_altitude, ) -from openrocketengine.units import ( +from rocket.units import ( kelvin, megapascals, meters, diff --git a/tests/test_isentropic.py b/tests/test_isentropic.py index 47faf2a..bcd7f2f 100644 --- a/tests/test_isentropic.py +++ b/tests/test_isentropic.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from openrocketengine.isentropic import ( +from rocket.isentropic import ( G0_SI, R_UNIVERSAL_SI, area_ratio_from_mach, diff --git a/tests/test_nozzle.py b/tests/test_nozzle.py index a8496d3..59fc94b 100644 --- a/tests/test_nozzle.py +++ b/tests/test_nozzle.py @@ -7,15 +7,15 @@ import numpy as np import pytest -from openrocketengine.engine import EngineInputs, design_engine -from openrocketengine.nozzle import ( +from rocket.engine import EngineInputs, design_engine +from rocket.nozzle import ( NozzleContour, conical_contour, full_chamber_contour, generate_nozzle_from_geometry, rao_bell_contour, ) -from openrocketengine.units import kelvin, megapascals, meters, newtons, pascals +from rocket.units import kelvin, megapascals, meters, newtons, pascals class TestNozzleContour: diff --git a/tests/test_propellants.py b/tests/test_propellants.py index cb6fa6f..e108167 100644 --- a/tests/test_propellants.py +++ b/tests/test_propellants.py @@ -2,7 +2,7 @@ import pytest -from openrocketengine.propellants import ( +from rocket.propellants import ( CombustionProperties, get_combustion_properties, is_cea_available, @@ -142,8 +142,8 @@ class TestEngineInputsFromPropellants: def test_from_propellants_basic(self) -> None: """Test basic from_propellants usage.""" - from openrocketengine.engine import EngineInputs - from openrocketengine.units import kilonewtons, megapascals + from rocket.engine import EngineInputs + from rocket.units import kilonewtons, megapascals inputs = EngineInputs.from_propellants( oxidizer="LOX", @@ -162,8 +162,8 @@ def test_from_propellants_basic(self) -> None: def test_from_propellants_with_defaults(self) -> None: """Test from_propellants with default parameters.""" - from openrocketengine.engine import EngineInputs - from openrocketengine.units import newtons, pascals + from rocket.engine import EngineInputs + from rocket.units import newtons, pascals inputs = EngineInputs.from_propellants( oxidizer="LOX", @@ -181,8 +181,8 @@ def test_from_propellants_with_defaults(self) -> None: def test_from_propellants_full_workflow(self) -> None: """Test complete workflow from propellants to geometry.""" - from openrocketengine.engine import EngineInputs, design_engine - from openrocketengine.units import kilonewtons, megapascals + from rocket.engine import EngineInputs, design_engine + from rocket.units import kilonewtons, megapascals inputs = EngineInputs.from_propellants( oxidizer="LOX", @@ -204,8 +204,8 @@ def test_from_propellants_full_workflow(self) -> None: def test_from_propellants_custom_name(self) -> None: """Test custom engine name.""" - from openrocketengine.engine import EngineInputs - from openrocketengine.units import megapascals, newtons + from rocket.engine import EngineInputs + from rocket.units import megapascals, newtons inputs = EngineInputs.from_propellants( oxidizer="LOX", diff --git a/tests/test_units.py b/tests/test_units.py index 236c8db..2ac5f7e 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -3,7 +3,7 @@ import pytest -from openrocketengine.units import ( +from rocket.units import ( Quantity, atmospheres, bar, From 48d9f09cc4330b6359e5db5e19e06fd2061415e9 Mon Sep 17 00:00:00 2001 From: Cameron Flannery Date: Wed, 26 Nov 2025 11:09:41 -0800 Subject: [PATCH 4/5] improve types --- rocket/analysis.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/rocket/analysis.py b/rocket/analysis.py index 5cd1adf..169a084 100644 --- a/rocket/analysis.py +++ b/rocket/analysis.py @@ -1094,6 +1094,35 @@ def n_pareto(self) -> int: """Number of Pareto-optimal points.""" return len(self.pareto_indices) + @property + def n_total(self) -> int: + """Total number of designs evaluated.""" + return len(self.all_results.inputs) + + @property + def n_feasible(self) -> int: + """Number of designs that passed all constraints.""" + return self.all_results.n_feasible + + def pareto_front(self) -> pl.DataFrame: + """Get Pareto-optimal designs as a DataFrame. + + Returns: + DataFrame with parameters and metrics for Pareto-optimal points + """ + # Extract metrics for Pareto points only + pareto_metrics: dict[str, list[Any]] = {} + + # Add parameters + for param_name, param_values in self.all_results.parameters.items(): + pareto_metrics[param_name] = [param_values[i] for i in self.pareto_indices] + + # Add metrics + for metric_name, metric_values in self.all_results.metrics.items(): + pareto_metrics[metric_name] = [metric_values[i] for i in self.pareto_indices] + + return pl.DataFrame(pareto_metrics) + def get_best(self, objective: str) -> tuple[Any, Any, float]: """Get the best design for a specific objective. From ba7f2043cc5159f48143dcb1070b42c08a6e7926 Mon Sep 17 00:00:00 2001 From: Cameron Flannery Date: Wed, 26 Nov 2025 11:15:48 -0800 Subject: [PATCH 5/5] ruff --- rocket/__init__.py | 47 ++-- rocket/analysis.py | 5 +- rocket/cycles/__init__.py | 2 +- rocket/cycles/base.py | 11 +- rocket/cycles/gas_generator.py | 17 +- rocket/cycles/pressure_fed.py | 7 +- rocket/cycles/staged_combustion.py | 5 +- rocket/examples/cycle_comparison.py | 14 +- rocket/examples/propellant_design.py | 2 +- rocket/examples/thermal_analysis.py | 2 +- rocket/output.py | 5 +- rocket/results.py | 5 +- rocket/system.py | 3 +- rocket/thermal/__init__.py | 2 +- rocket/thermal/regenerative.py | 5 +- tests/test_tanks.py | 348 +++++++++++++++++++++++++++ 16 files changed, 402 insertions(+), 78 deletions(-) create mode 100644 tests/test_tanks.py diff --git a/rocket/__init__.py b/rocket/__init__.py index 60d5d51..a73a4be 100644 --- a/rocket/__init__.py +++ b/rocket/__init__.py @@ -24,6 +24,21 @@ __version__ = "0.2.0" # Core engine design +# Analysis framework +from rocket.analysis import ( + Distribution, + LogNormal, + MultiObjectiveOptimizer, + Normal, + ParametricStudy, + ParetoResults, + Range, + StudyResults, + Triangular, + UncertaintyAnalysis, + UncertaintyResults, + Uniform, +) from rocket.engine import ( EngineGeometry, EngineInputs, @@ -46,6 +61,14 @@ rao_bell_contour, ) +# Output management +from rocket.output import ( + OutputContext, + clean_outputs, + get_default_output_dir, + list_outputs, +) + # Visualization from rocket.plotting import ( plot_cycle_comparison_bars, @@ -67,30 +90,6 @@ list_database_propellants, ) -# Output management -from rocket.output import ( - OutputContext, - clean_outputs, - get_default_output_dir, - list_outputs, -) - -# Analysis framework -from rocket.analysis import ( - Distribution, - LogNormal, - MultiObjectiveOptimizer, - Normal, - ParametricStudy, - ParetoResults, - Range, - StudyResults, - Triangular, - UncertaintyAnalysis, - UncertaintyResults, - Uniform, -) - # System-level design from rocket.system import ( EngineSystemResult, diff --git a/rocket/analysis.py b/rocket/analysis.py index 169a084..7fe10e0 100644 --- a/rocket/analysis.py +++ b/rocket/analysis.py @@ -1138,10 +1138,7 @@ def get_best(self, objective: str) -> tuple[Any, Any, float]: obj_idx = self.objective_names.index(objective) values = self.pareto_objectives[:, obj_idx] - if self.maximize[obj_idx]: - best_idx = int(np.argmax(values)) - else: - best_idx = int(np.argmin(values)) + best_idx = int(np.argmax(values)) if self.maximize[obj_idx] else int(np.argmin(values)) return ( self.pareto_inputs[best_idx], diff --git a/rocket/cycles/__init__.py b/rocket/cycles/__init__.py index 23c6f3c..860ba89 100644 --- a/rocket/cycles/__init__.py +++ b/rocket/cycles/__init__.py @@ -15,7 +15,7 @@ >>> >>> inputs = EngineInputs.from_propellants("LOX", "CH4", ...) >>> performance, geometry = design_engine(inputs) - >>> + >>> >>> cycle = GasGeneratorCycle( ... turbine_inlet_temp=kelvin(900), ... pump_efficiency=0.70, diff --git a/rocket/cycles/base.py b/rocket/cycles/base.py index 63cff81..fd27b26 100644 --- a/rocket/cycles/base.py +++ b/rocket/cycles/base.py @@ -12,12 +12,12 @@ from beartype import beartype from rocket.engine import EngineGeometry, EngineInputs, EnginePerformance -from rocket.units import Quantity, pascals, seconds, kg_per_second +from rocket.units import Quantity, pascals class CycleType(Enum): """Engine cycle type enumeration.""" - + PRESSURE_FED = auto() GAS_GENERATOR = auto() EXPANDER = auto() @@ -250,7 +250,7 @@ def npsh_available( return pascals(npsh_pa) -@beartype +@beartype def estimate_line_losses( mass_flow: Quantity, density: float, @@ -288,10 +288,7 @@ def estimate_line_losses( # Friction factor (assuming turbulent flow, smooth pipe) # Using Blasius correlation as approximation Re = density * V * D / 1e-3 # Approximate viscosity - if Re > 2300: - f = 0.316 / Re ** 0.25 - else: - f = 64 / Re + f = 0.316 / Re ** 0.25 if Re > 2300 else 64 / Re # Pipe friction losses dp_pipe = f * (L / D) * q diff --git a/rocket/cycles/gas_generator.py b/rocket/cycles/gas_generator.py index 9b7be8e..723a1ab 100644 --- a/rocket/cycles/gas_generator.py +++ b/rocket/cycles/gas_generator.py @@ -17,12 +17,11 @@ Examples: - SpaceX Merlin (LOX/RP-1) -- Rocketdyne F-1 (LOX/RP-1) +- Rocketdyne F-1 (LOX/RP-1) - RS-68 (LOX/LH2) - Vulcain (LOX/LH2) """ -import math from dataclasses import dataclass from beartype import beartype @@ -30,16 +29,13 @@ from rocket.cycles.base import ( CyclePerformance, CycleType, - estimate_line_losses, npsh_available, pump_power, - turbine_power, ) from rocket.engine import EngineGeometry, EngineInputs, EnginePerformance from rocket.tanks import get_propellant_density from rocket.units import Quantity, kelvin, kg_per_second, pascals, seconds - # Typical vapor pressures for common propellants [Pa] VAPOR_PRESSURES: dict[str, float] = { "LOX": 101325, @@ -219,8 +215,8 @@ def analyze( ) # GG propellant split - mdot_gg_ox = mdot_gg * self.gg_mixture_ratio / (1 + self.gg_mixture_ratio) - mdot_gg_fuel = mdot_gg / (1 + self.gg_mixture_ratio) + # mdot_gg_ox = mdot_gg * self.gg_mixture_ratio / (1 + self.gg_mixture_ratio) + # mdot_gg_fuel = mdot_gg / (1 + self.gg_mixture_ratio) # Net performance calculation # The GG exhaust has much lower velocity than main chamber @@ -238,7 +234,7 @@ def analyze( # But we've "spent" mdot_gg propellant for low-Isp exhaust F_main = thrust net_thrust = F_main # GG exhaust typically dumps to atmosphere - + # Effective total mass flow (main + GG) mdot_effective = mdot_total # GG flow comes from same tanks @@ -333,10 +329,7 @@ def estimate_turbopump_mass( # Historical correlation: mass ~ k * P^0.6 # k varies by propellant type and technology level - if "LH2" in propellant_type.upper(): - k = 0.015 # LH2 pumps are larger due to low density - else: - k = 0.008 # LOX/RP-1, LOX/CH4 + k = 0.015 if "LH2" in propellant_type.upper() else 0.008 # LH2 pumps are larger due to low density mass = k * P ** 0.6 diff --git a/rocket/cycles/pressure_fed.py b/rocket/cycles/pressure_fed.py index d56c502..6d2440c 100644 --- a/rocket/cycles/pressure_fed.py +++ b/rocket/cycles/pressure_fed.py @@ -32,8 +32,7 @@ ) from rocket.engine import EngineGeometry, EngineInputs, EnginePerformance from rocket.tanks import get_propellant_density -from rocket.units import Quantity, kg_per_second, pascals, seconds - +from rocket.units import Quantity, kg_per_second, pascals # Typical vapor pressures for common propellants [Pa] # At nominal storage temperatures @@ -53,11 +52,11 @@ def _get_vapor_pressure(propellant: str) -> float: """Get vapor pressure for a propellant.""" # Normalize name name = propellant.upper().replace("-", "").replace(" ", "") - + for key, value in VAPOR_PRESSURES.items(): if key.upper() == name: return value - + # Default to low vapor pressure if unknown return 1000.0 diff --git a/rocket/cycles/staged_combustion.py b/rocket/cycles/staged_combustion.py index 21acb94..743884b 100644 --- a/rocket/cycles/staged_combustion.py +++ b/rocket/cycles/staged_combustion.py @@ -27,7 +27,6 @@ - Humble, Henry & Larson, "Space Propulsion Analysis and Design" """ -import math from dataclasses import dataclass from beartype import beartype @@ -40,8 +39,7 @@ ) from rocket.engine import EngineGeometry, EngineInputs, EnginePerformance from rocket.tanks import get_propellant_density -from rocket.units import Quantity, kelvin, kg_per_second, pascals, seconds - +from rocket.units import Quantity, kg_per_second, pascals, seconds # Typical vapor pressures for common propellants [Pa] VAPOR_PRESSURES: dict[str, float] = { @@ -119,7 +117,6 @@ def analyze( # Extract values pc = inputs.chamber_pressure.to("Pa").value - mdot_total = performance.mdot.to("kg/s").value mdot_ox = performance.mdot_ox.to("kg/s").value mdot_fuel = performance.mdot_fuel.to("kg/s").value isp = performance.isp.value diff --git a/rocket/examples/cycle_comparison.py b/rocket/examples/cycle_comparison.py index d625229..e7b74c4 100644 --- a/rocket/examples/cycle_comparison.py +++ b/rocket/examples/cycle_comparison.py @@ -64,7 +64,7 @@ def main() -> None: print(f" Thrust target: {base_inputs.thrust.to('kN').value:.0f} kN") print(f" Chamber pressure: {base_inputs.chamber_pressure.to('MPa').value:.0f} MPa") - print(f" Propellants: LOX / CH4") + print(" Propellants: LOX / CH4") print(f" Mixture ratio: {base_inputs.mixture_ratio}") print() @@ -142,7 +142,7 @@ def main() -> None: gg_result = gas_generator.analyze(base_inputs, performance, geometry) total_pump_power_gg = ( - gg_result.pump_power_ox.to("kW").value + + gg_result.pump_power_ox.to("kW").value + gg_result.pump_power_fuel.to("kW").value ) @@ -191,7 +191,7 @@ def main() -> None: sc_result = staged_combustion.analyze(base_inputs, performance, geometry) total_pump_power_sc = ( - sc_result.pump_power_ox.to("kW").value + + sc_result.pump_power_ox.to("kW").value + sc_result.pump_power_fuel.to("kW").value ) @@ -237,7 +237,7 @@ def main() -> None: isp_gain_pct = 100 * isp_gain / results[1]["net_isp"] if results[1]["net_isp"] > 0 else 0 print() - print(f" Staged combustion vs Gas Generator:") + print(" Staged combustion vs Gas Generator:") print(f" Isp gain: {isp_gain:.1f} s ({isp_gain_pct:.1f}%)") print() @@ -260,7 +260,7 @@ def main() -> None: title="LOX/CH4 Engine Cycle Comparison", ) fig_bars.savefig(ctx.path("cycle_comparison_bars.png"), dpi=150, bbox_inches="tight") - print(f" - cycle_comparison_bars.png") + print(" - cycle_comparison_bars.png") # 2. Radar chart fig_radar = plot_cycle_radar( @@ -269,7 +269,7 @@ def main() -> None: title="Cycle Trade-offs (Normalized)", ) fig_radar.savefig(ctx.path("cycle_radar.png"), dpi=150, bbox_inches="tight") - print(f" - cycle_radar.png") + print(" - cycle_radar.png") # 3. Trade-off scatter plot fig_tradeoff = plot_cycle_tradeoff( @@ -280,7 +280,7 @@ def main() -> None: title="Performance vs Simplicity Trade Space", ) fig_tradeoff.savefig(ctx.path("cycle_tradeoff.png"), dpi=150, bbox_inches="tight") - print(f" - cycle_tradeoff.png") + print(" - cycle_tradeoff.png") # Save summary data ctx.save_summary({ diff --git a/rocket/examples/propellant_design.py b/rocket/examples/propellant_design.py index 9be7a5f..93a0d55 100644 --- a/rocket/examples/propellant_design.py +++ b/rocket/examples/propellant_design.py @@ -179,7 +179,7 @@ def main() -> None: ] }, "comparison_summary.json") - for name, inputs, perf, geom in designs: + for _name, inputs, perf, geom in designs: ctx.log(f"Generating dashboard for {inputs.name}...") nozzle = generate_nozzle_from_geometry(geom) contour = full_chamber_contour(inputs, geom, nozzle) diff --git a/rocket/examples/thermal_analysis.py b/rocket/examples/thermal_analysis.py index 70c294a..feba325 100644 --- a/rocket/examples/thermal_analysis.py +++ b/rocket/examples/thermal_analysis.py @@ -153,7 +153,7 @@ def main() -> None: print(f" Coolant outlet temp: {ch4_cooling.coolant_outlet_temp.to('K').value:.0f} K") print(f" Flow margin: {ch4_cooling.flow_margin:.2f}x") if ch4_cooling.warnings: - print(f" Warnings:") + print(" Warnings:") for w in ch4_cooling.warnings: print(f" ⚠ {w}") diff --git a/rocket/output.py b/rocket/output.py index 4ed768e..122b8b1 100644 --- a/rocket/output.py +++ b/rocket/output.py @@ -138,10 +138,7 @@ def path(self, filename: str, subdir: str | None = None) -> Path: elif ext in {".txt", ".md", ".rst", ".log"}: subdir = "reports" - if subdir: - full_path = self.output_dir / subdir / filename - else: - full_path = self.output_dir / filename + full_path = self.output_dir / subdir / filename if subdir else self.output_dir / filename # Track file for metadata self._metadata["files"].append(str(full_path.relative_to(self.output_dir))) diff --git a/rocket/results.py b/rocket/results.py index 19b344b..9ebb6b5 100644 --- a/rocket/results.py +++ b/rocket/results.py @@ -12,7 +12,7 @@ >>> fig = plot_pareto(results, "isp_vac", "thrust_to_weight", maximize=[True, True]) """ -from typing import Sequence +from collections.abc import Sequence import matplotlib.pyplot as plt import numpy as np @@ -22,7 +22,6 @@ from rocket.analysis import StudyResults, UncertaintyResults - # ============================================================================= # Plot Style Configuration # ============================================================================= @@ -251,7 +250,7 @@ def plot_2d_contour( ax.contour(X, Y, Z, levels=levels, colors="white", alpha=0.3, linewidths=0.5) # Colorbar - cbar = fig.colorbar(contour, ax=ax, label=z_metric) + _ = fig.colorbar(contour, ax=ax, label=z_metric) # Show evaluated points if show_points: diff --git a/rocket/system.py b/rocket/system.py index ce3dc21..58c7f6e 100644 --- a/rocket/system.py +++ b/rocket/system.py @@ -34,6 +34,7 @@ from beartype import beartype +from rocket.cycles.base import CycleConfiguration, CyclePerformance, analyze_cycle from rocket.engine import ( EngineGeometry, EngineInputs, @@ -41,9 +42,7 @@ compute_geometry, compute_performance, ) -from rocket.cycles.base import CycleConfiguration, CyclePerformance, analyze_cycle from rocket.thermal.regenerative import CoolingFeasibility, check_cooling_feasibility -from rocket.units import kelvin @beartype diff --git a/rocket/thermal/__init__.py b/rocket/thermal/__init__.py index c7595c5..86b1ea5 100644 --- a/rocket/thermal/__init__.py +++ b/rocket/thermal/__init__.py @@ -35,8 +35,8 @@ recovery_factor, ) from rocket.thermal.regenerative import ( - CoolingFeasibility, CoolantProperties, + CoolingFeasibility, check_cooling_feasibility, get_coolant_properties, ) diff --git a/rocket/thermal/regenerative.py b/rocket/thermal/regenerative.py index ac9e84e..1167713 100644 --- a/rocket/thermal/regenerative.py +++ b/rocket/thermal/regenerative.py @@ -27,7 +27,6 @@ from rocket.thermal.heat_flux import estimate_heat_flux from rocket.units import Quantity, kelvin, pascals - # ============================================================================= # Coolant Properties Database # ============================================================================= @@ -356,7 +355,7 @@ def check_cooling_feasibility( # Feasibility assessment feasible = True - + if T_wall_estimate > T_wall_max: feasible = False warnings.append( @@ -419,7 +418,7 @@ def format_cooling_summary(result: CoolingFeasibility) -> str: lines = [ f"{'=' * 60}", - f"REGENERATIVE COOLING ANALYSIS", + "REGENERATIVE COOLING ANALYSIS", f"Status: {status}", f"{'=' * 60}", "", diff --git a/tests/test_tanks.py b/tests/test_tanks.py new file mode 100644 index 0000000..4615202 --- /dev/null +++ b/tests/test_tanks.py @@ -0,0 +1,348 @@ +"""Tests for the tanks module.""" + +import math + +import pytest + +from rocket.tanks import ( + TANK_MATERIALS, + format_tank_summary, + get_propellant_density, + list_materials, + list_propellants, + size_propellant, + size_tank, +) +from rocket.units import ( + kilograms, + km_per_second, + meters, + meters_per_second, + pascals, +) + + +class TestPropellantDatabase: + """Test propellant density database.""" + + def test_list_propellants(self) -> None: + """Test listing available propellants.""" + propellants = list_propellants() + + assert len(propellants) > 0 + assert "LOX" in propellants + assert "RP1" in propellants + assert "LH2" in propellants + assert "CH4" in propellants + + def test_get_lox_density(self) -> None: + """Test getting LOX density.""" + rho = get_propellant_density("LOX") + assert 1100 < rho < 1200 # ~1141 kg/m³ + + def test_get_lh2_density(self) -> None: + """Test getting LH2 density.""" + rho = get_propellant_density("LH2") + assert 60 < rho < 80 # ~70.8 kg/m³ + + def test_get_rp1_density(self) -> None: + """Test getting RP-1 density.""" + rho = get_propellant_density("RP1") + assert 800 < rho < 850 # ~810 kg/m³ + + def test_name_normalization(self) -> None: + """Test propellant name normalization.""" + # These should all work + assert get_propellant_density("LOX") == get_propellant_density("LO2") + assert get_propellant_density("RP1") == get_propellant_density("RP-1") + assert get_propellant_density("CH4") == get_propellant_density("LCH4") + + def test_unknown_propellant_raises(self) -> None: + """Test that unknown propellant raises ValueError.""" + with pytest.raises(ValueError, match="Unknown propellant"): + get_propellant_density("UNKNOWN_PROP") + + +class TestMaterialDatabase: + """Test tank material database.""" + + def test_list_materials(self) -> None: + """Test listing available materials.""" + materials = list_materials() + + assert len(materials) > 0 + assert "Al2219" in materials + assert "SS301" in materials + assert "CFRP" in materials + + def test_material_properties(self) -> None: + """Test that materials have required properties.""" + for _name, props in TANK_MATERIALS.items(): + assert "density" in props + assert "yield_strength" in props + assert "ultimate_strength" in props + assert props["density"] > 0 + assert props["yield_strength"] > 0 + assert props["ultimate_strength"] >= props["yield_strength"] + + +class TestSizePropellant: + """Test propellant sizing calculations.""" + + def test_basic_propellant_sizing(self) -> None: + """Test basic propellant mass calculation.""" + prop = size_propellant( + isp_s=300, + delta_v=meters_per_second(3000), + dry_mass=kilograms(500), + mixture_ratio=2.7, + ) + + # Check all outputs are valid + assert prop.total_propellant.value > 0 + assert prop.oxidizer_mass.value > 0 + assert prop.fuel_mass.value > 0 + assert prop.burn_time.value > 0 + assert prop.mass_ratio > 1.0 + + # Check mass balance + total = prop.oxidizer_mass.value + prop.fuel_mass.value + assert total == pytest.approx(prop.total_propellant.value, rel=1e-10) + + # Check mixture ratio is preserved + actual_mr = prop.oxidizer_mass.value / prop.fuel_mass.value + assert actual_mr == pytest.approx(2.7, rel=1e-10) + + def test_rocket_equation_accuracy(self) -> None: + """Test that rocket equation is correctly implemented.""" + # Known values: dv = Isp * g0 * ln(MR) + # For dv = 3000 m/s, Isp = 300s, MR = exp(3000 / (300 * 9.80665)) = 2.78 + prop = size_propellant( + isp_s=300, + delta_v=meters_per_second(3000), + dry_mass=kilograms(1000), + mixture_ratio=1.0, # No split for simple calculation + ) + + expected_mr = math.exp(3000 / (300 * 9.80665)) + assert prop.mass_ratio == pytest.approx(expected_mr, rel=1e-6) + + # Propellant mass = dry_mass * (MR - 1) + expected_prop = 1000 * (expected_mr - 1) + assert prop.total_propellant.value == pytest.approx(expected_prop, rel=1e-6) + + def test_high_delta_v(self) -> None: + """Test with high delta-V (e.g., orbital).""" + prop = size_propellant( + isp_s=450, # Hydrolox Isp + delta_v=km_per_second(9.5), # Orbital velocity + dry_mass=kilograms(1000), + mixture_ratio=6.0, + ) + + # Mass ratio should be high for orbital + assert prop.mass_ratio > 5 + + def test_with_mass_flow_rate(self) -> None: + """Test burn time calculation with explicit mdot.""" + from rocket.units import kg_per_second + + prop = size_propellant( + isp_s=300, + delta_v=meters_per_second(3000), + dry_mass=kilograms(500), + mixture_ratio=2.7, + mdot=kg_per_second(10), + ) + + # Burn time = propellant_mass / mdot + expected_burn_time = prop.total_propellant.value / 10 + assert prop.burn_time.value == pytest.approx(expected_burn_time, rel=1e-6) + + def test_dimension_validation(self) -> None: + """Test that dimension validation works.""" + with pytest.raises(ValueError, match="delta_v must be velocity"): + size_propellant( + isp_s=300, + delta_v=kilograms(3000), # Wrong dimension! + dry_mass=kilograms(500), + ) + + with pytest.raises(ValueError, match="dry_mass must be mass"): + size_propellant( + isp_s=300, + delta_v=meters_per_second(3000), + dry_mass=meters(500), # Wrong dimension! + ) + + +class TestSizeTank: + """Test tank sizing calculations.""" + + def test_basic_tank_sizing(self) -> None: + """Test basic tank sizing.""" + tank = size_tank( + propellant_mass=kilograms(10000), + propellant="LOX", + tank_pressure=pascals(500000), # 5 bar + material="Al2219", + ) + + # Check all outputs are valid + assert tank.volume.value > 0 + assert tank.diameter.value > 0 + assert tank.barrel_length.value >= 0 + assert tank.dome_height.value > 0 + assert tank.total_length.value > 0 + assert tank.wall_thickness.value > 0 + assert tank.dry_mass.value > 0 + assert tank.propellant == "LOX" + assert tank.material == "Al2219" + + def test_volume_calculation(self) -> None: + """Test that tank volume is correct for propellant mass.""" + tank = size_tank( + propellant_mass=kilograms(1141), # 1 m³ of LOX + propellant="LOX", + tank_pressure=pascals(300000), + ullage_fraction=0, # No ullage for exact calculation + ) + + # Volume should be approximately 1 m³ + assert tank.volume.value == pytest.approx(1.0, rel=0.01) + + def test_fixed_diameter(self) -> None: + """Test tank sizing with fixed diameter.""" + tank = size_tank( + propellant_mass=kilograms(5000), + propellant="RP1", + tank_pressure=pascals(400000), + diameter=meters(1.5), + ) + + assert tank.diameter.value == pytest.approx(1.5, rel=1e-6) + + def test_wall_thickness_increases_with_pressure(self) -> None: + """Test that wall thickness increases with pressure.""" + tank_low = size_tank( + propellant_mass=kilograms(5000), + propellant="LOX", + tank_pressure=pascals(200000), + diameter=meters(1.0), + ) + + tank_high = size_tank( + propellant_mass=kilograms(5000), + propellant="LOX", + tank_pressure=pascals(1000000), + diameter=meters(1.0), + ) + + assert tank_high.wall_thickness.value > tank_low.wall_thickness.value + + def test_different_materials(self) -> None: + """Test that different materials give different results.""" + tank_al = size_tank( + propellant_mass=kilograms(5000), + propellant="LOX", + tank_pressure=pascals(500000), + material="Al2219", + diameter=meters(1.0), + ) + + tank_ss = size_tank( + propellant_mass=kilograms(5000), + propellant="LOX", + tank_pressure=pascals(500000), + material="SS301", + diameter=meters(1.0), + ) + + # Steel is stronger so thinner wall, but denser so may be heavier + assert tank_al.wall_thickness.value != tank_ss.wall_thickness.value + + def test_unknown_material_raises(self) -> None: + """Test that unknown material raises ValueError.""" + with pytest.raises(ValueError, match="Unknown material"): + size_tank( + propellant_mass=kilograms(5000), + propellant="LOX", + tank_pressure=pascals(500000), + material="UNOBTAINIUM", + ) + + def test_lh2_tank_larger_volume(self) -> None: + """Test that LH2 tank is larger due to low density.""" + tank_lox = size_tank( + propellant_mass=kilograms(1000), + propellant="LOX", + tank_pressure=pascals(300000), + ) + + tank_lh2 = size_tank( + propellant_mass=kilograms(1000), + propellant="LH2", + tank_pressure=pascals(300000), + ) + + # LH2 density is ~16x lower than LOX + assert tank_lh2.volume.value > tank_lox.volume.value * 10 + + +class TestTankSummary: + """Test tank summary formatting.""" + + def test_format_tank_summary(self) -> None: + """Test formatting tank geometry as string.""" + tank = size_tank( + propellant_mass=kilograms(5000), + propellant="LOX", + tank_pressure=pascals(500000), + material="Al2219", + ) + + summary = format_tank_summary(tank) + + assert "LOX" in summary + assert "Al2219" in summary + assert "Volume" in summary + assert "Diameter" in summary + assert "Wall thickness" in summary + assert "Dry mass" in summary + + +class TestIntegration: + """Integration tests combining propellant and tank sizing.""" + + def test_full_vehicle_sizing_workflow(self) -> None: + """Test complete workflow from delta-V to tanks.""" + # Step 1: Size propellant + prop = size_propellant( + isp_s=300, + delta_v=km_per_second(3), + dry_mass=kilograms(500), + mixture_ratio=2.7, + ) + + # Step 2: Size oxidizer tank + lox_tank = size_tank( + propellant_mass=prop.oxidizer_mass, + propellant="LOX", + tank_pressure=pascals(400000), + ) + + # Step 3: Size fuel tank + rp1_tank = size_tank( + propellant_mass=prop.fuel_mass, + propellant="RP1", + tank_pressure=pascals(300000), + ) + + # Verify results are reasonable + assert lox_tank.dry_mass.value > 0 + assert rp1_tank.dry_mass.value > 0 + + # With MR=2.7, oxidizer mass is ~73% of total + # LOX is denser than RP-1, but more mass, so LOX tank is larger + assert lox_tank.volume.value > rp1_tank.volume.value +