From 5271180fd01e16a44ba71b6e146413a160a21d2a Mon Sep 17 00:00:00 2001 From: Cameron Flannery Date: Wed, 26 Nov 2025 00:08:49 -0800 Subject: [PATCH 1/2] rocket --- README.md | 76 ++- docs/conf.py | 16 +- docs/getting_started.rst | 14 +- docs/index.rst | 8 +- openrocketengine/examples/__init__.py | 2 - pyproject.toml | 17 +- {openrocketengine => rocket}/__init__.py | 67 ++- {openrocketengine => rocket}/engine.py | 109 +++- rocket/examples/__init__.py | 2 + .../examples/basic_engine.py | 12 +- rocket/examples/vehicle_sizing.py | 266 ++++++++++ {openrocketengine => rocket}/isentropic.py | 0 {openrocketengine => rocket}/nozzle.py | 12 +- {openrocketengine => rocket}/plotting.py | 72 ++- rocket/propellants.py | 293 +++++++++++ rocket/tanks.py | 466 ++++++++++++++++++ {openrocketengine => rocket}/units.py | 8 +- tests/test_engine.py | 4 +- tests/test_isentropic.py | 2 +- tests/test_nozzle.py | 6 +- tests/test_propellants.py | 203 ++++++++ tests/test_tanks.py | 353 +++++++++++++ tests/test_units.py | 2 +- uv.lock | 66 ++- 24 files changed, 1959 insertions(+), 117 deletions(-) delete mode 100644 openrocketengine/examples/__init__.py rename {openrocketengine => rocket}/__init__.py (50%) rename {openrocketengine => rocket}/engine.py (82%) create mode 100644 rocket/examples/__init__.py rename {openrocketengine => rocket}/examples/basic_engine.py (96%) create mode 100644 rocket/examples/vehicle_sizing.py rename {openrocketengine => rocket}/isentropic.py (100%) rename {openrocketengine => rocket}/nozzle.py (96%) rename {openrocketengine => rocket}/plotting.py (89%) create mode 100644 rocket/propellants.py create mode 100644 rocket/tanks.py rename {openrocketengine => rocket}/units.py (99%) create mode 100644 tests/test_propellants.py create mode 100644 tests/test_tanks.py diff --git a/README.md b/README.md index 281193c..2fc5d97 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,68 @@ -# Welcome to openrocketengine -![Python package](https://github.com/cmflannery/openrocketengine/workflows/Python%20package/badge.svg) -[openrocketengine](https://github.com/cmflannery/openrocketengine) is an open source project designed to help with the design and development of liquid rocket engines. - - -[1]: http://soliton.ae.gatech.edu/people/jseitzma/classes/ae6450/bell_nozzle.pdf "GATech: Bell Nozzles" -[2]: https://ntrs.nasa.gov/archive/nasa/casi.ntrs.nasa.gov/19710019929.pdf "Design of Liquid Propellant Rocket Engines" -[3]: https://ntrs.nasa.gov/archive/nasa/casi.ntrs.nasa.gov/19720026079.pdf "Liquid Propellant Rocket Combustion Instability, NASA SP-194" +# Rocket + +Tools for rocket vehicle design and analysis. + +## Installation + +Requires a Fortran compiler for RocketCEA (NASA CEA thermochemistry). + +```bash +# macOS +brew install gcc + +# Linux (Debian/Ubuntu) +sudo apt-get install gfortran + +# Then install +pip install rocket +``` + +## Quick Start + +```python +from rocket import EngineInputs, design_engine, plot_engine_dashboard +from rocket.units import kilonewtons, megapascals + +# Design from propellant selection (thermochemistry auto-calculated) +inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="RP1", + thrust=kilonewtons(100), + chamber_pressure=megapascals(7), + mixture_ratio=2.7, +) + +# Compute performance and geometry +performance, geometry = design_engine(inputs) + +print(f"Isp (sea level): {performance.isp}") +print(f"Isp (vacuum): {performance.isp_vac}") +print(f"Throat diameter: {geometry.throat_diameter}") + +# Visualize +plot_engine_dashboard(inputs, performance, geometry) +``` + +## Features + +- **Type-safe**: Runtime type checking with beartype +- **Units handling**: Built-in `Quantity` class prevents unit errors +- **Fast**: Numba-accelerated isentropic flow calculations +- **NASA CEA**: Accurate thermochemistry via RocketCEA +- **Visualization**: Engine cross-sections, performance curves, dashboards +- **Nozzle contours**: Rao bell and conical nozzle generation with CSV export + +## Modules + +- `rocket.engine` - Engine design and performance analysis +- `rocket.nozzle` - Nozzle contour generation +- `rocket.units` - Physical quantity handling with units +- `rocket.plotting` - Visualization tools +- `rocket.propellants` - NASA CEA thermochemistry integration +- `rocket.tanks` - Propellant and tank sizing (coming soon) + +## References + +1. [GATech: Bell Nozzles](http://soliton.ae.gatech.edu/people/jseitzma/classes/ae6450/bell_nozzle.pdf) +2. [Design of Liquid Propellant Rocket Engines](https://ntrs.nasa.gov/archive/nasa/casi.ntrs.nasa.gov/19710019929.pdf) +3. [Liquid Propellant Rocket Combustion Instability, NASA SP-194](https://ntrs.nasa.gov/archive/nasa/casi.ntrs.nasa.gov/19720026079.pdf) diff --git a/docs/conf.py b/docs/conf.py index ada59d5..f945b8e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,7 +19,7 @@ # -- Project information ----------------------------------------------------- -project = "openrocketengine" +project = "rocket" copyright = "2018, cmflannery" author = "cmflannery" @@ -106,7 +106,7 @@ # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = "openrocketenginedoc" +htmlhelp_basename = "rocketdoc" # -- Options for LaTeX output ------------------------------------------------ @@ -132,8 +132,8 @@ latex_documents = [ ( master_doc, - "openrocketengine.tex", - "openrocketengine Documentation", + "rocket.tex", + "rocket Documentation", "cmflannery", "manual", ), @@ -145,7 +145,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, "openrocketengine", "openrocketengine Documentation", [author], 1) + (master_doc, "rocket", "rocket Documentation", [author], 1) ] @@ -157,10 +157,10 @@ texinfo_documents = [ ( master_doc, - "openrocketengine", - "openrocketengine Documentation", + "rocket", + "rocket Documentation", author, - "openrocketengine", + "rocket", "One line description of project.", "Miscellaneous", ), diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 8bd8336..d537452 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -4,9 +4,9 @@ Getting Started Designing a Rocket Engine ------------------------- -What does OpenRocketEngine Do? +What does Rocket Do? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -OpenRocketEngine will perform some basic calculations for rocket thrust chamber designs. Additionally, +Rocket will perform some basic calculations for rocket thrust chamber designs. Additionally, it will be able to perform comparisions of different configurations. This code was originally created with the intention of automating vehicle sizing trade studies. @@ -29,7 +29,7 @@ The inputs are classified as either required or optional. Configuration Files ~~~~~~~~~~~~~~~~~~~ -openrocketengine takes a configuration file as the only input, specifying the engine propellant properties, pressures desired, +rocket takes a configuration file as the only input, specifying the engine propellant properties, pressures desired, and geometric design choices. Right now, there is only one possible combination of parameters that all have to be included in the config file. In the future, there may be additional options to automatically retrieve propellant properties from CEA. @@ -37,9 +37,9 @@ Config files are usually named with the engine name and the revision number with A typical configuration file looks like the following:: - # This is a test configuration file for openrocketengine + # This is a test configuration file for rocket # - # The parameters listed here are all the known parameters that openrocketengine can take as inputs. + # The parameters listed here are all the known parameters that rocket can take as inputs. # Refer to the official documentation for more implementation and usage details. name RBF1 units SI @@ -57,13 +57,13 @@ A typical configuration file looks like the following:: Running the program ~~~~~~~~~~~~~~~~~~~ -openrocketengine can be fun from the command line with the command `rocket`:: +rocket can be fun from the command line with the command `rocket`:: $ rocket RBF-rev01.cfg Outputs ~~~~~~~ -openrocketengine generates an output excel workbook with two sheets; one geometric parameters, and one for performance parameters. +rocket generates an output excel workbook with two sheets; one geometric parameters, and one for performance parameters. Recommended Workflow diff --git a/docs/index.rst b/docs/index.rst index d1046bc..d21b460 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,9 +1,9 @@ -.. openrocketengine documentation master file, created by +.. rocket documentation master file, created by sphinx-quickstart on Sun Jul 29 01:55:39 2018. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to openrocketengine's documentation! +Welcome to rocket's documentation! ============================================ .. toctree:: @@ -19,14 +19,14 @@ Welcome to openrocketengine's documentation! Overview -------- -OpenRocketEngine performs the calculations for simple analysis and design of rocket engines. For a +Rocket performs the calculations for simple analysis and design of rocket engines. For a general overview of the philosophy behind designing rocket engines, refer to the `rocket propulsion section`_ of General Body of Knowledge (GBOK). Installation ------------ -OpenRocketEngine only supports python 3.5 and above. Functionality with other python releases is +Rocket only supports python 3.5 and above. Functionality with other python releases is untested and not guaranteed. Basic usage:: $ rocket config_file.cfg 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/pyproject.toml b/pyproject.toml index f3da031..bddc08f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,14 @@ [project] -name = "openrocketengine" -version = "0.2.0" -description = "OpenRocketEngine - Tools for liquid rocket engine design and analysis" +name = "rocket" +version = "0.3.0" +description = "Rocket - Tools for rocket vehicle design and analysis" readme = "README.md" requires-python = ">=3.11" license = "MIT" authors = [ { name = "Cameron Flannery" } ] -keywords = ["rocket", "propulsion", "aerospace", "engineering", "nozzle"] +keywords = ["rocket", "propulsion", "aerospace", "engineering", "nozzle", "tanks", "vehicle"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Science/Research", @@ -26,6 +26,7 @@ dependencies = [ "beartype>=0.18", "numba>=0.60", "matplotlib>=3.9", + "rocketcea>=1.2.1", ] [project.optional-dependencies] @@ -34,14 +35,10 @@ dev = [ "pytest-cov>=4.0", "ruff>=0.4", ] -cea = [ - "rocketcea>=1.2", -] [project.urls] -Homepage = "https://github.com/openrocketengine/openrocketengine" -Documentation = "https://openrocketengine.readthedocs.io" -Repository = "https://github.com/openrocketengine/openrocketengine" +Homepage = "https://github.com/cmflannery/rocket" +Repository = "https://github.com/cmflannery/rocket" [build-system] requires = ["hatchling"] diff --git a/openrocketengine/__init__.py b/rocket/__init__.py similarity index 50% rename from openrocketengine/__init__.py rename to rocket/__init__.py index cb1a4b5..74c3f07 100644 --- a/openrocketengine/__init__.py +++ b/rocket/__init__.py @@ -1,30 +1,27 @@ -"""OpenRocketEngine - Tools for liquid rocket engine design and analysis. +"""Rocket - Tools for rocket vehicle design and analysis. This package provides a comprehensive toolkit for designing and analyzing -liquid propellant rocket engines using isentropic flow equations. +rocket vehicles, including propulsion, tanks, and structures. Example: - >>> from openrocketengine import EngineInputs, design_engine - >>> from openrocketengine.units import newtons, megapascals, kelvin, meters, pascals + >>> from rocket import EngineInputs, design_engine + >>> from rocket.units import kilonewtons, megapascals >>> - >>> 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, + >>> inputs = EngineInputs.from_propellants( + ... oxidizer="LOX", + ... fuel="RP1", + ... thrust=kilonewtons(100), + ... chamber_pressure=megapascals(7), + ... mixture_ratio=2.7, ... ) >>> performance, geometry = design_engine(inputs) >>> print(f"Isp: {performance.isp.value:.1f} s") """ -__version__ = "0.2.0" +__version__ = "0.3.0" # Core engine design -from openrocketengine.engine import ( +from rocket.engine import ( EngineGeometry, EngineInputs, EnginePerformance, @@ -38,7 +35,7 @@ ) # Nozzle contour generation -from openrocketengine.nozzle import ( +from rocket.nozzle import ( NozzleContour, conical_contour, full_chamber_contour, @@ -47,13 +44,34 @@ ) # Visualization -from openrocketengine.plotting import ( +from rocket.plotting import ( 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, +) + +# Tank sizing +from rocket.tanks import ( + PropellantRequirements, + TankGeometry, + format_tank_summary, + get_propellant_density, + list_materials, + list_propellants, + size_propellant, + size_tank, +) + __all__ = [ # Version "__version__", @@ -80,4 +98,19 @@ "plot_nozzle_contour", "plot_performance_vs_altitude", "plot_engine_dashboard", + "plot_mass_breakdown", + # Propellants + "CombustionProperties", + "get_combustion_properties", + "get_optimal_mixture_ratio", + "is_cea_available", + # Tanks + "PropellantRequirements", + "TankGeometry", + "size_propellant", + "size_tank", + "get_propellant_density", + "list_propellants", + "list_materials", + "format_tank_summary", ] diff --git a/openrocketengine/engine.py b/rocket/engine.py similarity index 82% rename from openrocketengine/engine.py rename to rocket/engine.py index 197438d..6d43b43 100644 --- a/openrocketengine/engine.py +++ b/rocket/engine.py @@ -1,4 +1,4 @@ -"""Engine module for OpenRocketEngine. +"""Engine module for Rocket. This module provides the core data structures and computation functions for rocket engine design and analysis. @@ -15,7 +15,7 @@ from beartype import beartype -from openrocketengine.isentropic import ( +from rocket.isentropic import ( G0_SI, area_ratio_from_mach, bell_nozzle_length, @@ -31,11 +31,17 @@ thrust_coefficient, thrust_coefficient_vacuum, ) -from openrocketengine.units import ( +from rocket.propellants import ( + get_combustion_properties, + get_optimal_mixture_ratio, +) +from rocket.units import ( Quantity, + kelvin, kg_per_second, meters, meters_per_second, + pascals, seconds, square_meters, ) @@ -131,6 +137,103 @@ def effective_ambient_pressure(self) -> Quantity: 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}") + """ + # 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 diff --git a/rocket/examples/__init__.py b/rocket/examples/__init__.py new file mode 100644 index 0000000..3cda6b7 --- /dev/null +++ b/rocket/examples/__init__.py @@ -0,0 +1,2 @@ +"""Example scripts for Rocket.""" + diff --git a/openrocketengine/examples/basic_engine.py b/rocket/examples/basic_engine.py similarity index 96% rename from openrocketengine/examples/basic_engine.py rename to rocket/examples/basic_engine.py index deb7437..13f569d 100644 --- a/openrocketengine/examples/basic_engine.py +++ b/rocket/examples/basic_engine.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -"""Basic engine design example for OpenRocketEngine. +"""Basic engine design example for Rocket. This example demonstrates the complete workflow for designing a small liquid rocket engine: @@ -15,30 +15,30 @@ for a student rocket project. """ -from openrocketengine.engine import ( +from rocket.engine import ( EngineInputs, compute_geometry, compute_performance, format_geometry_summary, format_performance_summary, ) -from openrocketengine.nozzle import ( +from rocket.nozzle import ( full_chamber_contour, generate_nozzle_from_geometry, ) -from openrocketengine.plotting import ( +from rocket.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 +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("Rocket - Basic Engine Design Example") print("=" * 70) print() diff --git a/rocket/examples/vehicle_sizing.py b/rocket/examples/vehicle_sizing.py new file mode 100644 index 0000000..2ef8164 --- /dev/null +++ b/rocket/examples/vehicle_sizing.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python +"""Vehicle sizing example for Rocket. + +This example demonstrates the complete workflow from mission requirements +to vehicle sizing with professional visualizations: +1. Define mission delta-V +2. Size engine from propellants +3. Calculate propellant mass +4. Size propellant tanks +5. Generate vehicle stack and mass breakdown visualizations +""" + +from rocket import ( + EngineInputs, + design_engine, + size_propellant, + size_tank, +) +from rocket.nozzle import full_chamber_contour, generate_nozzle_from_geometry +from rocket.plotting import ( + plot_engine_dashboard, + plot_mass_breakdown, +) +from rocket.units import kilograms, kilonewtons, km_per_second, megapascals, pascals + + +def print_header(text: str) -> None: + """Print a formatted section header.""" + print() + print("┌" + "─" * 68 + "┐") + print(f"│ {text:<66} │") + print("└" + "─" * 68 + "┘") + + +def print_table(rows: list[tuple[str, str]], title: str = "") -> None: + """Print a formatted table.""" + if title: + print(f"\n {title}") + print(" " + "─" * 40) + for label, value in rows: + print(f" {label:<24} {value:>14}") + + +def main() -> None: + """Run the vehicle sizing example.""" + print() + print("╔" + "═" * 68 + "╗") + print("║" + " " * 18 + "ROCKET VEHICLE SIZING TOOL" + " " * 24 + "║") + print("║" + " " * 20 + "LOX/CH4 SSTO Concept" + " " * 28 + "║") + print("╚" + "═" * 68 + "╝") + + # ========================================================================= + # Mission Requirements + # ========================================================================= + + print_header("MISSION REQUIREMENTS") + + delta_v = km_per_second(9.5) + payload_mass = kilograms(1000) + + print_table([ + ("Target orbit", "LEO (400 km)"), + ("Delta-V requirement", f"{delta_v.to('km/s').value:.1f} km/s"), + ("Payload mass", f"{payload_mass.to('kg').value:,.0f} kg"), + ]) + + # ========================================================================= + # Engine Design + # ========================================================================= + + print_header("ENGINE DESIGN") + + engine_inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(500), + chamber_pressure=megapascals(10), + mixture_ratio=3.2, + name="Methalox-500", + ) + + performance, geometry = design_engine(engine_inputs) + + print_table([ + ("Engine name", engine_inputs.name), + ("Propellants", "LOX / CH4"), + ("Thrust", f"{engine_inputs.thrust.to('kN').value:.0f} kN"), + ("Chamber pressure", f"{engine_inputs.chamber_pressure.to('MPa').value:.0f} MPa"), + ("Chamber temperature", f"{engine_inputs.chamber_temp.to('K').value:.0f} K"), + ("Mixture ratio (O/F)", f"{engine_inputs.mixture_ratio}"), + ], "Configuration") + + print_table([ + ("Isp (sea level)", f"{performance.isp.value:.1f} s"), + ("Isp (vacuum)", f"{performance.isp_vac.value:.1f} s"), + ("Thrust coefficient", f"{performance.thrust_coeff:.3f}"), + ("c*", f"{performance.cstar.to('m/s').value:.0f} m/s"), + ("Mass flow", f"{performance.mdot.value:.1f} kg/s"), + ], "Performance") + + print_table([ + ("Throat diameter", f"{geometry.throat_diameter.to('m').value * 100:.1f} cm"), + ("Exit diameter", f"{geometry.exit_diameter.to('m').value * 100:.1f} cm"), + ("Expansion ratio", f"{geometry.expansion_ratio:.1f}"), + ("Chamber diameter", f"{geometry.chamber_diameter.to('m').value * 100:.1f} cm"), + ("Chamber length", f"{geometry.chamber_length.to('m').value * 100:.1f} cm"), + ], "Geometry") + + # ========================================================================= + # Propellant Sizing + # ========================================================================= + + print_header("PROPELLANT SIZING") + + # Estimate structure mass + structure_mass_estimate = kilograms(3000) + dry_mass = kilograms(payload_mass.value + structure_mass_estimate.value) + isp_avg = (performance.isp.value + performance.isp_vac.value) / 2 + + propellant = size_propellant( + isp_s=isp_avg, + delta_v=delta_v, + dry_mass=dry_mass, + mixture_ratio=engine_inputs.mixture_ratio, + mdot=performance.mdot, + ) + + print_table([ + ("Average Isp used", f"{isp_avg:.1f} s"), + ("Initial dry mass est.", f"{dry_mass.to('kg').value:,.0f} kg"), + ], "Assumptions") + + print_table([ + ("Oxidizer (LOX)", f"{propellant.oxidizer_mass.to('kg').value:,.0f} kg"), + ("Fuel (CH4)", f"{propellant.fuel_mass.to('kg').value:,.0f} kg"), + ("Total propellant", f"{propellant.total_propellant.to('kg').value:,.0f} kg"), + ("Mass ratio", f"{propellant.mass_ratio:.2f}"), + ("Burn time", f"{propellant.burn_time.to('s').value:.0f} s"), + ], "Propellant Requirements") + + # ========================================================================= + # Tank Sizing + # ========================================================================= + + print_header("TANK SIZING") + + lox_tank = size_tank( + propellant_mass=propellant.oxidizer_mass, + propellant="LOX", + tank_pressure=pascals(300000), + material="Al2195", + ) + + print_table([ + ("Volume", f"{lox_tank.volume.to('m^3').value:.2f} m³"), + ("Diameter", f"{lox_tank.diameter.to('m').value:.2f} m"), + ("Total length", f"{lox_tank.total_length.to('m').value:.2f} m"), + ("Wall thickness", f"{lox_tank.wall_thickness.to('m').value * 1000:.1f} mm"), + ("Tank mass", f"{lox_tank.dry_mass.to('kg').value:.0f} kg"), + ], "LOX Tank (Al2195)") + + ch4_tank = size_tank( + propellant_mass=propellant.fuel_mass, + propellant="CH4", + tank_pressure=pascals(250000), + material="Al2195", + ) + + print_table([ + ("Volume", f"{ch4_tank.volume.to('m^3').value:.2f} m³"), + ("Diameter", f"{ch4_tank.diameter.to('m').value:.2f} m"), + ("Total length", f"{ch4_tank.total_length.to('m').value:.2f} m"), + ("Wall thickness", f"{ch4_tank.wall_thickness.to('m').value * 1000:.1f} mm"), + ("Tank mass", f"{ch4_tank.dry_mass.to('kg').value:.0f} kg"), + ], "CH4 Tank (Al2195)") + + # ========================================================================= + # Vehicle Mass Summary + # ========================================================================= + + print_header("VEHICLE MASS SUMMARY") + + # Component masses + engine_mass = 300.0 + avionics_mass = 100.0 + misc_structure = 500.0 + + masses = { + "Payload": payload_mass.value, + "Engine": engine_mass, + "LOX Tank": lox_tank.dry_mass.value, + "CH4 Tank": ch4_tank.dry_mass.value, + "Avionics": avionics_mass, + "Structure": misc_structure, + "LOX": propellant.oxidizer_mass.value, + "CH4": propellant.fuel_mass.value, + } + + total_dry = sum(v for k, v in masses.items() if k not in ["LOX", "CH4"]) + total_wet = sum(masses.values()) + + print_table([ + ("Payload", f"{masses['Payload']:,.0f} kg"), + ("Engine", f"{masses['Engine']:,.0f} kg"), + ("LOX Tank", f"{masses['LOX Tank']:,.0f} kg"), + ("CH4 Tank", f"{masses['CH4 Tank']:,.0f} kg"), + ("Avionics", f"{masses['Avionics']:,.0f} kg"), + ("Structure", f"{masses['Structure']:,.0f} kg"), + ], "Dry Mass Components") + + print() + print(f" {'─' * 40}") + print(f" {'TOTAL DRY MASS':<24} {total_dry:>14,.0f} kg") + print(f" {'Propellant (LOX + CH4)':<24} {propellant.total_propellant.value:>14,.0f} kg") + print(f" {'─' * 40}") + print(f" {'TOTAL WET MASS':<24} {total_wet:>14,.0f} kg") + + # Key metrics + twr = engine_inputs.thrust.to("N").value / (total_wet * 9.80665) + structure_fraction = (total_dry - payload_mass.value) / propellant.total_propellant.value + + print_table([ + ("Thrust-to-weight ratio", f"{twr:.2f}"), + ("Structure fraction", f"{structure_fraction * 100:.1f}%"), + ("Propellant fraction", f"{propellant.total_propellant.value / total_wet * 100:.1f}%"), + ], "Performance Metrics") + + # ========================================================================= + # Generate Visualizations + # ========================================================================= + + print_header("GENERATING VISUALIZATIONS") + + # 1. Engine dashboard + print(" [1/2] Engine dashboard...") + nozzle = generate_nozzle_from_geometry(geometry) + contour = full_chamber_contour(engine_inputs, geometry, nozzle) + fig_engine = plot_engine_dashboard(engine_inputs, performance, geometry, contour) + fig_engine.savefig("engine_dashboard.png", dpi=150, bbox_inches="tight") + print(" → Saved: engine_dashboard.png") + + # 2. Mass breakdown + print(" [2/2] Mass breakdown...") + fig_mass = plot_mass_breakdown(masses, title="Vehicle Mass Breakdown") + fig_mass.savefig("mass_breakdown.png", dpi=150, bbox_inches="tight") + print(" → Saved: mass_breakdown.png") + + # Final summary + print() + print("╔" + "═" * 68 + "╗") + print("║" + " " * 26 + "DESIGN COMPLETE" + " " * 27 + "║") + print("╠" + "═" * 68 + "╣") + print(f"║ Vehicle: Methalox SSTO ║") + print(f"║ Payload: {payload_mass.value:,.0f} kg to LEO ║") + print(f"║ Wet Mass: {total_wet:,.0f} kg ║") + print(f"║ T/W: {twr:.2f} ║") + print("╠" + "═" * 68 + "╣") + print("║ Output files: ║") + print("║ • engine_dashboard.png ║") + print("║ • mass_breakdown.png ║") + print("╚" + "═" * 68 + "╝") + print() + + +if __name__ == "__main__": + main() diff --git a/openrocketengine/isentropic.py b/rocket/isentropic.py similarity index 100% rename from openrocketengine/isentropic.py rename to rocket/isentropic.py diff --git a/openrocketengine/nozzle.py b/rocket/nozzle.py similarity index 96% rename from openrocketengine/nozzle.py rename to rocket/nozzle.py index 26d250a..8df18bc 100644 --- a/openrocketengine/nozzle.py +++ b/rocket/nozzle.py @@ -22,8 +22,8 @@ from beartype import beartype from numpy.typing import NDArray -from openrocketengine.engine import EngineGeometry, EngineInputs -from openrocketengine.units import Quantity +from rocket.engine import EngineGeometry, EngineInputs +from rocket.units import Quantity # ============================================================================= # Nozzle Contour Data Structure @@ -375,10 +375,12 @@ def full_chamber_contour( 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(math.pi - theta_c, math.pi, n_arc) - x_arc = R1 * np.cos(theta_range) # Goes from negative to 0 - y_arc = Rt + R1 + R1 * np.sin(theta_range) # Connects to throat + 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 diff --git a/openrocketengine/plotting.py b/rocket/plotting.py similarity index 89% rename from openrocketengine/plotting.py rename to rocket/plotting.py index 81caf29..d16f5a8 100644 --- a/openrocketengine/plotting.py +++ b/rocket/plotting.py @@ -1,4 +1,4 @@ -"""Visualization module for OpenRocketEngine. +"""Visualization module for Rocket. Provides plotting functions for: - Engine cross-section views @@ -17,15 +17,15 @@ from matplotlib.path import Path as MplPath from numpy.typing import NDArray -from openrocketengine.engine import ( +from rocket.engine import ( EngineGeometry, EngineInputs, EnginePerformance, isp_at_altitude, thrust_at_altitude, ) -from openrocketengine.nozzle import NozzleContour -from openrocketengine.units import pascals +from rocket.nozzle import NozzleContour +from rocket.units import pascals # ============================================================================= # Plot Style Configuration @@ -446,7 +446,7 @@ def plot_isp_vs_expansion_ratio( Returns: matplotlib Figure """ - from openrocketengine.isentropic import ( + from rocket.isentropic import ( area_ratio_from_mach, mach_from_pressure_ratio, thrust_coefficient, @@ -657,3 +657,65 @@ def plot_engine_dashboard( return fig + +@beartype +def plot_mass_breakdown( + masses: dict[str, float | int], + title: str = "Vehicle Mass Breakdown", +) -> Figure: + """Plot a mass breakdown pie chart and bar chart. + + Args: + masses: Dictionary of component names to masses in kg + title: Plot title + + Returns: + matplotlib Figure + """ + _setup_style() + fig, (ax_pie, ax_bar) = plt.subplots(1, 2, figsize=(14, 6)) + + # Sort by mass + sorted_items = sorted(masses.items(), key=lambda x: x[1], reverse=True) + labels = [item[0] for item in sorted_items] + values = [item[1] for item in sorted_items] + total = sum(values) + + # Color palette + colors = plt.cm.Set3(np.linspace(0, 1, len(labels))) + + # Pie chart + wedges, texts, autotexts = ax_pie.pie( + values, labels=None, autopct=lambda p: f"{p:.1f}%" if p > 3 else "", + colors=colors, startangle=90, counterclock=False, + wedgeprops=dict(linewidth=2, edgecolor="white") + ) + ax_pie.set_title("Mass Distribution", fontsize=12, fontweight="bold") + + # Legend for pie chart + legend_labels = [f"{l}: {v:,.0f} kg" for l, v in zip(labels, values)] + ax_pie.legend(wedges, legend_labels, loc="center left", bbox_to_anchor=(1, 0.5)) + + # Bar chart + y_pos = np.arange(len(labels)) + bars = ax_bar.barh(y_pos, values, color=colors, edgecolor="black", linewidth=1) + ax_bar.set_yticks(y_pos) + ax_bar.set_yticklabels(labels) + ax_bar.set_xlabel("Mass (kg)") + ax_bar.set_title("Component Masses", fontsize=12, fontweight="bold") + ax_bar.grid(True, alpha=0.3, axis="x") + + # Add value labels on bars + for bar, val in zip(bars, values): + width = bar.get_width() + ax_bar.text(width + total * 0.01, bar.get_y() + bar.get_height() / 2, + f"{val:,.0f} kg", va="center", fontsize=9) + + ax_bar.set_xlim(0, max(values) * 1.15) + + # Total mass annotation + fig.suptitle(f"{title}\nTotal: {total:,.0f} kg", fontsize=14, fontweight="bold") + fig.tight_layout(rect=[0, 0, 1, 0.93]) + + return fig + diff --git a/rocket/propellants.py b/rocket/propellants.py new file mode 100644 index 0000000..fd4fee1 --- /dev/null +++ b/rocket/propellants.py @@ -0,0 +1,293 @@ +"""Propellant thermochemistry module for Rocket. + +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 + + +# ============================================================================= +# 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", + ) + + +# ============================================================================= +# Public API +# ============================================================================= + + +@beartype +def get_combustion_properties( + oxidizer: str, + fuel: str, + mixture_ratio: float, + chamber_pressure_pa: float, +) -> CombustionProperties: + """Get combustion thermochemistry properties for a propellant combination. + + This function returns the thermochemical properties needed for rocket engine + performance calculations using NASA CEA via RocketCEA. + + 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 + + Returns: + CombustionProperties containing Tc, MW, gamma, Cp, c* + + 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}") + """ + return _get_properties_from_cea(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 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. + + 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) + """ + 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/tanks.py b/rocket/tanks.py new file mode 100644 index 0000000..7eb4456 --- /dev/null +++ b/rocket/tanks.py @@ -0,0 +1,466 @@ +"""Tank sizing module for Rocket. + +This module provides tools for sizing propellant tanks and calculating +propellant requirements based on mission delta-V and engine performance. + +Features: +- Propellant density database (LOX, LH2, RP-1, CH4, etc.) +- Rocket equation calculations for propellant mass +- Tank geometry sizing (cylindrical with elliptical domes) +- Structural mass estimation + +Example: + >>> from rocket.tanks import size_propellant, size_tank + >>> from rocket.units import km_per_second, kilograms + >>> + >>> prop = size_propellant( + ... isp_s=300, + ... delta_v=km_per_second(3), + ... dry_mass=kilograms(500), + ... ) + >>> print(f"Total propellant: {prop.total_propellant}") +""" + +import math +from dataclasses import dataclass + +from beartype import beartype + +from rocket.units import ( + Quantity, + cubic_meters, + kilograms, + meters, + pascals, + seconds, +) + + +# ============================================================================= +# Propellant Database +# ============================================================================= + +# Propellant densities at typical storage conditions [kg/m³] +# Sources: Sutton & Biblarz, propellant datasheets +PROPELLANT_DENSITIES: dict[str, float] = { + # Oxidizers + "LOX": 1141.0, # Liquid oxygen at -183°C + "LO2": 1141.0, # Alias + "N2O4": 1450.0, # Nitrogen tetroxide at 20°C + "N2O": 1220.0, # Nitrous oxide at -88°C (liquid) + "H2O2": 1450.0, # High-test peroxide (90%) + "MON25": 1400.0, # Mixed oxides of nitrogen + "IRFNA": 1560.0, # Inhibited red fuming nitric acid + + # Fuels + "LH2": 70.8, # Liquid hydrogen at -253°C + "RP1": 810.0, # RP-1 kerosene at 20°C + "RP-1": 810.0, # Alias + "CH4": 422.6, # Liquid methane at -161°C + "LCH4": 422.6, # Alias + "Ethanol": 789.0, # Ethanol at 20°C + "C2H5OH": 789.0, # Alias + "MMH": 878.0, # Monomethylhydrazine at 20°C + "UDMH": 793.0, # Unsymmetrical dimethylhydrazine at 20°C + "N2H4": 1004.0, # Hydrazine at 20°C + "A-50": 903.0, # Aerozine-50 (50% UDMH, 50% N2H4) + "IPA": 786.0, # Isopropyl alcohol at 20°C + "Jet-A": 804.0, # Jet fuel at 15°C +} + +# Tank material properties +TANK_MATERIALS: dict[str, dict[str, float]] = { + "Al2219": { + "density": 2840.0, # kg/m³ + "yield_strength": 290e6, # Pa (T87 temper) + "ultimate_strength": 400e6, # Pa + }, + "Al2195": { + "density": 2710.0, # kg/m³ (Al-Li alloy) + "yield_strength": 455e6, # Pa (T8 temper) + "ultimate_strength": 530e6, # Pa + }, + "Al6061": { + "density": 2700.0, # kg/m³ + "yield_strength": 276e6, # Pa (T6 temper) + "ultimate_strength": 310e6, # Pa + }, + "SS301": { + "density": 7880.0, # kg/m³ (stainless steel) + "yield_strength": 965e6, # Pa (full hard) + "ultimate_strength": 1275e6, # Pa + }, + "Ti6Al4V": { + "density": 4430.0, # kg/m³ + "yield_strength": 880e6, # Pa + "ultimate_strength": 950e6, # Pa + }, + "CFRP": { + "density": 1600.0, # kg/m³ (carbon fiber composite) + "yield_strength": 600e6, # Pa (conservative) + "ultimate_strength": 1000e6, # Pa + }, +} + + +# ============================================================================= +# Data Structures +# ============================================================================= + + +@beartype +@dataclass(frozen=True, slots=True) +class PropellantRequirements: + """Propellant masses required for a given mission. + + Calculated from the rocket equation based on required delta-V, + specific impulse, and vehicle dry mass. + + Attributes: + oxidizer_mass: Mass of oxidizer required [kg] + fuel_mass: Mass of fuel required [kg] + total_propellant: Total propellant mass [kg] + burn_time: Estimated burn time [s] + mass_ratio: Wet mass / dry mass [-] + """ + + oxidizer_mass: Quantity + fuel_mass: Quantity + total_propellant: Quantity + burn_time: Quantity + mass_ratio: float + + +@beartype +@dataclass(frozen=True, slots=True) +class TankGeometry: + """Tank dimensions and properties. + + Represents a cylindrical tank with elliptical end domes. + All dimensions are for the inner wall (propellant volume). + + Attributes: + volume: Internal tank volume [m³] + diameter: Tank outer diameter [m] + barrel_length: Cylindrical section length [m] + dome_height: Height of each elliptical dome [m] + total_length: Total tank length including domes [m] + wall_thickness: Tank wall thickness [m] + dry_mass: Tank structural mass [kg] + propellant: Propellant name + material: Tank material name + """ + + volume: Quantity + diameter: Quantity + barrel_length: Quantity + dome_height: Quantity + total_length: Quantity + wall_thickness: Quantity + dry_mass: Quantity + propellant: str + material: str + + +# ============================================================================= +# Propellant Sizing +# ============================================================================= + + +@beartype +def get_propellant_density(propellant: str) -> float: + """Get density of a propellant in kg/m³. + + Args: + propellant: Propellant name (e.g., "LOX", "RP1", "LH2") + + Returns: + Density in kg/m³ + + Raises: + ValueError: If propellant not found in database + """ + # Normalize name + name = propellant.upper().replace("-", "").replace(" ", "") + + # Check direct match + if propellant in PROPELLANT_DENSITIES: + return PROPELLANT_DENSITIES[propellant] + + # Check normalized + for key, value in PROPELLANT_DENSITIES.items(): + if key.upper().replace("-", "") == name: + return value + + available = list(PROPELLANT_DENSITIES.keys()) + raise ValueError(f"Unknown propellant '{propellant}'. Available: {available}") + + +@beartype +def size_propellant( + isp_s: float | int, + delta_v: Quantity, + dry_mass: Quantity, + mixture_ratio: float | int = 1.0, + mdot: Quantity | None = None, +) -> PropellantRequirements: + """Calculate propellant mass required for a given delta-V. + + Uses the Tsiolkovsky rocket equation: + delta_v = Isp * g0 * ln(m_wet / m_dry) + + Args: + isp_s: Specific impulse in seconds + delta_v: Required velocity change [velocity] + dry_mass: Vehicle dry mass (structure, payload, etc.) [mass] + mixture_ratio: Oxidizer/fuel mass ratio. Default 1.0 (no split). + mdot: Mass flow rate for burn time calculation [mass/time]. + If None, burn time is estimated from typical thrust-to-weight. + + Returns: + PropellantRequirements with oxidizer, fuel, and total masses + + Example: + >>> prop = size_propellant( + ... isp_s=300, + ... delta_v=km_per_second(3), + ... dry_mass=kilograms(500), + ... mixture_ratio=2.7, # LOX/RP-1 + ... ) + """ + # Validate dimensions + if delta_v.dimension != "velocity": + raise ValueError(f"delta_v must be velocity, got {delta_v.dimension}") + if dry_mass.dimension != "mass": + raise ValueError(f"dry_mass must be mass, got {dry_mass.dimension}") + + # Convert to SI + dv = delta_v.to("m/s").value + m_dry = dry_mass.to("kg").value + g0 = 9.80665 # m/s² + + # Rocket equation: dv = Isp * g0 * ln(m_wet/m_dry) + # => m_wet/m_dry = exp(dv / (Isp * g0)) + exhaust_velocity = isp_s * g0 + mass_ratio = math.exp(dv / exhaust_velocity) + m_wet = m_dry * mass_ratio + m_propellant = m_wet - m_dry + + # Split into oxidizer and fuel + if mixture_ratio > 0: + m_oxidizer = m_propellant * mixture_ratio / (1 + mixture_ratio) + m_fuel = m_propellant / (1 + mixture_ratio) + else: + m_oxidizer = 0.0 + m_fuel = m_propellant + + # Estimate burn time + if mdot is not None: + if mdot.dimension != "mass_flow": + raise ValueError(f"mdot must be mass_flow, got {mdot.dimension}") + mdot_kg_s = mdot.to("kg/s").value + burn_time_s = m_propellant / mdot_kg_s + else: + # Estimate from typical thrust-to-weight ratio (~1.2 for first stage) + # F = mdot * Isp * g0, T/W = F / (m_wet * g0) = 1.2 + # => mdot = 1.2 * m_wet / Isp + mdot_est = 1.2 * m_wet / isp_s + burn_time_s = m_propellant / mdot_est + + return PropellantRequirements( + oxidizer_mass=kilograms(m_oxidizer), + fuel_mass=kilograms(m_fuel), + total_propellant=kilograms(m_propellant), + burn_time=seconds(burn_time_s), + mass_ratio=mass_ratio, + ) + + +# ============================================================================= +# Tank Sizing +# ============================================================================= + + +@beartype +def size_tank( + propellant_mass: Quantity, + propellant: str, + tank_pressure: Quantity, + material: str = "Al2219", + dome_ratio: float | int = 0.7071, # sqrt(2)/2 for 2:1 ellipse + safety_factor: float | int = 1.5, + ullage_fraction: float | int = 0.03, + diameter: Quantity | None = None, +) -> TankGeometry: + """Size a propellant tank for given mass and pressure. + + Designs a cylindrical tank with elliptical end domes. + Wall thickness is based on hoop stress from internal pressure. + + Args: + propellant_mass: Mass of propellant to store [mass] + propellant: Propellant name (for density lookup) + tank_pressure: Maximum expected operating pressure [pressure] + material: Tank material name. Default "Al2219". + dome_ratio: Dome height / radius ratio. Default 0.707 (2:1 ellipse). + safety_factor: Structural safety factor. Default 1.5. + ullage_fraction: Volume fraction for ullage. Default 0.03 (3%). + diameter: Fixed tank diameter [length]. If None, sized for L/D ~ 2. + + Returns: + TankGeometry with dimensions and mass estimate + + Example: + >>> tank = size_tank( + ... propellant_mass=kilograms(10000), + ... propellant="LOX", + ... tank_pressure=pascals(500000), # 5 bar + ... material="Al2219", + ... ) + """ + # Validate inputs + if propellant_mass.dimension != "mass": + raise ValueError(f"propellant_mass must be mass, got {propellant_mass.dimension}") + if tank_pressure.dimension != "pressure": + raise ValueError(f"tank_pressure must be pressure, got {tank_pressure.dimension}") + + # Get propellant density + rho_prop = get_propellant_density(propellant) + + # Get material properties + if material not in TANK_MATERIALS: + available = list(TANK_MATERIALS.keys()) + raise ValueError(f"Unknown material '{material}'. Available: {available}") + mat = TANK_MATERIALS[material] + rho_mat = mat["density"] + sigma_yield = mat["yield_strength"] + + # Convert to SI + m_prop = propellant_mass.to("kg").value + p = tank_pressure.to("Pa").value + + # Calculate required volume (with ullage) + v_prop = m_prop / rho_prop + v_total = v_prop * (1 + ullage_fraction) + + # Determine diameter + if diameter is not None: + if diameter.dimension != "length": + raise ValueError(f"diameter must be length, got {diameter.dimension}") + d = diameter.to("m").value + else: + # Size for L/D ~ 2 (barrel only, not counting domes) + # V_barrel = pi * r² * L = pi * r² * (2 * 2r) = 4 * pi * r³ + # V_dome (2 elliptical domes) = 2 * (2/3) * pi * r² * (dome_ratio * r) + # = (4/3) * pi * r³ * dome_ratio + # V_total = 4*pi*r³ + (4/3)*pi*r³*dome_ratio = pi*r³*(4 + 4*dome_ratio/3) + coeff = 4 + 4 * dome_ratio / 3 + r = (v_total / (math.pi * coeff)) ** (1 / 3) + d = 2 * r + + r = d / 2 + + # Calculate dome volume (two elliptical domes) + # V_dome = (2/3) * pi * r² * h_dome for each dome + h_dome = dome_ratio * r + v_domes = 2 * (2 / 3) * math.pi * r**2 * h_dome + + # Calculate barrel length + v_barrel = v_total - v_domes + if v_barrel < 0: + # Domes alone provide enough volume + v_barrel = 0 + # Recalculate dome height + # 2 * (2/3) * pi * r² * h = v_total + h_dome = v_total / ((4 / 3) * math.pi * r**2) + v_domes = v_total + + l_barrel = v_barrel / (math.pi * r**2) if v_barrel > 0 else 0 + + # Total length + l_total = l_barrel + 2 * h_dome + + # Wall thickness from hoop stress + # sigma = p * r / t => t = p * r / (sigma / SF) + sigma_allow = sigma_yield / safety_factor + t = p * r / sigma_allow + + # Minimum gauge (manufacturing limit) + t = max(t, 0.001) # 1mm minimum + + # Tank mass estimate + # Barrel: 2 * pi * r * L * t * rho + # Domes: approximate as 2 * (surface area of ellipsoid cap) * t * rho + # Surface ≈ 2 * pi * r * (r + h) for hemisphere, less for ellipse + # Use factor of 0.8 for 2:1 ellipse + a_barrel = 2 * math.pi * r * l_barrel + a_domes = 2 * 2 * math.pi * r * (r + h_dome) * 0.8 # Two domes + a_total = a_barrel + a_domes + m_tank = a_total * t * rho_mat + + # Add mass for welds, fittings, etc. (typically 15-20%) + m_tank *= 1.15 + + return TankGeometry( + volume=cubic_meters(v_total), + diameter=meters(d), + barrel_length=meters(l_barrel), + dome_height=meters(h_dome), + total_length=meters(l_total), + wall_thickness=meters(t), + dry_mass=kilograms(m_tank), + propellant=propellant, + material=material, + ) + + +# ============================================================================= +# Convenience Functions +# ============================================================================= + + +@beartype +def list_propellants() -> list[str]: + """List available propellants in the density database. + + Returns: + List of propellant names + """ + return list(PROPELLANT_DENSITIES.keys()) + + +@beartype +def list_materials() -> list[str]: + """List available tank materials. + + Returns: + List of material names + """ + return list(TANK_MATERIALS.keys()) + + +@beartype +def format_tank_summary(tank: TankGeometry) -> str: + """Format tank geometry as a readable string. + + Args: + tank: Tank geometry to summarize + + Returns: + Multi-line string summary + """ + lines = [ + f"Tank: {tank.propellant} ({tank.material})", + "=" * 40, + f"Volume: {tank.volume.to('m^3').value:.3f} m³", + f" ({tank.volume.to('m^3').value * 1000:.1f} L)", + f"Diameter: {tank.diameter.to('m').value:.3f} m", + f" ({tank.diameter.to('m').value * 100:.1f} cm)", + f"Barrel length: {tank.barrel_length.to('m').value:.3f} m", + f"Dome height: {tank.dome_height.to('m').value:.3f} m (each)", + f"Total length: {tank.total_length.to('m').value:.3f} m", + f"Wall thickness: {tank.wall_thickness.to('m').value * 1000:.2f} mm", + f"Dry mass: {tank.dry_mass.to('kg').value:.1f} kg", + ] + return "\n".join(lines) + diff --git a/openrocketengine/units.py b/rocket/units.py similarity index 99% rename from openrocketengine/units.py rename to rocket/units.py index c039bf0..dfcff30 100644 --- a/openrocketengine/units.py +++ b/rocket/units.py @@ -1,4 +1,4 @@ -"""Units module for OpenRocketEngine. +"""Units module for Rocket. Provides a Quantity class for type-safe physical quantities with unit conversion. All physical values in the library should use Quantity, never bare floats. @@ -550,6 +550,12 @@ def feet_per_second(value: float | int) -> Quantity: return Quantity(value, "ft/s", "velocity") +@beartype +def km_per_second(value: float | int) -> Quantity: + """Create a velocity quantity in km/s.""" + return Quantity(value, "km/s", "velocity") + + @beartype def square_meters(value: float | int) -> Quantity: """Create an area quantity in m^2.""" 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 new file mode 100644 index 0000000..e8eee69 --- /dev/null +++ b/tests/test_propellants.py @@ -0,0 +1,203 @@ +"""Tests for the propellants module.""" + +import pytest + +from rocket.propellants import ( + CombustionProperties, + get_combustion_properties, + get_optimal_mixture_ratio, + is_cea_available, +) + + +class TestCombustionProperties: + """Test CombustionProperties dataclass.""" + + def test_create_properties(self) -> None: + """Test creating combustion properties.""" + props = CombustionProperties( + chamber_temp_k=3500.0, + molecular_weight=22.0, + gamma=1.2, + specific_heat_cp=2000.0, + characteristic_velocity=1800.0, + oxidizer="LOX", + fuel="RP1", + mixture_ratio=2.7, + chamber_pressure_pa=7e6, + source="test", + ) + assert props.chamber_temp_k == 3500.0 + assert props.gamma == 1.2 + assert props.source == "test" + + +class TestCEAIntegration: + """Test RocketCEA integration.""" + + def test_is_cea_available(self) -> None: + """Test that CEA is available (required dependency).""" + assert is_cea_available() is True + + def test_get_lox_rp1_properties(self) -> None: + """Test getting LOX/RP1 properties from CEA.""" + props = get_combustion_properties( + oxidizer="LOX", + fuel="RP1", + mixture_ratio=2.7, + chamber_pressure_pa=7e6, + ) + + # Verify reasonable values for LOX/RP1 + assert 3400 < props.chamber_temp_k < 3800 + assert 20 < props.molecular_weight < 26 + assert 1.1 < props.gamma < 1.25 + assert 1700 < props.characteristic_velocity < 1900 + assert props.source == "rocketcea" + + def test_get_lox_lh2_properties(self) -> None: + """Test getting LOX/LH2 properties from CEA.""" + props = get_combustion_properties( + oxidizer="LOX", + fuel="LH2", + mixture_ratio=6.0, + chamber_pressure_pa=10e6, + ) + + # LOX/LH2 has higher Isp, lower MW + assert 3300 < props.chamber_temp_k < 3700 + assert 12 < props.molecular_weight < 18 + assert 1.1 < props.gamma < 1.25 + assert 2300 < props.characteristic_velocity < 2500 + + def test_get_lox_ch4_properties(self) -> None: + """Test getting LOX/CH4 properties from CEA.""" + props = get_combustion_properties( + oxidizer="LOX", + fuel="CH4", + mixture_ratio=3.2, + chamber_pressure_pa=7e6, + ) + + assert 3400 < props.chamber_temp_k < 3700 + assert 18 < props.molecular_weight < 24 + assert props.source == "rocketcea" + + def test_get_n2o4_mmh_properties(self) -> None: + """Test getting N2O4/MMH (hypergolic) properties.""" + props = get_combustion_properties( + oxidizer="N2O4", + fuel="MMH", + mixture_ratio=2.0, + chamber_pressure_pa=1e6, + ) + + assert 3000 < props.chamber_temp_k < 3500 + assert 20 < props.molecular_weight < 25 + + def test_name_normalization(self) -> None: + """Test that propellant names are normalized.""" + # These should all give similar results + props1 = get_combustion_properties("LOX", "RP1", 2.7, 7e6) + props2 = get_combustion_properties("LO2", "RP-1", 2.7, 7e6) + + # All should map to the same propellant combination + assert props1.chamber_temp_k == pytest.approx(props2.chamber_temp_k, rel=0.01) + + +class TestOptimalMixtureRatio: + """Test mixture ratio optimization.""" + + def test_optimal_mr_lox_rp1(self) -> None: + """Test finding optimal MR for LOX/RP1.""" + mr, isp = get_optimal_mixture_ratio( + oxidizer="LOX", + fuel="RP1", + chamber_pressure_pa=7e6, + expansion_ratio=40.0, + metric="isp", + ) + + # Optimal MR for LOX/RP1 is around 2.3-2.8 + assert 2.0 < mr < 3.0 + assert isp > 300 # Reasonable Isp in seconds + + def test_optimal_mr_lox_lh2(self) -> None: + """Test finding optimal MR for LOX/LH2.""" + mr, isp = get_optimal_mixture_ratio( + oxidizer="LOX", + fuel="LH2", + chamber_pressure_pa=10e6, + expansion_ratio=40.0, + metric="isp", + ) + + # Optimal MR for LOX/LH2 is around 5-7 + assert 4.0 < mr < 8.0 + assert isp > 400 # High Isp for hydrolox + + +class TestEngineInputsFromPropellants: + """Test EngineInputs.from_propellants() factory method.""" + + def test_from_propellants_basic(self) -> None: + """Test basic from_propellants usage.""" + from rocket.engine import EngineInputs + from rocket.units import kilonewtons, megapascals + + inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="RP1", + thrust=kilonewtons(100), + chamber_pressure=megapascals(7), + mixture_ratio=2.7, + ) + + assert inputs.thrust.to("kN").value == pytest.approx(100) + assert inputs.chamber_pressure.to("MPa").value == pytest.approx(7) + assert inputs.mixture_ratio == pytest.approx(2.7) + # Chamber temp should be set from CEA + assert 3400 < inputs.chamber_temp.to("K").value < 3800 + assert inputs.name == "LOX/RP1 Engine" + + def test_from_propellants_with_defaults(self) -> None: + """Test from_propellants with default parameters.""" + from rocket.engine import EngineInputs + from rocket.units import newtons, pascals + + inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="LH2", + thrust=newtons(50000), + chamber_pressure=pascals(5e6), + mixture_ratio=6.0, + ) + + # Check defaults were applied + assert inputs.exit_pressure.to("Pa").value == pytest.approx(101325) + assert inputs.lstar.to("m").value == pytest.approx(1.0) + assert inputs.contraction_ratio == pytest.approx(4.0) + assert inputs.bell_fraction == pytest.approx(0.8) + + def test_from_propellants_full_workflow(self) -> None: + """Test complete workflow from propellants to geometry.""" + from rocket.engine import EngineInputs, design_engine + from rocket.units import kilonewtons, megapascals + + inputs = EngineInputs.from_propellants( + oxidizer="LOX", + fuel="CH4", + thrust=kilonewtons(50), + chamber_pressure=megapascals(5), + mixture_ratio=3.2, + name="Methane Test Engine", + ) + + # Should be able to compute performance and geometry + performance, geometry = design_engine(inputs) + + # Verify reasonable results + assert 280 < performance.isp.value < 360 # LOX/CH4 Isp range + assert performance.mdot.value > 0 + assert geometry.throat_diameter.value > 0 + assert geometry.expansion_ratio > 1 diff --git a/tests/test_tanks.py b/tests/test_tanks.py new file mode 100644 index 0000000..4732732 --- /dev/null +++ b/tests/test_tanks.py @@ -0,0 +1,353 @@ +"""Tests for the tanks module.""" + +import math + +import pytest + +from rocket.tanks import ( + PROPELLANT_DENSITIES, + TANK_MATERIALS, + PropellantRequirements, + TankGeometry, + format_tank_summary, + get_propellant_density, + list_materials, + list_propellants, + size_propellant, + size_tank, +) +from rocket.units import ( + cubic_meters, + kilograms, + km_per_second, + meters, + meters_per_second, + pascals, + seconds, +) + + +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 + 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, diff --git a/uv.lock b/uv.lock index de6c608..8c3b374 100644 --- a/uv.lock +++ b/uv.lock @@ -546,40 +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" }, -] - -[package.optional-dependencies] -cea = [ - { name = "rocketcea" }, -] -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", marker = "extra == 'cea'", specifier = ">=1.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, -] -provides-extras = ["dev", "cea"] - [[package]] name = "packaging" version = "25.0" @@ -745,6 +711,38 @@ 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.3.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 = "rocketcea" version = "1.2.1" From aa3020262c573d58b87dc2f1c90e5c8d9f04133e Mon Sep 17 00:00:00 2001 From: Cameron Flannery Date: Wed, 26 Nov 2025 00:17:01 -0800 Subject: [PATCH 2/2] update workflow --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 8777da1..0f391d7 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,7 +5,7 @@ name: Python package on: push: - branches: [ master, cameron/revival ] + branches: [ master ] pull_request: branches: [ master ]