Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Pre-launch design validator** (`validate_design`, `DesignWarning`,
`format_warnings`) with heuristic checks for marginal thrust-to-weight,
predicted apogee, transonic flight, motor-too-big-for-airframe,
ballistic descent, and "lawn dart" timing. Runs automatically in the
CLI; pass `--no-validate` to skip.
- **CSV and JSON export** on `SimulationResult` (`.to_csv(path)`,
`.to_dict()`, `.to_json(path)`) for downstream notebook / spreadsheet
analysis. CLI flags `--csv FILE` and `--json FILE` write the same data.
- **`1/2A6-2` motor preset** for use with the Mosquito kit and other
small rockets — replaces the previous silent fallback to A8-3.
- CLI exit code `2` when the design validator returns any `error`-level
warning, for use in CI / scripts.

### Planned

- More built-in motor presets (1/2A, 1/4A, mid-power and high-power motors)
- More built-in motor presets (1/4A and mid-power)
- Wind / weathercocking (would extend the model from 1-D to 2-D)
- Multi-stage configurations
- Stability-margin analysis (CG vs CP)
Expand Down
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ the same rocket would do on the Moon, Mars, Venus, or Titan.
configured with its recommended motor and recovery.
- **`.eng` motor file loader** — drop in any motor from
[Thrustcurve.org](https://www.thrustcurve.org/).
- **Pre-launch design validator** flags marginal thrust-to-weight,
underpowered configurations, transonic flight, motors that don't fit
the body tube, and "lawn dart" timing.
- **CSV / JSON export** for the trajectory time-series and summary
stats — pipe straight into a Jupyter notebook or spreadsheet.
- **CLI and library** — use `rocket-sim` as a command-line tool or
`import rocket_sim` from your own scripts.

Expand Down Expand Up @@ -78,6 +83,9 @@ rocket-sim --kit big-bertha --dashboard -o flight.png
# Use a downloaded thrustcurve.org motor file
rocket-sim --kit alpha-iii --motor-file ./Estes_C6.eng

# Save the trajectory time-series for analysis
rocket-sim --kit alpha-iii --csv flight.csv --json flight.json --no-plot

# List available motors and kits
rocket-sim --list-motors
rocket-sim --list-kits
Expand Down Expand Up @@ -129,10 +137,32 @@ result = simulate_rocket(rocket)
print(result.summary())
```

### Validate a design

```python
from rocket_sim import get_kit, validate_design, format_warnings

rocket = get_kit("alpha-iii")
warnings = validate_design(rocket)
print(format_warnings(warnings))
# → "No design warnings."
```

### Export the time-series

```python
from rocket_sim import get_kit, simulate_rocket

result = simulate_rocket(get_kit("alpha-iii"))
result.to_csv("flight.csv")
result.to_json("flight.json")
```

## Built-in motor presets

| Designation | Total impulse | Burn time | Peak thrust | Delay grain |
|---|---|---|---|---|
| 1/2A6-2 | ~1.25 N·s | 0.32 s | ~7 N | 2 s |
| A8-3 | ~2.5 N·s | 0.5 s | ~13 N | 3 s |
| B6-4 | ~5.0 N·s | 0.86 s | ~12 N | 4 s |
| C6-3 | ~10.0 N·s | 1.85 s | ~14 N | 3 s |
Expand All @@ -152,7 +182,7 @@ data. For exact certified-motor data, download `.eng` files from
|---|---|---|---|---|
| Estes Alpha III | 34 g | 24.7 mm (BT-50) | C6-5 | 12-inch parachute |
| Estes Big Bertha | 77 g | 41.3 mm (BT-60) | C6-5 | 18-inch parachute |
| Estes Mosquito | 4.5 g | 13.2 mm (BT-5) | A8-3 | 30 × 2.5 cm streamer |
| Estes Mosquito | 4.5 g | 13.2 mm (BT-5) | 1/2A6-2 | 30 × 2.5 cm streamer |
| Estes V-2 | 64 g | 41.3 mm (BT-60) | C6-3 | 12-inch parachute |

## Built-in celestial bodies
Expand Down Expand Up @@ -198,6 +228,7 @@ rocket-sim/
│ ├── presets.py # Kit presets
│ ├── simulation.py # 3-phase trajectory integrator
│ ├── config.py # SimulationConfig
│ ├── validation.py # Pre-launch design validator
│ ├── visualization.py # Plotter
│ └── cli.py # rocket-sim CLI
├── tests/
Expand Down
5 changes: 5 additions & 0 deletions src/rocket_sim/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
simulate_multiple,
simulate_rocket,
)
from rocket_sim.validation import DesignWarning, format_warnings, validate_design
from rocket_sim.visualization import PlotOptions, PlotStyle, Plotter

__version__ = "0.1.0"
Expand Down Expand Up @@ -70,6 +71,10 @@
"get_kit",
"get_kit_info",
"list_kits",
# Validation
"DesignWarning",
"validate_design",
"format_warnings",
# Visualisation
"Plotter",
"PlotStyle",
Expand Down
48 changes: 45 additions & 3 deletions src/rocket_sim/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from rocket_sim.physics import CelestialBody, Physics
from rocket_sim.presets import get_kit, get_kit_info, list_kits
from rocket_sim.simulation import simulate_multiple
from rocket_sim.validation import DesignWarning, format_warnings, validate_design
from rocket_sim.visualization import PlotOptions, PlotStyle, Plotter

BODIES: dict[str, CelestialBody] = {
Expand Down Expand Up @@ -152,6 +153,23 @@ def create_parser() -> argparse.ArgumentParser:
help="Matplotlib style",
)
out_group.add_argument("--dpi", type=int, default=150, help="Plot DPI (default: 150)")
out_group.add_argument(
"--csv",
type=Path,
metavar="FILE",
help="Write the trajectory time-series to a CSV file",
)
out_group.add_argument(
"--json",
type=Path,
metavar="FILE",
help="Write the result (summary + time-series) to a JSON file",
)
out_group.add_argument(
"--no-validate",
action="store_true",
help="Skip the pre-launch design validation step",
)

# Verbosity.
parser.add_argument("-v", "--verbose", action="count", default=0, help="Increase verbosity")
Expand Down Expand Up @@ -336,21 +354,45 @@ def main(argv: list[str] | None = None) -> int:
print(f" Launch mass: {rocket.launch_mass_kg * 1000:.1f} g")
print()

# Pre-launch design validation (always runs unless --no-validate);
# the validator runs its own simulation, which we reuse below to
# avoid double-simulating.
design_warnings: list[DesignWarning] = []
if not args.no_validate:
design_warnings = validate_design(rocket, sim_config)
if not args.quiet and design_warnings:
print(format_warnings(design_warnings))
print()

results = simulate_multiple([rocket], sim_config)
result = results[0]

if not args.quiet:
print(results[0].summary())
print(result.summary())
print()

# Time-series exports.
if args.csv:
result.to_csv(args.csv)
if not args.quiet:
print(f"Wrote CSV: {args.csv}")
if args.json:
result.to_json(args.json)
if not args.quiet:
print(f"Wrote JSON: {args.json}")

# Plot.
if not args.no_plot or args.output:
options = PlotOptions(style=PlotStyle(args.style), dpi=args.dpi)
plotter = Plotter(options)
if args.dashboard:
plotter.plot_dashboard(results[0], filename=args.output, show=not args.no_plot)
plotter.plot_dashboard(result, filename=args.output, show=not args.no_plot)
else:
plotter.plot_trajectory(results[0], filename=args.output, show=not args.no_plot)
plotter.plot_trajectory(result, filename=args.output, show=not args.no_plot)

# Exit code reflects the worst severity flagged.
if any(w.severity == "error" for w in design_warnings):
return 2
return 0


Expand Down
18 changes: 18 additions & 0 deletions src/rocket_sim/motors.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,24 @@ def _make(


MOTORS: dict[str, Motor] = {
"1/2A6-2": _make(
"1/2A6-2",
diameter_mm=13.0,
length_mm=45.0,
propellant_g=1.66,
total_g=4.7,
delay_s=2.0,
# Total impulse ~1.25 N*s, peak ~7 N, burn ~0.32 s
curve=(
(0.0, 0.0),
(0.04, 5.0),
(0.08, 7.0),
(0.13, 4.0),
(0.20, 2.5),
(0.32, 1.5),
(0.32, 0.0),
),
),
"A8-3": _make(
"A8-3",
diameter_mm=18.0,
Expand Down
20 changes: 3 additions & 17 deletions src/rocket_sim/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"mosquito": {
"name": "Estes Mosquito",
"dry_mass_kg": 0.0045, # 4.5 g — extremely small
"motor": "1/2A6-2", # Not in the built-in motor presets — we'll fall back below.
"motor": "1/2A6-2",
"diameter_m": 0.0132, # BT-5 ≈ 13.2 mm
"drag_coefficient": 0.75,
"recovery": Streamer(length_m=0.30, width_m=0.025, drag_coefficient=0.5),
Expand All @@ -57,20 +57,6 @@
},
}

# The Mosquito's stock motor (1/2A6-2) isn't in our built-in motor
# presets, so substitute the smallest motor we ship (A8-3) and note
# that in the docstring. Users wanting the exact stock motor can load
# it from a .eng file.
_KIT_MOTOR_FALLBACKS: dict[str, str] = {
"1/2A6-2": "A8-3",
}


def _resolve_motor(designation: str) -> object:
"""Return a fresh Motor for a designation, with fallback for unsupported codes."""
fallback = _KIT_MOTOR_FALLBACKS.get(designation, designation)
return get_motor(fallback)


def get_kit(name: str) -> Rocket:
"""
Expand All @@ -95,7 +81,7 @@ def get_kit(name: str) -> Rocket:
available = ", ".join(_KIT_SPECS.keys())
raise KeyError(f"Unknown kit: {name!r}. Available: {available}")
spec = _KIT_SPECS[key]
motor = _resolve_motor(spec["motor"]) # type: ignore[arg-type]
motor = get_motor(spec["motor"]) # type: ignore[arg-type]
recovery = spec["recovery"]
if recovery is not None:
# Recovery is frozen but cheap; still return a fresh copy via replace
Expand All @@ -104,7 +90,7 @@ def get_kit(name: str) -> Rocket:
return Rocket(
name=spec["name"], # type: ignore[arg-type]
dry_mass_kg=spec["dry_mass_kg"], # type: ignore[arg-type]
motor=motor, # type: ignore[arg-type]
motor=motor,
diameter_m=spec["diameter_m"], # type: ignore[arg-type]
drag_coefficient=spec["drag_coefficient"], # type: ignore[arg-type]
recovery=recovery, # type: ignore[arg-type]
Expand Down
80 changes: 80 additions & 0 deletions src/rocket_sim/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@

from __future__ import annotations

import csv
import json
import logging
from collections.abc import Iterator
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any

from rocket_sim.config import SimulationConfig
from rocket_sim.models import Parachute, Rocket, Streamer
Expand Down Expand Up @@ -130,6 +134,79 @@ def summary(self) -> str:
f" Landing velocity: {self.landing_velocity_ms:7.2f} m/s"
)

# --- Export helpers ---------------------------------------------------

_CSV_COLUMNS = (
"time_s",
"altitude_m",
"velocity_ms",
"acceleration_ms2",
"mass_kg",
"thrust_n",
"drag_n",
"phase",
)

def to_csv(self, path: Path | str) -> None:
"""
Write the time-series of states to a CSV file.

Columns: time_s, altitude_m, velocity_ms, acceleration_ms2,
mass_kg, thrust_n, drag_n, phase. One row per simulation step.
"""
with open(path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(self._CSV_COLUMNS)
for s in self.states:
writer.writerow(
[
s.time,
s.altitude,
s.velocity,
s.acceleration,
s.mass,
s.thrust,
s.drag,
s.phase.value,
]
)

def to_dict(self) -> dict[str, Any]:
"""Return a JSON-serialisable dict containing summary stats and the time-series."""
return {
"rocket_name": self.rocket_name,
"summary": {
"apogee_m": self.apogee_m,
"apogee_time_s": self.apogee_time_s,
"burnout_altitude_m": self.burnout_altitude_m,
"burnout_velocity_ms": self.burnout_velocity_ms,
"burnout_time_s": self.burnout_time_s,
"max_velocity_ms": self.max_velocity_ms,
"max_acceleration_ms2": self.max_acceleration_ms2,
"flight_time_s": self.flight_time_s,
"recovery_deployment_time_s": self.recovery_deployment_time_s,
"landing_velocity_ms": self.landing_velocity_ms,
"deployed_below_ground": self.deployed_below_ground,
},
"states": [
{
"time_s": s.time,
"altitude_m": s.altitude,
"velocity_ms": s.velocity,
"acceleration_ms2": s.acceleration,
"mass_kg": s.mass,
"thrust_n": s.thrust,
"drag_n": s.drag,
"phase": s.phase.value,
}
for s in self.states
],
}

def to_json(self, path: Path | str, indent: int | None = 2) -> None:
"""Write the result (summary + time-series) to a JSON file."""
Path(path).write_text(json.dumps(self.to_dict(), indent=indent))


def _recovery_drag_term(rocket: Rocket) -> tuple[float, float]:
"""
Expand Down Expand Up @@ -291,6 +368,9 @@ def run(self) -> SimulationResult:
if phase != FlightPhase.LANDED:
result.flight_time_s = t
result.landing_velocity_ms = abs(velocity)
# Lawn dart: ground impact before recovery deployed.
if recovery_deploy_t is None and rocket.recovery is not None:
result.deployed_below_ground = True
phase = FlightPhase.LANDED
result.states.append(
SimulationState(
Expand Down
Loading
Loading