diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5697c1..d55c364 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, develop] + branches: [main] pull_request: branches: [main] @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - name: Checkout code @@ -23,14 +23,8 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - - name: Cache pip packages - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-pip- + cache: pip + cache-dependency-path: pyproject.toml - name: Install dependencies run: | @@ -38,10 +32,10 @@ jobs: pip install -e ".[dev]" - name: Run tests with coverage - run: | - pytest --cov=rocket_sim --cov-report=xml --cov-report=term-missing + run: pytest --cov=rocket_sim --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov + if: matrix.python-version == '3.12' uses: codecov/codecov-action@v4 with: file: ./coverage.xml @@ -58,22 +52,21 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml - name: Install dependencies run: | python -m pip install --upgrade pip - pip install ruff black isort - - - name: Check formatting with Black - run: black --check src tests - - - name: Check import sorting with isort - run: isort --check-only src tests + pip install -e ".[dev]" - - name: Lint with Ruff + - name: Ruff lint run: ruff check src tests + - name: Ruff format check + run: ruff format --check src tests + type-check: name: Type Check runs-on: ubuntu-latest @@ -85,7 +78,9 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml - name: Install dependencies run: | @@ -107,7 +102,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" - name: Install build tools run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1ec4f99..6738a02 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,26 +17,13 @@ repos: - id: detect-private-key - id: debug-statements - # Python code formatting - - repo: https://github.com/psf/black - rev: 24.1.1 - hooks: - - id: black - language_version: python3 - - # Import sorting - - repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - args: ["--profile", "black"] - - # Linting with Ruff + # Lint + format with Ruff (replaces black, isort, flake8, pyupgrade, etc.) - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.1.14 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format # Type checking with mypy - repo: https://github.com/pre-commit/mirrors-mypy @@ -49,15 +36,6 @@ repos: args: [--ignore-missing-imports] exclude: tests/ - # Check for security issues - - repo: https://github.com/PyCQA/bandit - rev: 1.7.7 - hooks: - - id: bandit - args: ["-c", "pyproject.toml"] - additional_dependencies: ["bandit[toml]"] - exclude: tests/ - # Configuration ci: autofix_commit_msg: | diff --git a/CHANGELOG.md b/CHANGELOG.md index c232082..0a16a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0] - 2024-01-24 +## [Unreleased] + +### Planned + +- Multi-stage rocket support +- Atmospheric drag integrated into trajectory simulation +- 3D trajectory visualization +- Real-time simulation mode +- Export to various data formats (CSV, JSON) +- Orbital insertion calculations + +## [0.1.0] + +First public release on PyPI. ### Added @@ -73,7 +86,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - pytest test suite with fixtures - Pre-commit hooks configuration - GitHub Actions CI/CD - - Black, isort, ruff for code quality + - Ruff for linting and formatting - mypy for type checking ### Technical Details @@ -83,15 +96,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - NumPy for numerical operations - Modern Python packaging with pyproject.toml - src-layout package structure - -## [Unreleased] - -### Planned - -- Multi-stage rocket support -- Atmospheric drag simulation -- 3D trajectory visualization -- Real-time simulation mode -- Rocket builder GUI -- Export to various data formats (CSV, JSON) -- Orbital insertion calculations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6611965..cbc7a89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,9 +47,8 @@ Feature suggestions are welcome! Please open an issue with: ``` 8. **Ensure code quality**: ```bash - black src tests - isort src tests - ruff check src tests + ruff check --fix src tests + ruff format src tests mypy src ``` 9. **Commit** with a clear message: @@ -108,20 +107,19 @@ pytest -k "test_gravity" This project uses: -- **Black** for code formatting -- **isort** for import sorting -- **Ruff** for linting +- **Ruff** for linting and formatting (replaces black, isort, flake8, pyupgrade) - **mypy** for type checking All of these run automatically via pre-commit hooks, but you can also run them manually: ```bash -# Format code -black src tests -isort src tests +# Lint and auto-fix +ruff check --fix src tests -# Check for issues -ruff check src tests +# Format +ruff format src tests + +# Type check mypy src ``` diff --git a/README.md b/README.md index 83bdc8e..82eaf60 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,49 @@ # Rocket Simulator -[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![PyPI](https://img.shields.io/pypi/v/rocket-sim.svg)](https://pypi.org/project/rocket-sim/) +[![Python](https://img.shields.io/pypi/pyversions/rocket-sim.svg)](https://pypi.org/project/rocket-sim/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Tests](https://img.shields.io/badge/tests-passing-brightgreen.svg)]() -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![CI](https://github.com/rramboer/rocket-sim/actions/workflows/ci.yml/badge.svg)](https://github.com/rramboer/rocket-sim/actions/workflows/ci.yml) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -A physics-based rocket trajectory simulation library for Python. Simulate rocket launches with realistic gravitational physics, visualize trajectories, and compare real-world rockets. +A small, educational physics-based rocket trajectory simulation library for Python. Simulate vertical rocket launches with altitude-dependent gravity, visualize trajectories, and compare real-world rockets. ![Rocket Trajectory Simulation](docs/images/rocket_comparison.png) ## Features -- **Realistic Physics** - Altitude-dependent gravity using Newton's inverse-square law -- **Pre-configured Rockets** - 13 real-world rockets including Saturn V, Falcon 9, Starship, and more -- **Custom Rockets** - Create and simulate your own rocket designs -- **Beautiful Visualizations** - Publication-quality plots with multiple styling options -- **CLI & Library** - Use as a command-line tool or import as a Python library -- **Type-Safe** - Full type hints for excellent IDE support -- **Well-Tested** - Comprehensive test suite with pytest +- **Altitude-dependent gravity** using Newton's inverse-square law +- **13 pre-configured rockets** including Saturn V, Falcon 9, Starship, and more +- **Custom rockets** — create and simulate your own designs +- **Multiple celestial bodies** — Earth, Moon, and Mars, or roll your own +- **Plots** with multiple matplotlib styles (trajectory, velocity, dashboard, comparison) +- **CLI & library** — use as a command-line tool or import as a Python library +- **Type-safe** — full type hints throughout the public API +- **Well-tested** — pytest suite covering physics and simulation logic + +### Modelling caveats + +This is a deliberately simplified educational model: + +- Motion is **1-D vertical only** (no pitch-over, no horizontal velocity, no orbital insertion). +- **Mass is constant** during flight; propellant burn is not modelled. +- **No atmospheric drag** is applied to trajectories. (`Physics.atmospheric_density` and `Physics.drag_force` are exposed as standalone utilities for users doing their own analyses.) + +For a realistic 6-DOF aerospace simulation, see tools like RocketPy. ## Quick Start ### Installation ```bash -# Clone the repository -git clone https://github.com/yourusername/rocket-sim.git -cd rocket-sim +pip install rocket-sim +``` -# Install in development mode -pip install -e . +Or, for development: -# Or install with development dependencies +```bash +git clone https://github.com/rramboer/rocket-sim.git +cd rocket-sim pip install -e ".[dev]" ``` @@ -130,9 +141,11 @@ plotter.plot_dashboard(result, filename="dashboard.png") | Long March 5 | 867,000 | 10,600,000 | 480 | 1.25 | | Vega | 137,000 | 2,310,000 | 110 | 1.72 | | Electron | 12,550 | 240,000 | 150 | 1.95 | -| New Shepard | 75,000 | 490,000 | 110 | 0.67 | +| New Shepard † | 75,000 | 490,000 | 110 | 0.67 | | Vulcan Centaur | 546,700 | 11,340,000 | 180 | 2.11 | +† New Shepard's listed first-stage thrust gives T/W < 1 at the masses shown, so the simulator reports immediate landing. The numbers reflect public BE-3 specs; treat the result as a sanity-check signal, not a flight prediction. + ## Physics Model The simulation uses realistic physics including: @@ -149,7 +162,7 @@ The simulation uses realistic physics including: v_esc = √(2GM / (R + h)) ``` -- **Atmospheric Density** (optional): Exponential atmosphere model +- **Atmospheric Density** (utility, *not* applied to trajectories): Exponential atmosphere model exposed via `Physics.atmospheric_density(...)` ``` ρ(h) = ρ₀ × exp(-h / H) ``` @@ -188,7 +201,7 @@ rocket-sim/ ```bash # Clone and install with dev dependencies -git clone https://github.com/yourusername/rocket-sim.git +git clone https://github.com/rramboer/rocket-sim.git cd rocket-sim pip install -e ".[dev]" @@ -212,12 +225,9 @@ pytest tests/test_physics.py ### Code Quality ```bash -# Format code -black src tests -isort src tests - -# Lint -ruff check src tests +# Lint and format (Ruff handles both) +ruff check --fix src tests +ruff format src tests # Type checking mypy src diff --git a/pyproject.toml b/pyproject.toml index 981db36..87ea8e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "rocket-sim" -version = "1.0.0" +version = "0.1.0" description = "A physics-based rocket trajectory simulation library" readme = "README.md" license = "MIT" @@ -21,7 +21,7 @@ keywords = [ "education", ] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", @@ -48,8 +48,6 @@ dev = [ "pytest-cov>=4.0.0", "mypy>=1.0.0", "ruff>=0.1.0", - "black>=23.0.0", - "isort>=5.12.0", "pre-commit>=3.0.0", ] docs = [ @@ -62,11 +60,11 @@ docs = [ rocket-sim = "rocket_sim.cli:cli_main" [project.urls] -Homepage = "https://github.com/yourusername/rocket-sim" -Documentation = "https://github.com/yourusername/rocket-sim#readme" -Repository = "https://github.com/yourusername/rocket-sim.git" -Issues = "https://github.com/yourusername/rocket-sim/issues" -Changelog = "https://github.com/yourusername/rocket-sim/blob/main/CHANGELOG.md" +Homepage = "https://github.com/rramboer/rocket-sim" +Documentation = "https://github.com/rramboer/rocket-sim#readme" +Repository = "https://github.com/rramboer/rocket-sim.git" +Issues = "https://github.com/rramboer/rocket-sim/issues" +Changelog = "https://github.com/rramboer/rocket-sim/blob/main/CHANGELOG.md" [tool.hatch.build.targets.sdist] include = [ @@ -105,18 +103,8 @@ ignore = [ [tool.ruff.lint.isort] known-first-party = ["rocket_sim"] -# Black - Code formatter -[tool.black] -line-length = 100 -target-version = ["py310", "py311", "py312"] -include = '\.pyi?$' - -# isort - Import sorter -[tool.isort] -profile = "black" -line_length = 100 -known_first_party = ["rocket_sim"] -src_paths = ["src", "tests"] +[tool.ruff.format] +docstring-code-format = true # MyPy - Static type checker [tool.mypy] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index d4c8a73..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,26 +0,0 @@ -# Development dependencies for rocket-sim -# Install with: pip install -r requirements-dev.txt - -# Include production dependencies --r requirements.txt - -# Testing -pytest>=7.0.0 -pytest-cov>=4.0.0 -pytest-xdist>=3.0.0 - -# Type checking -mypy>=1.0.0 - -# Linting and formatting -ruff>=0.1.0 -black>=23.0.0 -isort>=5.12.0 - -# Pre-commit hooks -pre-commit>=3.0.0 - -# Documentation (optional) -sphinx>=6.0.0 -sphinx-rtd-theme>=1.2.0 -myst-parser>=1.0.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 612afa1..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Production dependencies for rocket-sim -# Install with: pip install -r requirements.txt - -matplotlib>=3.7.0 -numpy>=1.24.0 diff --git a/src/rocket_sim/__init__.py b/src/rocket_sim/__init__.py index a35fc6d..972d51c 100644 --- a/src/rocket_sim/__init__.py +++ b/src/rocket_sim/__init__.py @@ -7,17 +7,18 @@ from rocket_sim.config import SimulationConfig from rocket_sim.models import Engine, Rocket, RocketConfig -from rocket_sim.physics import Physics +from rocket_sim.physics import CelestialBody, Physics from rocket_sim.presets import PRESETS, get_preset, list_presets from rocket_sim.simulation import ( RocketSimulation, SimulationResult, + SimulationState, simulate_multiple, simulate_rocket, ) from rocket_sim.visualization import PlotOptions, PlotStyle, Plotter -__version__ = "1.0.0" +__version__ = "0.1.0" __author__ = "Ryan R" __all__ = [ # Core classes @@ -26,11 +27,13 @@ "RocketConfig", "RocketSimulation", "SimulationResult", + "SimulationState", # Simulation functions "simulate_rocket", "simulate_multiple", # Physics "Physics", + "CelestialBody", # Configuration "SimulationConfig", # Presets diff --git a/src/rocket_sim/cli.py b/src/rocket_sim/cli.py index e54ff44..6c38068 100644 --- a/src/rocket_sim/cli.py +++ b/src/rocket_sim/cli.py @@ -336,7 +336,6 @@ def main(argv: list[str] | None = None) -> int: sim_config = SimulationConfig( dt=args.dt, max_time=args.max_time, - log_level=logging.DEBUG if args.verbose >= 2 else logging.WARNING, ) # Run simulations diff --git a/src/rocket_sim/config.py b/src/rocket_sim/config.py index 1c3cafe..df1a654 100644 --- a/src/rocket_sim/config.py +++ b/src/rocket_sim/config.py @@ -8,20 +8,11 @@ from __future__ import annotations import json -import logging -from dataclasses import dataclass, field -from enum import Enum +from dataclasses import dataclass from pathlib import Path from typing import Any -class IntegrationMethod(Enum): - """Numerical integration methods available for simulation.""" - - EULER = "euler" - EULER_CROMER = "euler_cromer" # Symplectic Euler - better energy conservation - - @dataclass class SimulationConfig: """ @@ -30,16 +21,12 @@ class SimulationConfig: Attributes: dt: Time step for simulation in seconds. max_time: Maximum simulation duration in seconds. - integration_method: Numerical integration method to use. detect_escape: Whether to stop simulation on escape velocity. - log_level: Logging verbosity level. """ dt: float = 0.1 max_time: float = 1_000_000.0 - integration_method: IntegrationMethod = IntegrationMethod.EULER detect_escape: bool = True - log_level: int = logging.INFO def __post_init__(self) -> None: """Validate configuration parameters.""" @@ -53,24 +40,16 @@ def to_dict(self) -> dict[str, Any]: return { "dt": self.dt, "max_time": self.max_time, - "integration_method": self.integration_method.value, "detect_escape": self.detect_escape, - "log_level": self.log_level, } @classmethod def from_dict(cls, data: dict[str, Any]) -> SimulationConfig: """Create configuration from dictionary.""" - method = data.get("integration_method", "euler") - if isinstance(method, str): - method = IntegrationMethod(method) - return cls( dt=float(data.get("dt", 0.1)), max_time=float(data.get("max_time", 1_000_000.0)), - integration_method=method, detect_escape=bool(data.get("detect_escape", True)), - log_level=int(data.get("log_level", logging.INFO)), ) def save(self, path: Path | str) -> None: @@ -86,46 +65,3 @@ def load(cls, path: Path | str) -> SimulationConfig: with open(path) as f: data = json.load(f) return cls.from_dict(data) - - -@dataclass -class PlotConfig: - """ - Configuration for plot output. - - Attributes: - figsize: Figure size as (width, height) in inches. - dpi: Resolution in dots per inch. - style: Matplotlib style to use. - show_grid: Whether to display grid lines. - show_legend: Whether to display legend. - altitude_unit: Unit for altitude display ('m', 'km', 'mi'). - time_unit: Unit for time display ('s', 'min', 'h'). - """ - - figsize: tuple[float, float] = (12, 8) - dpi: int = 150 - style: str = "seaborn-v0_8-darkgrid" - show_grid: bool = True - show_legend: bool = True - altitude_unit: str = "km" - time_unit: str = "s" - title: str = "Rocket Trajectory Simulation" - - # Unit conversion factors - _altitude_factors: dict[str, float] = field( - default_factory=lambda: {"m": 1.0, "km": 0.001, "mi": 0.000621371}, - repr=False, - ) - _time_factors: dict[str, float] = field( - default_factory=lambda: {"s": 1.0, "min": 1 / 60, "h": 1 / 3600}, - repr=False, - ) - - def get_altitude_factor(self) -> float: - """Get conversion factor for altitude unit.""" - return self._altitude_factors.get(self.altitude_unit, 0.001) - - def get_time_factor(self) -> float: - """Get conversion factor for time unit.""" - return self._time_factors.get(self.time_unit, 1.0) diff --git a/src/rocket_sim/models.py b/src/rocket_sim/models.py index 70af2d4..7df1565 100644 --- a/src/rocket_sim/models.py +++ b/src/rocket_sim/models.py @@ -74,20 +74,15 @@ def from_dict(cls, data: dict[str, float | str]) -> RocketConfig: @dataclass class Engine: """ - Represents a rocket engine. - - Models a simple rocket engine with constant thrust output during - its burn time. + Represents a rocket engine with constant thrust during its burn time. Attributes: thrust: Thrust force in Newtons. burn_time: Duration of thrust in seconds. - specific_impulse: Engine efficiency in seconds (optional). """ thrust: float # Newtons burn_time: float # seconds - specific_impulse: float = 300.0 # seconds (typical for chemical rockets) def __post_init__(self) -> None: """Validate engine parameters.""" @@ -95,8 +90,6 @@ def __post_init__(self) -> None: raise ValueError(f"Thrust cannot be negative: {self.thrust}") if self.burn_time < 0: raise ValueError(f"Burn time cannot be negative: {self.burn_time}") - if self.specific_impulse <= 0: - raise ValueError(f"Specific impulse must be positive: {self.specific_impulse}") @property def total_impulse(self) -> float: @@ -104,7 +97,7 @@ def total_impulse(self) -> float: return self.thrust * self.burn_time def is_burning(self, elapsed_time: float) -> bool: - """Check if engine is still burning at given time.""" + """Check if engine is burning at given time. Returns True for elapsed_time in [0, burn_time).""" return 0 <= elapsed_time < self.burn_time def get_thrust(self, elapsed_time: float) -> float: @@ -125,12 +118,13 @@ class Rocket: """ Represents a rocket with physical state and propulsion. - This class models the rocket's physical properties and tracks its - state (position, velocity) during simulation. + Note: Mass is held constant throughout the simulation; propellant + burn is not modelled. This is the "constant-mass approximation." Attributes: - mass: Rocket mass in kilograms. + mass: Rocket mass in kilograms (treated as constant). engine: Engine instance providing propulsion. + body: Celestial body for gravity calculations. Defaults to Earth. altitude: Current altitude in meters. velocity: Current velocity in m/s (positive = upward). time: Elapsed time since launch in seconds. @@ -138,6 +132,7 @@ class Rocket: mass: float engine: Engine + body: CelestialBody | None = None altitude: float = field(default=0.0, init=False) velocity: float = field(default=0.0, init=False) time: float = field(default=0.0, init=False) @@ -148,18 +143,28 @@ def __post_init__(self) -> None: raise ValueError(f"Mass must be positive: {self.mass}") @classmethod - def from_config(cls, config: RocketConfig) -> Rocket: + def from_config( + cls, + config: RocketConfig, + body: CelestialBody | None = None, + ) -> Rocket: """ Create a Rocket instance from a RocketConfig. Args: config: Configuration parameters for the rocket. + body: Celestial body for gravity. Defaults to Earth. Returns: A new Rocket instance. """ engine = Engine(thrust=config.thrust, burn_time=config.burn_time) - return cls(mass=config.mass, engine=engine) + return cls(mass=config.mass, engine=engine, body=body) + + @property + def _body(self) -> CelestialBody: + """Resolve body to Earth if unset (kept private to avoid changing the dataclass field type).""" + return self.body if self.body is not None else Physics.EARTH def reset(self) -> None: """Reset rocket to initial launch state.""" @@ -167,37 +172,35 @@ def reset(self) -> None: self.velocity = 0.0 self.time = 0.0 - def update( - self, - dt: float, - body: CelestialBody | None = None, - ) -> tuple[float, float]: + def update(self, dt: float) -> tuple[float, float]: """ Update rocket state for a time step. Computes acceleration from thrust and gravity, then updates - velocity and position using simple Euler integration. + velocity and position using symplectic Euler (Euler-Cromer) + integration, which conserves energy better than the plain + Euler method for orbital trajectories. + + Mass is held constant — no propellant burn is modelled. Args: dt: Time step in seconds. - body: Celestial body for gravity calculation. Defaults to Earth. Returns: Tuple of (altitude, velocity) after the update. """ - # Get current gravity - gravity = Physics.gravity_at_altitude(self.altitude, body) + gravity = Physics.gravity_at_altitude(self.altitude, self._body) - # Calculate acceleration thrust = self.engine.get_thrust(self.time) if thrust > 0: # noqa: SIM108 + # Kept as if/else for readability; thrust and coast acceleration are physically distinct. # During burn: a = (T - mg) / m = T/m - g acceleration = thrust / self.mass - gravity else: # Coasting: only gravity acceleration = -gravity - # Euler integration + # Symplectic Euler: update velocity first, then position with new velocity self.velocity += acceleration * dt self.altitude += self.velocity * dt self.time += dt @@ -217,15 +220,17 @@ def kinetic_energy(self) -> float: @property def potential_energy(self) -> float: """ - Calculate gravitational potential energy relative to surface. + Calculate gravitational potential energy relative to the surface. - Uses the exact formula accounting for varying gravity with altitude. + Uses the exact integral of GMm/r² (variable gravity), expressed + equivalently as PE = m·g₀·h · R/(R + h), where g₀ and R are the + body's surface gravity and radius. """ - g0 = Physics.EARTH.surface_gravity - r = Physics.EARTH.radius - # PE = mgh * (r / (r + h)) for varying gravity - if self.altitude == 0: + if self.altitude <= 0: return 0.0 + body = self._body + g0 = body.surface_gravity + r = body.radius return self.mass * g0 * self.altitude * (r / (r + self.altitude)) @property @@ -241,4 +246,4 @@ def is_ascending(self) -> bool: @property def is_on_ground(self) -> bool: """Check if rocket is on the ground.""" - return self.altitude == 0 and self.velocity == 0 + return self.altitude <= 0 and self.velocity <= 0 diff --git a/src/rocket_sim/physics.py b/src/rocket_sim/physics.py index 1969ec8..6a05efc 100644 --- a/src/rocket_sim/physics.py +++ b/src/rocket_sim/physics.py @@ -82,10 +82,12 @@ def gravity_at_altitude( ValueError: If altitude is negative. Examples: - >>> Physics.gravity_at_altitude(0) # Surface gravity + >>> Physics.gravity_at_altitude( + ... 0 + ... ) # Surface gravity (from GM/R²; ~9.82, slightly above the conventional 9.80665) 9.819... - >>> Physics.gravity_at_altitude(400_000) # ISS orbit ~400km - 8.676... + >>> Physics.gravity_at_altitude(400_000) # ISS orbit ~400 km + 8.694... """ if altitude < 0: raise ValueError(f"Altitude cannot be negative: {altitude}") @@ -120,9 +122,9 @@ def escape_velocity( Examples: >>> Physics.escape_velocity(0) # From Earth's surface - 11185.7... + 11185.9... >>> Physics.escape_velocity(400_000) # From ISS orbit - 10926.5... + 10850.5... """ if altitude < 0: raise ValueError(f"Altitude cannot be negative: {altitude}") @@ -167,20 +169,25 @@ def orbital_velocity( @staticmethod def atmospheric_density(altitude: float) -> float: """ - Estimate atmospheric density using exponential atmosphere model. + Estimate atmospheric density using an exponential-atmosphere model. This is a simplified model that assumes exponential decay of - atmospheric density with altitude. Accurate for altitudes up to - about 100 km. + atmospheric density with altitude. Reasonable up to about 100 km. + + Note: + This helper is exposed for users doing their own analyses; it + is **not** invoked by the default `RocketSimulation` trajectory + loop. The trajectory simulation is currently atmosphere-free. Args: altitude: Height above surface in meters. Must be >= 0. Returns: - Atmospheric density in kg/m^3. + Atmospheric density in kg/m^3. Returns 0 for altitudes above + 100 km (Karman line). - Note: - Returns 0 for altitudes above 100 km (Karman line). + Raises: + ValueError: If altitude is negative. """ if altitude < 0: raise ValueError(f"Altitude cannot be negative: {altitude}") @@ -207,6 +214,11 @@ def drag_force( Uses the standard drag equation: F_d = 0.5 * rho * v^2 * C_d * A + Note: + Like `atmospheric_density`, this is a standalone utility for + users doing their own analyses. The default `RocketSimulation` + trajectory loop does not apply drag. + Args: velocity: Speed in m/s. altitude: Height above surface in meters. @@ -215,6 +227,9 @@ def drag_force( Returns: Drag force in Newtons (always positive, opposing motion). + + Raises: + ValueError: If altitude is negative (propagated from atmospheric_density). """ rho = Physics.atmospheric_density(altitude) return 0.5 * rho * velocity**2 * drag_coefficient * cross_sectional_area diff --git a/src/rocket_sim/presets.py b/src/rocket_sim/presets.py index e6f19c9..47a4f5c 100644 --- a/src/rocket_sim/presets.py +++ b/src/rocket_sim/presets.py @@ -7,6 +7,8 @@ from __future__ import annotations +from dataclasses import replace + from rocket_sim.models import RocketConfig # Pre-configured rocket specifications based on real-world data @@ -110,15 +112,13 @@ def get_preset(name: str) -> RocketConfig: >>> config.thrust 7607000 """ - # Try exact match first if name in PRESETS: - return PRESETS[name] + return replace(PRESETS[name]) - # Try case-insensitive match name_lower = name.lower() for preset_name, config in PRESETS.items(): if preset_name.lower() == name_lower: - return config + return replace(config) available = ", ".join(PRESETS.keys()) raise KeyError(f"Unknown preset: '{name}'. Available: {available}") diff --git a/src/rocket_sim/simulation.py b/src/rocket_sim/simulation.py index 1157f94..518a4fb 100644 --- a/src/rocket_sim/simulation.py +++ b/src/rocket_sim/simulation.py @@ -152,11 +152,9 @@ def __init__( self.sim_config = sim_config or SimulationConfig() self.body = body or Physics.EARTH - # Create rocket instance - self.rocket = Rocket.from_config(rocket_config) - - # Configure logging - logging.basicConfig(level=self.sim_config.log_level) + # Create rocket instance bound to this simulation's body so that + # gravity and energy properties stay consistent. + self.rocket = Rocket.from_config(rocket_config, body=self.body) logger.debug( f"Initialized simulation for {rocket_config.name} " @@ -182,9 +180,9 @@ def step(self, dt: float | None = None) -> SimulationState: dt = self.sim_config.dt old_velocity = self.rocket.velocity - altitude, velocity = self.rocket.update(dt, self.body) + altitude, velocity = self.rocket.update(dt) - acceleration = (velocity - old_velocity) / dt if dt > 0 else 0.0 + acceleration = (velocity - old_velocity) / dt is_burning = self.rocket.engine.is_burning(self.rocket.time) return SimulationState( @@ -252,7 +250,7 @@ def run(self) -> SimulationResult: result.flight_time = self.rocket.time logger.info( f"Escape velocity achieved at t={self.rocket.time:.2f}s, " - f"alt={state.altitude/1000:.2f}km" + f"alt={state.altitude / 1000:.2f}km" ) break @@ -307,6 +305,7 @@ def run_generator(self) -> Iterator[SimulationState]: def simulate_rocket( config: RocketConfig, sim_config: SimulationConfig | None = None, + body: CelestialBody | None = None, ) -> SimulationResult: """ Convenience function to run a single rocket simulation. @@ -314,17 +313,19 @@ def simulate_rocket( Args: config: Rocket configuration. sim_config: Optional simulation configuration. + body: Celestial body for gravity. Defaults to Earth. Returns: SimulationResult with trajectory data. """ - sim = RocketSimulation(config, sim_config) + sim = RocketSimulation(config, sim_config, body=body) return sim.run() def simulate_multiple( configs: list[RocketConfig], sim_config: SimulationConfig | None = None, + body: CelestialBody | None = None, ) -> list[SimulationResult]: """ Simulate multiple rockets. @@ -332,12 +333,9 @@ def simulate_multiple( Args: configs: List of rocket configurations. sim_config: Optional simulation configuration (shared). + body: Celestial body for gravity (shared). Defaults to Earth. Returns: List of SimulationResult objects. """ - results = [] - for config in configs: - result = simulate_rocket(config, sim_config) - results.append(result) - return results + return [simulate_rocket(config, sim_config, body=body) for config in configs]