From b383d3c4fee4a2db15890d47db6c30d1f58ea883 Mon Sep 17 00:00:00 2001 From: Ryan Ramboer Date: Thu, 7 May 2026 22:20:06 -0500 Subject: [PATCH] feat: design validator, CSV/JSON export, 1/2A6-2 motor preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small but high-value additions, stacking on the hobby-rocket pivot (PR #2): 1. Pre-launch design validator (validate_design, DesignWarning, format_warnings). Heuristic checks for marginal thrust-to-weight, underpowered configurations, transonic flight without reinforced airframe, motor too big for the airframe, ballistic descent, and "lawn dart" timing. Wired into the CLI by default; --no-validate disables it. CLI returns exit code 2 if any error-level warning fires so it's usable in CI / scripts. 2. CSV and JSON export on SimulationResult (.to_csv, .to_dict, .to_json). CLI flags --csv FILE / --json FILE. Unlocks downstream notebook / spreadsheet workflows without forcing users to reach into the states list themselves. 3. 1/2A6-2 motor preset added to MOTORS, with the Mosquito kit now pointing at it directly. Removes the previous silent fallback that substituted A8-3 when 1/2A6-2 was requested — that fallback is gone, along with the _resolve_motor / _KIT_MOTOR_FALLBACKS plumbing. Also fixes a missed case in the simulator: the lawn-dart flag (SimulationResult.deployed_below_ground) was only set when the recovery charge fired below ground, not when the rocket landed before recovery ever deployed. Now set in both cases. Tests: +18 (114 total). All ruff + mypy + pytest + build + twine clean. README and CHANGELOG updated with new flags, exports, and motor row. --- CHANGELOG.md | 17 ++- README.md | 33 ++++- src/rocket_sim/__init__.py | 5 + src/rocket_sim/cli.py | 48 +++++++- src/rocket_sim/motors.py | 18 +++ src/rocket_sim/presets.py | 20 +-- src/rocket_sim/simulation.py | 80 ++++++++++++ src/rocket_sim/validation.py | 228 +++++++++++++++++++++++++++++++++++ tests/test_export.py | 83 +++++++++++++ tests/test_motors.py | 9 ++ tests/test_validation.py | 162 +++++++++++++++++++++++++ 11 files changed, 681 insertions(+), 22 deletions(-) create mode 100644 src/rocket_sim/validation.py create mode 100644 tests/test_export.py create mode 100644 tests/test_validation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 086ba05..31e76bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 6031771..20c05c5 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 | @@ -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 @@ -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/ diff --git a/src/rocket_sim/__init__.py b/src/rocket_sim/__init__.py index 3706d1a..9d5f8c7 100644 --- a/src/rocket_sim/__init__.py +++ b/src/rocket_sim/__init__.py @@ -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" @@ -70,6 +71,10 @@ "get_kit", "get_kit_info", "list_kits", + # Validation + "DesignWarning", + "validate_design", + "format_warnings", # Visualisation "Plotter", "PlotStyle", diff --git a/src/rocket_sim/cli.py b/src/rocket_sim/cli.py index 3339e9f..d581093 100644 --- a/src/rocket_sim/cli.py +++ b/src/rocket_sim/cli.py @@ -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] = { @@ -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") @@ -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 diff --git a/src/rocket_sim/motors.py b/src/rocket_sim/motors.py index f78f5b7..afc5b6b 100644 --- a/src/rocket_sim/motors.py +++ b/src/rocket_sim/motors.py @@ -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, diff --git a/src/rocket_sim/presets.py b/src/rocket_sim/presets.py index 9731914..64d8768 100644 --- a/src/rocket_sim/presets.py +++ b/src/rocket_sim/presets.py @@ -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), @@ -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: """ @@ -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 @@ -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] diff --git a/src/rocket_sim/simulation.py b/src/rocket_sim/simulation.py index 5a3ed39..7b7484e 100644 --- a/src/rocket_sim/simulation.py +++ b/src/rocket_sim/simulation.py @@ -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 @@ -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]: """ @@ -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( diff --git a/src/rocket_sim/validation.py b/src/rocket_sim/validation.py new file mode 100644 index 0000000..4a10d57 --- /dev/null +++ b/src/rocket_sim/validation.py @@ -0,0 +1,228 @@ +""" +Pre-launch design validation for hobby rockets. + +The `validate_design` function runs a quick simulation of the supplied +rocket and returns a list of `DesignWarning` flags for common +hobby-rocketry problems: marginal thrust-to-weight, predicted apogee +too low, transonic flight without reinforced airframe, motor too big +for the body tube, mismatched delay grain causing a "lawn dart", etc. + +This is heuristic — it does not check stability margin (CG/CP), wind +weathercocking, or anything 2-D. Treat the warnings as starting +points, not certifications. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +from rocket_sim.config import SimulationConfig +from rocket_sim.physics import Physics +from rocket_sim.simulation import simulate_rocket + +if TYPE_CHECKING: + from rocket_sim.models import Rocket + +Severity = Literal["info", "warning", "error"] + +# Speed of sound at sea level on Earth, used as the transonic warning +# threshold. Real value varies with temperature and altitude; this is a +# rounded reference value. +SPEED_OF_SOUND_M_S: float = 343.0 + +# Recommended minimum thrust-to-weight ratio at peak thrust for stable +# launch-rod departure. Below this, the rocket may not stabilise on a +# standard ~1 m launch rod. +MIN_PEAK_TWR: float = 5.0 + +# Predicted apogees below this in meters get an "underpowered" warning. +MIN_APOGEE_M: float = 30.0 + + +@dataclass(frozen=True) +class DesignWarning: + """ + A single advisory or error flagged by `validate_design`. + + Attributes: + severity: ``"info"``, ``"warning"``, or ``"error"``. Errors + indicate the configuration won't fly (or won't fly safely); + warnings indicate suboptimal but functional configurations. + code: Short identifier (e.g. ``"low_twr"``, ``"transonic"``) + useful for programmatic filtering. + message: Human-readable description. + """ + + severity: Severity + code: str + message: str + + +def validate_design( + rocket: Rocket, + sim_config: SimulationConfig | None = None, +) -> list[DesignWarning]: + """ + Run pre-launch sanity checks on a `Rocket` configuration. + + Runs one simulation under the provided config (or defaults) and + examines both static design parameters and dynamic flight outcomes. + + Returns a list of `DesignWarning` instances, ordered roughly from + most serious to least serious. An empty list means no flagged + issues. + + Args: + rocket: The rocket configuration to check. + sim_config: Optional simulation config; defaults to + `SimulationConfig()`. + + Returns: + A list of warnings. May be empty. + """ + warnings: list[DesignWarning] = [] + + body = rocket.body if rocket.body is not None else Physics.EARTH + + # --- Static (no-sim) checks --- + + # Motor must physically fit in the body tube. + if rocket.motor.diameter_m > rocket.diameter_m * 1.001: + warnings.append( + DesignWarning( + severity="error", + code="motor_too_big", + message=( + f"Motor diameter ({rocket.motor.diameter_m * 1000:.1f} mm) exceeds " + f"airframe diameter ({rocket.diameter_m * 1000:.1f} mm); " + "motor will not fit in the body tube." + ), + ) + ) + + # Thrust-to-weight ratio at peak thrust. + weight_n = rocket.launch_mass_kg * body.surface_gravity + peak_twr = rocket.motor.peak_thrust / weight_n if weight_n > 0 else 0.0 + if peak_twr < 1.0: + warnings.append( + DesignWarning( + severity="error", + code="will_not_lift", + message=( + f"Peak thrust-to-weight ratio is only {peak_twr:.2f} on " + f"{body.name}; rocket cannot lift off." + ), + ) + ) + elif peak_twr < MIN_PEAK_TWR: + warnings.append( + DesignWarning( + severity="warning", + code="low_twr", + message=( + f"Peak thrust-to-weight ratio is {peak_twr:.2f} (below the " + f"recommended minimum of {MIN_PEAK_TWR:.0f}). The rocket may not " + "stabilise during launch-rod departure; consider a more powerful motor." + ), + ) + ) + + # --- Run a simulation, then check dynamic outcomes --- + + result = simulate_rocket(rocket, sim_config) + + if result.deployed_below_ground: + warnings.append( + DesignWarning( + severity="error", + code="lawn_dart", + message=( + f"Rocket impacts the ground at t = {result.flight_time_s:.2f} s, " + f"before the recovery system deploys at t = " + f"{rocket.motor.ejection_time():.2f} s. Use a shorter motor " + "delay grain to avoid a 'lawn dart' landing." + ), + ) + ) + + if result.apogee_m < MIN_APOGEE_M: + warnings.append( + DesignWarning( + severity="warning", + code="low_apogee", + message=( + f"Predicted apogee is only {result.apogee_m:.1f} m on " + f"{body.name}. Consider a more powerful motor or a lighter airframe." + ), + ) + ) + + if result.max_velocity_ms > SPEED_OF_SOUND_M_S: + warnings.append( + DesignWarning( + severity="warning", + code="transonic", + message=( + f"Predicted maximum velocity is {result.max_velocity_ms:.0f} m/s, " + f"exceeding the speed of sound ({SPEED_OF_SOUND_M_S:.0f} m/s). " + "Transonic flight stresses the airframe; reinforce fins and joints, " + "or choose a less powerful motor." + ), + ) + ) + + if rocket.recovery is None: + warnings.append( + DesignWarning( + severity="warning", + code="ballistic_descent", + message=( + f"No recovery system. Rocket will impact the ground at " + f"{result.landing_velocity_ms:.1f} m/s — likely destroying " + "the airframe and potentially injuring bystanders." + ), + ) + ) + + if result.recovery_deployment_time_s is not None and not result.deployed_below_ground: + # Check delay-grain timing accuracy. + delta = result.recovery_deployment_time_s - result.apogee_time_s + # Tolerate ±1 s of mismatch silently; flag larger ones as info. + if delta < -2.0: + warnings.append( + DesignWarning( + severity="info", + code="delay_too_short", + message=( + f"Recovery deploys {-delta:.1f} s before apogee while the rocket " + "is still ascending; parachute may shred. Consider a longer motor delay." + ), + ) + ) + elif delta > 2.0: + warnings.append( + DesignWarning( + severity="info", + code="delay_too_long", + message=( + f"Recovery deploys {delta:.1f} s after apogee; " + "rocket descends quite a bit before the chute opens. " + "Consider a shorter motor delay." + ), + ) + ) + + return warnings + + +def format_warnings(warnings: list[DesignWarning]) -> str: + """Format a list of warnings as a human-readable multi-line string.""" + if not warnings: + return "No design warnings." + lines = [] + icons = {"error": "✗", "warning": "⚠", "info": "i"} + for w in warnings: + icon = icons.get(w.severity, "•") + lines.append(f" {icon} [{w.severity.upper():7s} {w.code}] {w.message}") + return "Design warnings:\n" + "\n".join(lines) diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..3f78f8f --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,83 @@ +"""Tests for SimulationResult.to_csv / to_json export helpers.""" + +from __future__ import annotations + +import csv +import json + +from rocket_sim.models import Rocket +from rocket_sim.simulation import simulate_rocket + + +class TestCsvExport: + def test_writes_expected_columns(self, alpha_iii: Rocket, tmp_path) -> None: + result = simulate_rocket(alpha_iii) + path = tmp_path / "flight.csv" + result.to_csv(path) + with open(path) as f: + reader = csv.reader(f) + header = next(reader) + assert header == [ + "time_s", + "altitude_m", + "velocity_ms", + "acceleration_ms2", + "mass_kg", + "thrust_n", + "drag_n", + "phase", + ] + + def test_row_count_matches_states(self, alpha_iii: Rocket, tmp_path) -> None: + result = simulate_rocket(alpha_iii) + path = tmp_path / "flight.csv" + result.to_csv(path) + with open(path) as f: + reader = csv.reader(f) + rows = list(reader) + # header + N states + assert len(rows) == 1 + len(result.states) + + def test_first_row_starts_at_t_zero(self, alpha_iii: Rocket, tmp_path) -> None: + result = simulate_rocket(alpha_iii) + path = tmp_path / "flight.csv" + result.to_csv(path) + with open(path) as f: + reader = csv.DictReader(f) + first = next(reader) + assert float(first["time_s"]) == 0.0 + assert first["phase"] == "boost" + + +class TestJsonExport: + def test_to_dict_has_expected_keys(self, alpha_iii: Rocket) -> None: + result = simulate_rocket(alpha_iii) + d = result.to_dict() + assert "rocket_name" in d + assert "summary" in d + assert "states" in d + # Summary contains apogee. + assert "apogee_m" in d["summary"] + # States is non-empty list of dicts with the right keys. + assert isinstance(d["states"], list) + assert d["states"][0]["phase"] == "boost" + + def test_writes_valid_json(self, alpha_iii: Rocket, tmp_path) -> None: + result = simulate_rocket(alpha_iii) + path = tmp_path / "flight.json" + result.to_json(path) + # Parses without error. + loaded = json.loads(path.read_text()) + assert loaded["rocket_name"] == result.rocket_name + assert loaded["summary"]["apogee_m"] == result.apogee_m + assert len(loaded["states"]) == len(result.states) + + def test_states_match_summary_apogee(self, alpha_iii: Rocket, tmp_path) -> None: + result = simulate_rocket(alpha_iii) + path = tmp_path / "flight.json" + result.to_json(path) + loaded = json.loads(path.read_text()) + max_alt = max(s["altitude_m"] for s in loaded["states"]) + # apogee_m is reported relative to launch site; with default + # launch_altitude_m=0 they should match. + assert max_alt == loaded["summary"]["apogee_m"] diff --git a/tests/test_motors.py b/tests/test_motors.py index a70fed1..8e7a902 100644 --- a/tests/test_motors.py +++ b/tests/test_motors.py @@ -124,10 +124,19 @@ def test_ejection_time(self) -> None: class TestMotorPresets: def test_all_presets_present(self) -> None: names = list_motors() + assert "1/2A6-2" in names assert "A8-3" in names assert "C6-5" in names assert "F15-6" in names + def test_half_a_motor_classifies_correctly(self) -> None: + m = get_motor("1/2A6-2") + # 1/2A: total impulse 0.625-1.25 N*s. + assert 0.5 < m.total_impulse < 1.5 + assert m.delay_seconds == 2.0 + # 13mm casing (BT-5). + assert abs(m.diameter_m - 0.013) < 1e-3 + def test_get_motor_returns_copy(self) -> None: a = get_motor("C6-5") b = get_motor("C6-5") diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..f7ce447 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,162 @@ +"""Tests for the pre-launch design validator.""" + +from __future__ import annotations + +from dataclasses import replace + +from rocket_sim.config import SimulationConfig +from rocket_sim.models import Parachute, Rocket +from rocket_sim.motors import Motor, get_motor +from rocket_sim.physics import Physics +from rocket_sim.validation import ( + DesignWarning, + format_warnings, + validate_design, +) + + +def _codes(warnings: list[DesignWarning]) -> set[str]: + return {w.code for w in warnings} + + +class TestStaticChecks: + def test_motor_too_big_for_airframe(self) -> None: + # 24 mm motor in 18 mm tube. + d12 = get_motor("D12-5") # 24 mm motor + rocket = Rocket( + name="TooSmallTube", + dry_mass_kg=0.05, + motor=d12, + diameter_m=0.018, # 18 mm — too small + drag_coefficient=0.75, + recovery=Parachute(diameter_m=0.3), + ) + codes = _codes(validate_design(rocket)) + assert "motor_too_big" in codes + + def test_will_not_lift_with_overweight_rocket(self) -> None: + # Massive rocket, tiny A motor → T/W < 1. + a8 = get_motor("A8-3") + rocket = Rocket( + name="TooHeavy", + dry_mass_kg=10.0, # 10 kg airframe with a tiny A motor + motor=a8, + diameter_m=0.025, + drag_coefficient=0.75, + recovery=Parachute(diameter_m=0.3), + ) + codes = _codes(validate_design(rocket)) + assert "will_not_lift" in codes + + def test_low_twr_warning(self) -> None: + # Heavy rocket where peak TWR is in (1, MIN_PEAK_TWR=5). + a8 = get_motor("A8-3") # peak ~13 N + # weight at 5x = 13/5 = 2.6 N → mass 0.265 kg gives TWR ≈ 5. + # Use a heavier mass to trigger the warning. + rocket = Rocket( + name="Marginal", + dry_mass_kg=0.5, # 500 g — very heavy for an A motor + motor=a8, + diameter_m=0.025, + drag_coefficient=0.75, + recovery=Parachute(diameter_m=0.3), + ) + warnings = validate_design(rocket) + codes = _codes(warnings) + # Either won't-lift or low-twr will fire depending on exact values. + assert "low_twr" in codes or "will_not_lift" in codes + + def test_clean_alpha_iii_no_critical_warnings(self, alpha_iii: Rocket) -> None: + warnings = validate_design(alpha_iii) + codes = _codes(warnings) + # The default kit + motor should not produce errors. + assert "motor_too_big" not in codes + assert "will_not_lift" not in codes + assert "lawn_dart" not in codes + + +class TestDynamicChecks: + def test_lawn_dart_flagged(self) -> None: + # 1-second motor with a 30-second delay grain on a small rocket. + long_delay = Motor( + designation="LONG-30", + name="LongDelay", + diameter_m=0.018, + length_m=0.07, + propellant_mass_kg=0.005, + total_mass_kg=0.010, + thrust_curve=((0, 0), (0.5, 8), (1, 8), (1, 0)), + delay_seconds=30.0, + ) + rocket = Rocket( + name="LawnDartCandidate", + dry_mass_kg=0.010, + motor=long_delay, + diameter_m=0.018, + drag_coefficient=0.75, + recovery=Parachute(diameter_m=0.3), + ) + codes = _codes(validate_design(rocket, SimulationConfig(dt=0.05, max_time=120))) + assert "lawn_dart" in codes + + def test_low_apogee_flagged_for_underpowered(self) -> None: + # Tiny motor on a relatively heavy airframe → marginal apogee. + rocket = Rocket( + name="Underpowered", + dry_mass_kg=0.060, # 60 g, relatively heavy for a 1/2A motor + motor=get_motor("1/2A6-2"), + diameter_m=0.018, + drag_coefficient=0.9, # high Cd + recovery=Parachute(diameter_m=0.3), + ) + codes = _codes(validate_design(rocket)) + # Either underpowered (low apogee) or low TWR will fire here. + assert codes & {"low_apogee", "low_twr"} + + def test_ballistic_descent_warned(self, alpha_iii: Rocket) -> None: + ballistic = replace(alpha_iii, recovery=None) + codes = _codes(validate_design(ballistic)) + assert "ballistic_descent" in codes + + def test_no_atmosphere_doesnt_break_validation(self, alpha_iii: Rocket) -> None: + moon_rocket = replace(alpha_iii, body=Physics.MOON) + # The validator must run without crashing on a vacuum body, even + # though the simulation behaves very differently there. + warnings = validate_design(moon_rocket, SimulationConfig(dt=0.05, max_time=1200)) + # Whatever fires, it should not include 'motor_too_big' or + # 'will_not_lift' (Alpha III + C6-5 fits and lifts off on the Moon). + codes = _codes(warnings) + assert "motor_too_big" not in codes + assert "will_not_lift" not in codes + + def test_transonic_warning_for_powerful_motor(self) -> None: + # Very light rocket on the Moon (no drag) with the F15 motor: + # easily breaks Mach 1. + rocket = Rocket( + name="MoonScreamer", + dry_mass_kg=0.030, + motor=get_motor("F15-6"), + diameter_m=0.029, + drag_coefficient=0.5, + recovery=Parachute(diameter_m=0.3), + body=Physics.MOON, + ) + codes = _codes(validate_design(rocket, SimulationConfig(dt=0.02, max_time=600))) + assert "transonic" in codes + + +class TestFormatWarnings: + def test_empty_list_message(self) -> None: + assert format_warnings([]) == "No design warnings." + + def test_renders_severities(self) -> None: + ws = [ + DesignWarning(severity="error", code="x", message="m1"), + DesignWarning(severity="warning", code="y", message="m2"), + DesignWarning(severity="info", code="z", message="m3"), + ] + text = format_warnings(ws) + assert "ERROR" in text + assert "WARNING" in text + assert "INFO" in text + assert "m1" in text and "m2" in text and "m3" in text