diff --git a/.coderabbit b/.coderabbit new file mode 100644 index 000000000..717968302 --- /dev/null +++ b/.coderabbit @@ -0,0 +1,43 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +language: "en-US" + +reviews: + profile: "assertive" + poem: false + in_progress_fortune: false + + auto_review: + enabled: true + drafts: false + ignore_title_keywords: + - "wip" + - "do not review" + + path_instructions: + - path: "**/*.py" + instructions: | + This is RocketPy, a scientific Python rocket trajectory simulator. + - Docstrings must use NumPy format with parameter types (e.g. `x : float`) but no units in the type field + - Do not add type hints to function signatures — the codebase does not use them + - No magic numbers without a comment explaining what they represent + - Flag any new public function or class missing a NumPy docstring + - Flag any logic that duplicates existing RocketPy utilities + + - path: "tests/**" + instructions: | + Tests use pytest with pytest.approx for float comparisons, not plain == or np.isclose. + Flag any float assertion that does not use pytest.approx. + Test names should describe the scenario being tested, not just the function name. + + finishing_touches: + docstrings: + enabled: true + unit_tests: + enabled: true + +knowledge_base: + web_search: + enabled: true + learnings: + scope: auto \ No newline at end of file diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 000000000..061f1a812 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,46 @@ +# .github/workflows/sync-upstream.yml +# Syncs your fork's default branch with the upstream repo daily. +# Also runs on manual trigger via workflow_dispatch. +# +# Setup: +# 1. Copy this file to .github/workflows/sync-upstream.yml in your fork +# 2. Set the UPSTREAM_REPO variable below to the upstream owner/repo + +name: Sync fork with upstream + +on: + schedule: + - cron: '0 12 * * *' # 5:00 AM PT daily + workflow_dispatch: # manual trigger from Actions tab + +env: + UPSTREAM_REPO: RocketPy-Team/RocketPy # change per fork + BRANCH: master # change if upstream uses 'main' + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout fork + uses: actions/checkout@v4 + with: + ref: ${{ env.BRANCH }} + fetch-depth: 0 + + - name: Add upstream remote + run: git remote add upstream https://github.com/${{ env.UPSTREAM_REPO }}.git + + - name: Fetch upstream + run: git fetch upstream ${{ env.BRANCH }} + + - name: Rebase on upstream + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git rebase upstream/${{ env.BRANCH }} + + - name: Push to fork + run: git push origin ${{ env.BRANCH }} --force-with-lease \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..e69de29bb diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..d70b2e15c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Install dependencies +make install + +# Run tests +make pytest # standard test suite +make pytest-slow # slow/marked tests with verbose output +make coverage # tests with coverage + +# Lint and format +make format # ruff format + isort +make lint # ruff + pylint +make ruff-lint # ruff only +make pylint # pylint only + +# Docs +make build-docs +``` + +**Run a single test file:** +```bash +pytest tests/unit/test_environment.py -v +``` + +**Run a single test by name:** +```bash +pytest tests/unit/test_environment.py::test_methodname_expectedbehaviour -v +``` + +## Architecture + +RocketPy simulates 6-DOF rocket trajectories. The core workflow is linear: + +``` +Environment → Motor → Rocket → Flight +``` + +**`rocketpy/environment/`** — Atmospheric models, weather data fetching (NOAA, Wyoming soundings, GFS forecasts). The `Environment` class (~116KB) is the entry point for atmospheric conditions. + +**`rocketpy/motors/`** — `Motor` is the base class. `SolidMotor`, `HybridMotor`, and `LiquidMotor` extend it. `Tank`, `TankGeometry`, and `Fluid` support liquid/hybrid propellant modeling. + +**`rocketpy/rocket/`** — `Rocket` aggregates a motor and aerodynamic surfaces. `aero_surface/` contains fins, nose cone, and tail implementations. `Parachute` uses trigger functions for deployment. + +**`rocketpy/simulation/`** — `Flight` (~162KB) is the simulation engine, integrating equations of motion with scipy's LSODA solver. `MonteCarlo` orchestrates many `Flight` runs for dispersion analysis. + +**`rocketpy/stochastic/`** — Wraps any component (Environment, Rocket, Motor, Flight) with uncertainty distributions for Monte Carlo input generation. + +**`rocketpy/mathutils/`** — `Function` class wraps callable data (arrays, lambdas, files) with interpolation and mathematical operations. Heavily used throughout for aerodynamic curves, thrust profiles, etc. + +**`rocketpy/plots/` and `rocketpy/prints/`** — Visualization and text output, each mirroring the module structure of the core classes. + +**`rocketpy/sensors/`** — Simulated sensors (accelerometer, gyroscope, barometer, GNSS) that can be attached to a `Rocket`. + +**`rocketpy/sensitivity/`** — Global sensitivity analysis via `SensitivityModel`. + +## Coding Standards + +- **Docstrings:** NumPy style with `Parameters`, `Returns`, and `Examples` sections. Always include units for physical quantities (e.g., "in meters", "in radians"). +- **No type hints in function signatures** — put types in the docstring `Parameters` section instead. +- **SI units by default** throughout the codebase (meters, kilograms, seconds, radians). +- **No magic numbers** — name constants with `UPPER_SNAKE_CASE` and comment their physical meaning. +- **Performance:** Use vectorized numpy operations. Cache expensive computations with `cached_property`. +- **Test names:** `test_methodname_expectedbehaviour` pattern. Use `pytest.approx` for float comparisons. +- **Tests follow AAA** (Arrange, Act, Assert) with fixtures from `tests/fixtures/`. +- **Backward compatibility:** Use deprecation warnings before removing public API features; document changes in CHANGELOG. + +# RocketPy fork instructions for Claude Code + +## What this repo is +RocketPy is a 6-DOF rocket trajectory simulator in pure Python. The main source +code lives in `rocketpy/`. Tests live in `tests/`. The default branch is `master`. + +## My workflow +- I am a solo contributor. Jules scouts issues and creates a branch. I hand off + to you (Claude Code) to build the implementation. +- Branches follow the pattern `scout/YYYY-MM-DD-brief-description`. +- Read `scout_report.md` in the repo root at the start of every session — it + contains the selected issue and any context Jules gathered. + +## Scope rules — read these carefully +- Only touch files directly relevant to the issue. Do not refactor unrelated code. +- Do not modify existing function signatures unless the issue explicitly requires it. +- Do not add type hints — the codebase does not use them. +- Do not rename variables or reformat code outside the files you are changing. +- If you think something adjacent should be fixed, mention it in a comment to me + instead of changing it. + +## Code conventions +- Docstrings: NumPy format only. Every new public function and class needs one. + Parameter types go in the docstring (e.g. `x : float`), not as type hints. + Do not document units in the type field — just the type name. +- No magic numbers. Any numeric constant needs a comment explaining what it is. +- Follow the style of the surrounding code exactly — indentation, spacing, naming. + +## Before you consider something done +- All new public functions have NumPy docstrings +- Tests written and passing +- No files modified outside the scope of the issue +- No type hints added anywhere +- Diff is clean — no stray whitespace changes, no reformatting of untouched lines + +## What to ask me before doing +- Any change to an existing public API +- Adding a new dependency +- Creating a new file that isn't a test or a direct implementation of the issue +- Anything that feels like it goes beyond the issue scopeß \ No newline at end of file diff --git a/jules_handoff.sh b/jules_handoff.sh new file mode 100644 index 000000000..1b2ac5463 --- /dev/null +++ b/jules_handoff.sh @@ -0,0 +1,234 @@ +#!/bin/bash +# ───────────────────────────────────────────────────────── +# jules_handoff.sh +# Checks out a Jules scout branch and launches Claude Code with context +# +# Usage: +# ./jules_handoff.sh # interactive branch picker +# ./jules_handoff.sh # direct branch name +# ───────────────────────────────────────────────────────── + +# ── Config — only thing to change when reusing across repos +DEFAULT_BRANCH="master" +UPSTREAM_FALLBACK="https://github.com/RocketPy-Team/RocketPy.git" +# ───────────────────────────────────────────────────────── + +set -e + +REPO_DIR=$(git rev-parse --show-toplevel 2>/dev/null) +if [ -z "$REPO_DIR" ]; then + echo "❌ Not inside a git repo." + exit 1 +fi + +cd "$REPO_DIR" + +# ── Detect upstream repo slug ───────────────────────────── + +UPSTREAM_URL=$(git remote get-url upstream 2>/dev/null || echo "") +if [ -z "$UPSTREAM_URL" ]; then + echo "❌ No upstream remote found. Run: git remote add upstream $UPSTREAM_FALLBACK" + exit 1 +fi + +UPSTREAM_REPO=$(echo "$UPSTREAM_URL" \ + | sed 's|https://github.com/||' \ + | sed 's|git@github.com:||' \ + | sed 's|[.]git$||') + +echo "📡 Upstream: $UPSTREAM_REPO" + +# ── Step 1: Determine the branch ───────────────────────── + +if [ -n "$1" ]; then + BRANCH="$1" +else + echo "" + echo "🔍 Fetching recent branches from origin..." + git fetch origin --quiet + + echo "" + echo "Recent branches (newest first):" + echo "──────────────────────────────" + + BRANCHES=$(git branch -r --sort=-committerdate \ + | grep 'origin/' \ + | grep -v "origin/$DEFAULT_BRANCH" \ + | sed 's|origin/||' \ + | head -20) + + if [ -z "$BRANCHES" ]; then + echo "No branches found other than $DEFAULT_BRANCH." + echo "Jules may not have created a branch yet." + exit 1 + fi + + i=1 + while IFS= read -r branch; do + echo " $i) $branch" + i=$((i+1)) + done <<< "$BRANCHES" + + echo "" + printf "Enter number of the Jules branch to work on: " + read -r SELECTION + + BRANCH=$(echo "$BRANCHES" | sed -n "${SELECTION}p" | xargs) + + if [ -z "$BRANCH" ]; then + echo "❌ Invalid selection" + exit 1 + fi +fi + +# ── Step 2: Check out the branch ───────────────────────── + +echo "" +echo "📥 Checking out branch: $BRANCH" + +git fetch origin "$BRANCH" --quiet + +if git show-ref --verify --quiet "refs/heads/$BRANCH"; then + git checkout "$BRANCH" + git pull origin "$BRANCH" --quiet +else + git checkout -b "$BRANCH" "origin/$BRANCH" +fi + +echo "✅ On branch: $BRANCH" + +# ── Step 3: Read scout_report.md ───────────────────────── + +echo "" +echo "📋 Reading scout report..." + +SCOUT_REPORT="" +SELECTED="" + +if [ -f "scout_report.md" ]; then + SELECTED=$(grep "SELECTED ISSUE:" scout_report.md | grep -oE '[0-9]+' | head -1) + + if [ -z "$SELECTED" ]; then + echo "" + echo "👉 Open scout_report.md and fill in the SELECTED ISSUE number, then re-run." + exit 0 + fi + + echo "✅ Selected issue: #$SELECTED" + + SCOUT_REPORT=$(python3 << PYEOF +import sys, re + +selected = "$SELECTED" +with open("scout_report.md") as f: + text = f.read() + +pattern = r"(## Issue " + selected + r" \u2014.*?)(?=## Issue \d|\Z)" +match = re.search(pattern, text, re.DOTALL) +if match: + print(match.group(1).strip()) +else: + print(text) +PYEOF +) + echo "✅ Extracted issue $SELECTED from scout_report.md" +else + echo "⚠️ No scout_report.md found on this branch" +fi + +# ── Step 4: Check for competing PRs ────────────────────── + +ISSUE_NUMBER="$SELECTED" +ISSUE_STATUS="" +ISSUE_TITLE="" +ISSUE_BODY="" + +if command -v gh &> /dev/null && [ -n "$ISSUE_NUMBER" ]; then + echo "🔍 Checking upstream issue #$ISSUE_NUMBER..." + ISSUE_JSON=$(gh issue view "$ISSUE_NUMBER" --repo "$UPSTREAM_REPO" --json state,title,body 2>/dev/null || echo "") + + if [ -n "$ISSUE_JSON" ]; then + ISSUE_STATUS=$(echo "$ISSUE_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('state','unknown'))") + ISSUE_TITLE=$(echo "$ISSUE_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('title',''))") + ISSUE_BODY=$(echo "$ISSUE_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('body','')[:800])") + echo "📌 Issue #$ISSUE_NUMBER: $ISSUE_STATUS — $ISSUE_TITLE" + + if [ "$ISSUE_STATUS" = "closed" ]; then + echo "" + echo "⛔ Issue #$ISSUE_NUMBER is already closed. Pick a different issue." + exit 0 + fi + fi + + echo "🔍 Checking for competing PRs..." + COMPETING=$(gh pr list \ + --repo "$UPSTREAM_REPO" \ + --search "fixes #$ISSUE_NUMBER" \ + --json number,title,isDraft,url \ + 2>/dev/null || echo "[]") + + PR_COUNT=$(echo "$COMPETING" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))") + + if [ "$PR_COUNT" -gt "0" ]; then + echo "" + echo "⛔ STOP — There is already an open PR for issue #$ISSUE_NUMBER:" + echo "$COMPETING" | python3 -c " +import sys, json +for pr in json.load(sys.stdin): + draft = ' [DRAFT]' if pr.get('isDraft') else '' + print(' #' + str(pr['number']) + draft + ': ' + pr['title']) + print(' ' + pr['url']) +" + echo "" + echo " Pick a different issue." + exit 0 + else + echo "✅ No competing PRs — you're clear to proceed." + fi +fi + +# ── Step 5: Build context file and launch Claude Code ──── + +CONTEXT_FILE="/tmp/jules-context-$(date +%s).md" + +cat > "$CONTEXT_FILE" << CONTEXT +# Jules Handoff — $UPSTREAM_REPO + +## Branch +$BRANCH + +## Selected Issue +#${ISSUE_NUMBER:-"unknown"} — ${ISSUE_TITLE:-"unknown"} +Status: ${ISSUE_STATUS:-"unknown"} + +--- + +## Scout Report +${SCOUT_REPORT:-"(no scout_report.md found)"} + +--- + +## Issue Body +${ISSUE_BODY:-"(could not fetch)"} + +--- + +## Instructions for Claude Code + +Read CLAUDE.md before doing anything else — it contains scope rules and +conventions you must follow. + +1. Read the scout report and issue above carefully. +2. Run existing tests for the relevant module first to establish a baseline: + \`python -m pytest tests/ -x -q -k \` +3. Implement the fix. Stay strictly within the scope of the issue. +4. Write tests using pytest.approx for any float assertions. +5. Ensure all new public functions have NumPy docstrings. +6. When done, give me a 3-sentence summary of what changed for the PR description. +CONTEXT + +echo "" +echo "🚀 Launching Claude Code..." +echo "" + +claude < "$CONTEXT_FILE" \ No newline at end of file diff --git a/prep_pr.sh b/prep_pr.sh new file mode 100644 index 000000000..bac87e26b --- /dev/null +++ b/prep_pr.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# prep_pr.sh — strips toolkit files from the current branch before opening a PR +# Usage: ./prep_pr.sh +# Run after cleaning up commit history. Modifies the current branch in place. + +set -euo pipefail + +TOOLKIT_FILES=( + "AGENTS.md" + "CLAUDE.md" + "jules_handoff.sh" + ".coderabbit.yaml" + "scout_report.md" + ".github/workflows/sync-upstream.yml" + ".github/workflows/scout.yml" +) + +RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; NC='\033[0m' +die() { echo -e "${RED}error: $1${NC}" >&2; exit 1; } +warn() { echo -e "${YELLOW}warn: $1${NC}"; } +ok() { echo -e "${GREEN}ok: $1${NC}"; } + +command -v git &>/dev/null || die "git not found" +git rev-parse --git-dir &>/dev/null || die "not inside a git repo" + +BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null) \ + || die "HEAD is detached — check out your working branch first" + +[[ "$BRANCH" == "master" || "$BRANCH" == "main" ]] \ + && die "you're on $BRANCH — check out your working branch first" + +git diff --quiet && git diff --cached --quiet \ + || die "you have uncommitted changes — commit or stash them first" + +echo "" +echo " Branch : $BRANCH" +echo " Will strip toolkit files and add a cleanup commit." +echo "" +read -rp "Proceed? [y/N] " confirm +[[ "${confirm,,}" == "y" ]] || { echo "Aborted."; exit 0; } + +STRIPPED=() +for f in "${TOOLKIT_FILES[@]}"; do + if git ls-files --error-unmatch "$f" &>/dev/null 2>&1; then + git rm -f "$f" + STRIPPED+=("$f") + fi +done + +if [[ ${#STRIPPED[@]} -eq 0 ]]; then + warn "no toolkit files found — nothing to strip" + exit 0 +fi + +git commit -m "chore: strip toolkit files before PR + +Local workflow helpers not intended for upstream: +$(printf ' - %s\n' "${STRIPPED[@]}")" + +ok "stripped: ${STRIPPED[*]}" +echo "" +echo " Next:" +echo " git push origin $BRANCH" +echo " open PR from $BRANCH → upstream master" \ No newline at end of file diff --git a/scout_report.md b/scout_report.md new file mode 100644 index 000000000..be7594174 --- /dev/null +++ b/scout_report.md @@ -0,0 +1,93 @@ +--- +# Scout Report — 2026-03-17 + +## ✅ SELECTED ISSUE: 1 +*(Fill in 1, 2, or 3 after reviewing the briefs below)* + +--- + +## Issue 1 — BUG: Wind Heading Profile Plots are not that good (https://github.com/RocketPy-Team/RocketPy/issues/253) + +### What's broken +When plotting the wind heading profile, the angle values can wrap around from 359° to 0° (or vice-versa). This causes matplotlib to draw a straight line straight across the plot, creating visual artifacts that look like massive sudden shifts in wind heading, confusing users. The line should either break or visually wrap smoothly. + +### Files to touch +`rocketpy/environment/environment.py` or `rocketpy/plots/environment_analysis_plots.py` (depending on where the plot function is defined, likely where `wind_heading` is being processed for matplotlib plots). +`tests/test_environment.py` (or test_environment_plots.py) + +### Implementation approach +1. Locate the function responsible for plotting the wind heading (often associated with `Environment.wind_heading` plotting or `EnvironmentAnalysis.wind_heading_profile_grid`). +2. Before passing the wind heading arrays to matplotlib, apply `numpy.unwrap` to the data. Note: `numpy.unwrap` expects angles in radians, so convert to radians using `np.deg2rad(angles)`, unwrap, and then convert back with `np.rad2deg(unwrapped_angles)`. Alternatively, use `numpy.unwrap(angles, period=360)`. +3. If unwrapping is difficult because of `Function` class constraints, an alternative is to insert `np.nan` values where the absolute difference between consecutive angles is greater than 180°, which tells matplotlib to break the line instead of connecting the dots across the graph. +4. Add a test case that creates an environment with a wind heading that crosses the 360-degree boundary and assert that the plotting function executes without raising an exception and the output data array (if accessible) contains unwrapped values or NaNs at the boundary. + +### Acceptance criteria +* The plotted line for wind heading no longer crosses the entire plot area horizontally when the wind shifts from ~359° to ~0° or vice-versa. +* Test cases cover a scenario with boundary-crossing wind heading values. +* Ensure `Environment` and `EnvironmentAnalysis` wind heading plots are both addressed. + +### Guardrails +Do not change the underlying physics computations or the mathematical definition of wind heading used in the simulation. This should strictly be a visualization/plotting fix. Do not introduce heavy dependencies outside of the existing scientific Python stack (numpy, matplotlib). + +### Difficulty +1 — Extremely straightforward, suitable for a beginner who understands matplotlib and numpy array manipulation. + +--- + +## Issue 2 — ENH: Implement Parachute Opening Shock Force Estimation (https://github.com/RocketPy-Team/RocketPy/issues/161) + +### What's broken +Currently, RocketPy cannot estimate the peak opening shock force (inflation load) of a parachute. Users cannot properly size their recovery hardware without this peak transient force, which often greatly exceeds the steady-state drag force. This limits the realism and engineering utility of the recovery system simulation. + +### Files to touch +`rocketpy/rocket/parachute.py` +`tests/test_parachute.py` + +### Implementation approach +1. Modify `Parachute.__init__` to accept a new optional parameter `opening_shock_coefficient` (default to a standard value like 1.5 or 1.6). Store it as `self.opening_shock_coefficient`. +2. Create a new method `calculate_opening_shock(self, density, velocity)` in the `Parachute` class. +3. In this method, implement the Knacke formula: `F_o = opening_shock_coefficient * (0.5 * density * velocity**2) * self.cd_s`. +4. Optionally, you can add a post-processing calculation in the `Flight` class to print or store this peak force when a parachute event triggers (capturing the velocity and density at that timestamp), though just adding the method to `Parachute` is a good first step. +5. Write a test in `test_parachute.py` that verifies `calculate_opening_shock` returns the expected value given specific density, velocity, cd_s, and opening shock coefficient inputs. + +### Acceptance criteria +* The `Parachute` class accepts an `opening_shock_coefficient` upon initialization. +* The `calculate_opening_shock` method returns the correct peak force value based on the formula provided in the issue description. +* Appropriate unit tests are added verifying the output. + +### Guardrails +Do not change the actual 6-DOF equation of motion integration logic to include this transient force as a dynamic load yet; the issue specifically asks for this as an *estimation* (post-processing/informational method) rather than integrating the shock load directly into the flight trajectory calculations. + +### Difficulty +2 — Requires understanding object-oriented Python, adding basic class methods, and translating a physics formula into code. + +--- + +## Issue 3 — ENH: Custom Exception errors and messages (https://github.com/RocketPy-Team/RocketPy/issues/285) + +### What's broken +When users make common mistakes (like creating a rocket with a negative static margin or passing incorrect types), RocketPy currently relies on generic Python exceptions (like `ValueError` or `TypeError`) or silently allows physics-breaking setups. This makes it hard for new users to debug their simulations. + +### Files to touch +`rocketpy/exceptions.py` (Create this file if it doesn't exist) +`rocketpy/rocket/rocket.py` +`tests/test_rocket.py` (and potentially other test files) + +### Implementation approach +1. Create a new module (e.g., `exceptions.py`) to hold custom exception classes inheriting from Python's built-in `Exception` or `ValueError`. For example, create `NegativeStaticMarginError`. +2. Locate the static margin calculation in `Rocket` (often accessed via `Rocket.static_margin`). +3. Add a check during rocket assembly or when static margin is accessed: if the static margin evaluates to a negative number, raise the new `NegativeStaticMarginError` with a helpful, descriptive message explaining why this is physically problematic and what the user should check (e.g., center of mass vs. center of pressure). +4. Add tests to ensure that these custom exceptions are raised correctly when a user provides bad inputs. + +### Acceptance criteria +* A new custom exception class is created for at least one common user error (e.g., negative static margin). +* The code raises this specific custom exception instead of a generic error or failing silently. +* Tests use `pytest.raises` to ensure the correct custom exception is triggered with invalid configurations. + +### Guardrails +Do not change the physics calculations for the static margin or other components. Do not break existing public APIs. Be careful to ensure exceptions are raised at the right time (e.g., when the user actually tries to assemble the rocket or run the flight, as static margin might be temporarily negative while components are being added). + +### Difficulty +2 — Involves standard Python exception handling and understanding where validation checks should be inserted without breaking the object initialization flow. + +---