From d71871a5e4f2cccc69b021de727cb181dcd7d088 Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:15:43 +0200 Subject: [PATCH 01/16] Add multicopter support plan Comprehensive proposal for simulating small electric multirotors: hover-yaw at zero speed, track/heading decoupling, and a battery/power model driven by vendored PyThrust data (no runtime dependency). One behaviour-preserving core refactor (extract Kinematics from Traffic as a replaceable entity); everything else lands as a plugin. Co-Authored-By: Claude Fable 5 --- docs/multicopter-plan.md | 263 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 docs/multicopter-plan.md diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md new file mode 100644 index 0000000..3209d0d --- /dev/null +++ b/docs/multicopter-plan.md @@ -0,0 +1,263 @@ +# Multicopter support plan + +Status: **proposal** — nothing in this document is implemented yet. + +## Goal + +Make MiniSky able to simulate small electric multirotors ("multicopters": DJI MAVIC/M600/PHAN4-class, +Amazon/Matternet-style delivery drones) with realistic behaviour: + +1. **Hover and yaw** — change heading at zero ground/airspeed, limited by a yaw rate instead of a + bank-angle turn rate. +2. **Decoupled track and heading** — change direction of travel without rotating the body. A + multicopter redirects thrust; its velocity vector (track) is independent of where the nose + points (heading). Course changes at waypoints are immediate, with no turn radius. +3. **Electric performance** — battery state of charge, power draw as a function of speed and + required thrust, and a flight envelope that degrades as the battery sags. + +Everything lands as a **plugin** plus one small, behaviour-preserving core refactor. This follows +the project direction: keep the core minimal, make behaviour hackable from outside. + +### Explicit non-goals + +- **Helicopters.** OpenAP's rotor list includes the EC35 (a crewed helicopter). It is deliberately + *not* covered: it keeps today's envelope-only performance and bank-to-turn kinematics. This is + why the feature is named *multicopter*, not *drone* (wrong axis: describes crew, not lift type) + and not *rotorcraft* (would promise helicopter support). +- **Aeroelastic / attitude-level dynamics.** We stay at the kinematic point-mass level of the rest + of the simulator; "heading" is the only attitude state. +- **A runtime PyThrust dependency.** We use PyThrust's *data* (see Phase 3), never its code at + runtime, and it is not added to `pyproject.toml`. + +## What the codebase already provides + +The exploration that produced this plan found MiniSky closer to multicopter-ready than expected: + +- **Rotor performance path exists.** `minisky/traffic/performance/perfoap.py` distinguishes + `LIFT_FIXWING` from `LIFT_ROTOR`. Creating an aircraft with a rotor typecode (`CRE D1 MAVIC ...`) + gets envelope-only performance: no drag polar, fixed `axmax = 3.5 m/s²`, static limits. + Shipped rotor typecodes: `EC35, M600, AMZN, MNET, PHAN4, M100, M200, MAVIC, HORSEFLY`. +- **Zero speed already passes the performance clamp.** Rotor envelopes have *negative* `vmin` + (e.g. M600: −18 m/s), and `OpenAP.limits()` clamps rotor TAS directly against `[vmin, vmax]`, + so `SPD D1 0` survives. Fixed-wing aircraft are clamped to stall speed and cannot do this. +- **The replaceable pattern.** Every first-level `TrafficArrays` subclass auto-registers for + `SELECTIMPL` (`minisky/core/trafficarrays.py`), and `_replace_instance_on_traf()` hot-swaps the + instance on `traf`, carrying per-aircraft arrays over and rebinding stack commands. `Autopilot`, + `OpenAP`, `APorASAS`, `ConflictDetection`, `ConflictResolution` are all swappable today. + `example_plugins/customautopilot.py` demonstrates the pattern. +- **Plugin machinery.** Timed `preupdate`/`update`/`reset` hooks, `plugin.Entity` + + `settrafarrays()` for per-aircraft state that grows/shrinks with the fleet, and + `@stack.command` for new commands. + +## What blocks the two manoeuvring behaviours + +Both live in `Traffic` (`minisky/traffic/traffic.py`), in ~100 lines of kinematics: + +1. **`update_airspeed()`** derives turn rate from the bank-angle triangle, + `ω = g·tan(φ)/max(tas, eps)` with `eps = 0.01`. At TAS → 0 this *explodes* (≈26 000 °/s), so + heading snaps instantly — hover-yaw "works" by numerical accident, with no physical yaw-rate + limit. +2. **`update_groundspeed()`** hard-couples `trk = hdg` and points the velocity vector along the + heading. The aircraft must fly where its nose points. Upstream, `APorASAS.update()` + (`minisky/traffic/aporasas.py`) converts the desired *track* into a desired *heading* (with wind + correction), baking the same coupling into the command path. + +Everything downstream is already agnostic: conflict detection/resolution, the stream snapshot and +LNAV all work off `trk`/`gs`, which remain well-defined when decoupled from `hdg`. + +`Traffic` is technically registered as a replaceable (`SELECTIMPL TRAFFIC ...` lists it), but the +hot-swap helper only swaps instances found *on* `traf` — it cannot replace the root object itself, +and in CLI runs plugins load after `minisky.init()` has constructed `traf`. Hence Phase 1. + +--- + +## Phase 1 — core refactor: extract `Kinematics` as a replaceable entity + +**The only core change in this plan.** Behaviour-preserving. + +Move `update_airspeed()`, `update_groundspeed()`, `update_pos()` and the state they own +(`ax`, `az`, `swhdgsel`, `swaltsel`) out of `Traffic` into a new first-level class: + +```python +# minisky/traffic/kinematics.py +class Kinematics(TrafficArrays): + """Integrates airspeed, heading, vertical speed and position each step. + + Replaceable via SELECTIMPL KINEMATICS ; plugins may subclass to + change how (a subset of) aircraft fly. + """ + def update(self): + self.update_airspeed() + self.update_groundspeed() + self.update_pos() +``` + +- Instantiated as `self.kinematics = Kinematics()` inside `Traffic.__init__`'s + `settrafarrays()` block; `Traffic.update()` calls `self.kinematics.update()` in place of the + three method calls. +- Because it is a first-level `TrafficArrays` subclass it **auto-registers** as replaceable — + `SELECTIMPL KINEMATICS MULTICOPTERKINEMATICS` then hot-swaps mid-simulation exactly like the + custom-autopilot example, with no further core support needed. +- Keep thin delegating properties on `Traffic` only if anything external reads `traf.ax` etc. + (grep first; `streaming.py` and `perfoap.py` read `traf.ax` — either keep `ax` on `Traffic` and + have `Kinematics` write it, or add a property. Decide during implementation; prefer keeping the + arrays registered on `Kinematics` and exposing properties on `Traffic`.) + +**Acceptance:** entire existing test suite passes unchanged; `SELECTIMPL KINEMATICS` lists the +base implementation; a trivial subclass registered from a test can be selected and reverts on +reset (mirror the existing `tests/integration/test_plugin.py` replaceable test). + +## Phase 2 — the `multicopter` plugin: membership + kinematics + +New file `plugins/multicopter.py` (plugin name `MULTICOPTER`), no core changes. + +### Membership + +Selection must **not** be `traf.perf.lifttype == LIFT_ROTOR` — that would sweep in the EC35. + +- Module constant `MULTICOPTER_TYPES = {"MAVIC", "PHAN4", "M100", "M200", "M600", "MNET", "AMZN", + "HORSEFLY"}` (the OpenAP rotor list minus helicopters). +- A `plugin.Entity` subclass holding per-aircraft arrays registered via `settrafarrays()`: + - `ismulticopter` (bool) — set in `create()` from the typecode, manual override via a + `MCOPT acid ON/OFF` stack command for custom typecodes; + - `selhdg` (deg) — commanded body heading, decoupled from track; + - `yawrate` (deg/s) — default ≈ 90 °/s, settable per aircraft (`YAWRATE acid 120`). + +### `MulticopterKinematics(Kinematics)` + +Selected with `SELECTIMPL KINEMATICS MULTICOPTERKINEMATICS` (the plugin issues this on load / +documents it). Calls `super().update()` for the whole fleet, then re-integrates the multicopter +rows (mask `m`): + +```python +dt = minisky.sim.simdt +# 1. Yaw at a fixed rate — valid at tas = 0 (hover-yaw) +delhdg = (mc.selhdg[m] - traf.hdg[m] + 180) % 360 - 180 +traf.hdg[m] += np.clip(delhdg, -mc.yawrate[m] * dt, mc.yawrate[m] * dt) +traf.hdg[m] %= 360 +# 2. Velocity vector follows the commanded *track* (LNAV/ASAS), not the heading +trkcmd = np.radians(traf.aporasas.trk[m]) +traf.gsnorth[m] = traf.tas[m] * np.cos(trkcmd) + traf.windnorth[m] * airborne +traf.gseast[m] = traf.tas[m] * np.sin(trkcmd) + traf.windeast[m] * airborne +traf.gs[m] = np.hypot(traf.gsnorth[m], traf.gseast[m]) +traf.trk[m] = np.degrees(np.arctan2(traf.gseast[m], traf.gsnorth[m])) % 360 +# 3. Re-integrate lat/lon for these rows (base class integrated with the wrong velocity) +``` + +Implementation notes: + +- The base class integrates position before the override, so either re-integrate lat/lon for the + masked rows from the stored previous position, or (cleaner) restructure `Kinematics.update()` + into `update_airspeed / update_groundspeed / update_pos` calls so the subclass overrides the + first two and lets `update_pos()` run once, after. Prefer the latter — it is exactly what the + Phase 1 split enables. +- Heading no longer follows track for these rows, so also subclass or bypass the + `APorASAS` trk→hdg coupling: `MulticopterAPorASAS(APorASAS)` that, after `super().update()`, + overwrites `self.hdg[m]` with `mc.selhdg[m]`. (`SELECTIMPL APORASAS MULTICOPTERAPORASAS`.) +- `HDG` (stack) semantics for multicopters: route the existing `HDG` command value into + `mc.selhdg` (nose) and add `YAW acid 45` as an explicit alias; the FMS/LNAV track command + continues to steer the velocity vector. Default behaviour when no `selhdg` was ever set: + follow the track (nose-along-course), so routes look natural without extra commands. +- Turn-anticipation in the FMS assumes a turn radius; multicopters fly point-to-point. Keep it + simple first: the immediate-course-capture behaviour falls out of step 2 automatically because + `aporasas.trk` snaps to the new leg bearing at waypoint switch. + +**Acceptance (integration tests, driven through the stack like `test_stack.py`):** + +- `CRE D1 MAVIC ... ; SPD D1 0` → ground speed reaches 0 and stays; aircraft holds position. +- At `gs == 0`, `HDG D1 90` → heading slews at `yawrate`, position unchanged. +- In cruise, `YAW D1 0` while flying track 090 → `trk` stays 090, `hdg` goes to 0. +- Waypoint passage: course changes leg-to-leg with no overshoot arc. +- A fixed-wing aircraft in the same simulation behaves byte-identically to `main` (regression + guard for the fleet-wide hooks). + +## Phase 3 — `MulticopterPerf`: electric performance from PyThrust *data* + +`class MulticopterPerf(OpenAP)`, selected with `SELECTIMPL OPENAP MULTICOPTERPERF`. Fixed-wing +rows keep `super()` behaviour untouched; multicopter rows get an electric model. This fills the +long-standing `# TODO: implement thrust computation for rotor aircraft` in `perfoap.py`. + +### Data pipeline (no new runtime dependency — decided) + +[PyThrust](https://github.com/Setuav/PyThrust) (Apache 2.0) ships everything needed as plain +data: + +- **Propeller tables** (`data/propellers/apc_202602/*.csv`, 441 APC props): full performance + grids with `rpm, speed_mps, thrust_n, power_w, torque_nm, ct, cp, ...` — thrust *and* shaft + power are already tabulated, so the inverse question a perf model asks + ("required thrust at this airspeed → power") is pure interpolation. No solver needed. +- **Motor specs** (`data/motors/*.json`): `kv`, `resistance`, `io`, `max_current` — shaft-to- + electrical conversion is a few lines of textbook motor algebra. +- **Battery curves** (`data/batteries/*.json`): open-circuit voltage and internal resistance vs + depth-of-discharge — `np.interp` territory. (Their example cell is synthetic; real types + should get measured curves eventually.) + +Pipeline, following the existing regen conventions (navdb parquet, `minisky commands docs`): + +1. `scripts/gen_multicopter_perf.py` — **self-contained** (numpy only, no pythrust import): + reads vendored prop CSV + motor JSON per multicopter typecode (config mapping typecode → + {prop, motor, cell, series/parallel, n_rotors, mass}), and emits one small artifact per type: + a grid `(airspeed, thrust) → (power_w, current_a, feasible)` (~30 KB float32 npz/parquet) + plus the battery curves. +2. Artifacts are **checked in** next to the plugin (`plugins/data/multicopter/`). The handful of + vendored source CSV/JSONs (~1 MB) live under `plugins/data/multicopter/pythrust/` together + with PyThrust's LICENSE and an attribution note (the prop tables are repackaged APC published + performance data). +3. Runtime: `MulticopterPerf` loads the artifacts at plugin load and evaluates with vectorised + `np.interp`/`RegularGridInterpolator`. Zero per-step Python loops, zero new dependencies. + +### Runtime model (multicopter rows) + +- **Required thrust:** per rotor, `T = m·√(g² + a²)/n_rotors` in hover/climb, plus a flat-plate + parasite term `½ρv²·CdS` in translation (edgewise-flow caveat below). +- **Power/current:** from the per-type map at `(tas, T)`; write `self.thrust` (total) and expose + `battery_power` as the electric analogue of `fuelflow`. +- **Battery:** per-aircraft `soc` array; integrate `soc -= I·dt / capacity`; terminal voltage + from OCV/R curves. +- **Envelope feedback:** where the map is infeasible at current battery voltage (sag at low SoC), + tighten `vmax`/`vsmax` in `limits()` — performance genuinely degrades as the battery empties. +- **Stack commands:** `BATT acid` (report SoC/power/endurance estimate), optional auto-RTH/land + threshold via the conditional-command machinery later. + +**Fidelity caveat (documented in the plugin):** APC coefficients are axial-flow; a translating +multicopter has edgewise inflow, so forward-flight power is approximate. Hover figures and the +qualitative trends (power vs speed, voltage sag) are sound — the right level for a traffic +simulator. + +**Acceptance:** hover endurance for a MAVIC-class config lands within sanity bounds (~20–35 min); +`BATT` reports monotonically decreasing SoC; envelope shrinks below a SoC threshold; unit tests +for the map interpolation against a few hand-computed points from the source CSV. + +## Phase 4 — docs, scenarios, cleanup + +- New guide `docs/guides/multicopters.md`: creating multicopters, hover/yaw commands, battery + model, how to add a new type (config + regen script). +- Update `docs/architecture.md` with the `Kinematics` entity and the replaceable list. +- Example scenario `scenarios/multicopter_delivery.scn`: create, fly a route, hover at a + delivery point, yaw for "camera", return; exercises everything above. +- Regenerate `docs/reference/commands.md` (`uv run minisky commands docs`) after adding the + stack commands (`MCOPT`, `YAW`, `YAWRATE`, `BATT`). +- `ruff`, `pyright`, full test suite green at every phase boundary. + +## Sequencing and effort + +| Phase | Scope | Risk | Depends on | +|---|---|---|---| +| 1 | Extract `Kinematics` (core, behaviour-preserving) | Low — mechanical move guarded by existing tests | — | +| 2 | Plugin: membership + kinematics + commands | Medium — command semantics for HDG/YAW need care | 1 | +| 3 | Perf: data vendoring, gen script, `MulticopterPerf`, battery | Medium — model calibration/sanity | 2 (usable after 1) | +| 4 | Docs, scenario, polish | Low | 2, 3 | + +Each phase is a separately reviewable PR; phase 1 is intentionally the only one touching +`minisky/`. + +## Decision log + +| Decision | Choice | Why | +|---|---|---| +| Name | **multicopter** (not drone/rotorcraft) | Names the lift/control type actually modelled; scope excludes helicopters (EC35) | +| Where behaviour lives | Plugin + replaceable subclasses | Matches "minimal core, hack from outside"; hot-swappable via `SELECTIMPL`; reverts on reset | +| Kinematics override mechanism | New first-level `Kinematics` entity (Phase 1) | `Traffic` itself can't be hot-swapped (root object); post-hoc plugin-hook correction would double-integrate state | +| Membership predicate | Plugin-owned typecode set + `ismulticopter` array | `LIFT_ROTOR` includes helicopters | +| PyThrust | Data only, vendored with attribution; self-contained gen script; nothing at runtime | Prop CSVs already tabulate thrust & power; keeps dependency tree untouched (Apache 2.0 permits) | +| Perf evaluation | Precomputed per-type maps, vectorised interp | Keeps the numpy discipline; fleet-size independent; regen convention already exists in repo | From 80d1b0e7073c9dd01f8391f8fb3de41f9544119b Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:18:00 +0200 Subject: [PATCH 02/16] Add thin MulticopterAutopilot to the multicopter plan Not a guidance rewrite - LNAV's track output already drives the decoupled kinematics, and fly-over waypoints exist. The subclass adds mission primitives (HOVER, DELIVER), a fixed capture radius replacing bank-based turn distance at low speed, and fly-over route defaults. Co-Authored-By: Claude Fable 5 --- docs/multicopter-plan.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md index 3209d0d..4e0d876 100644 --- a/docs/multicopter-plan.md +++ b/docs/multicopter-plan.md @@ -162,12 +162,41 @@ Implementation notes: simple first: the immediate-course-capture behaviour falls out of step 2 automatically because `aporasas.trk` snaps to the new leg bearing at waypoint switch. +### `MulticopterAutopilot(Autopilot)` — thin, mission-level + +A full autopilot rewrite is **not** needed: LNAV already outputs a *track* command +(`ap.trk = qdr2wp`), which is exactly what the decoupled kinematics consumes; fly-over waypoints +already exist (`ADDWPTMODE FLYOVER`); the vertical channel (`ALT`/`selvs`) is speed-independent, +so hover-climb/descend works with the plain `ALT` command; and turn-speed deceleration only +activates for `FLYTURN` waypoints, which multicopters won't use. + +A thin subclass (`SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT`) covers what the stock FMS cannot: + +- **Mission primitives** the FMS has no concept of: + - `HOVER acid [time]` — suspend LNAV, hold position (commanded gs = 0), auto-resume the route + after the optional duration. The conditional-command machinery (ATALT/ATDIST) cannot express + "hold for 90 s". + - `DELIVER acid alt [time]` — at the current position: vertical descent to `alt`, dwell, climb + back, continue the route. Implemented as a small per-aircraft state machine on top of + `super().update()`. +- **Low-speed guards**: `calcturn()` and the turn-distance/deceleration formulas are bank- and + speed-based; clamp `actwp.turndist` for multicopter rows to a fixed capture radius (~5–10 m) + so waypoint switching stays sane at creeping speeds and at hover on top of a waypoint. +- **Route defaults**: set fly-over + capture radius automatically for `ismulticopter` aircraft + when waypoints are added, so scenario authors need no extra commands. + +With this, the plugin issues three swaps on load — `KINEMATICS`, `APORASAS`, `AUTOPILOT` — each +subclass calling `super()` and adjusting only the masked multicopter rows. + **Acceptance (integration tests, driven through the stack like `test_stack.py`):** - `CRE D1 MAVIC ... ; SPD D1 0` → ground speed reaches 0 and stays; aircraft holds position. - At `gs == 0`, `HDG D1 90` → heading slews at `yawrate`, position unchanged. - In cruise, `YAW D1 0` while flying track 090 → `trk` stays 090, `hdg` goes to 0. - Waypoint passage: course changes leg-to-leg with no overshoot arc. +- `HOVER D1 90` mid-route → position frozen for 90 s of sim time, then the route resumes. +- `DELIVER D1 50 30` → vertical descent to 50 ft, 30 s dwell, climb back, route resumes; + lat/lon unchanged throughout. - A fixed-wing aircraft in the same simulation behaves byte-identically to `main` (regression guard for the fleet-wide hooks). @@ -236,7 +265,7 @@ for the map interpolation against a few hand-computed points from the source CSV - Example scenario `scenarios/multicopter_delivery.scn`: create, fly a route, hover at a delivery point, yaw for "camera", return; exercises everything above. - Regenerate `docs/reference/commands.md` (`uv run minisky commands docs`) after adding the - stack commands (`MCOPT`, `YAW`, `YAWRATE`, `BATT`). + stack commands (`MCOPT`, `YAW`, `YAWRATE`, `HOVER`, `DELIVER`, `BATT`). - `ruff`, `pyright`, full test suite green at every phase boundary. ## Sequencing and effort @@ -258,6 +287,7 @@ Each phase is a separately reviewable PR; phase 1 is intentionally the only one | Name | **multicopter** (not drone/rotorcraft) | Names the lift/control type actually modelled; scope excludes helicopters (EC35) | | Where behaviour lives | Plugin + replaceable subclasses | Matches "minimal core, hack from outside"; hot-swappable via `SELECTIMPL`; reverts on reset | | Kinematics override mechanism | New first-level `Kinematics` entity (Phase 1) | `Traffic` itself can't be hot-swapped (root object); post-hoc plugin-hook correction would double-integrate state | +| Custom autopilot | Thin `MulticopterAutopilot` for mission primitives (HOVER/DELIVER), capture-radius clamp and fly-over defaults only | LNAV's track output already suits decoupled kinematics; no guidance rewrite needed | | Membership predicate | Plugin-owned typecode set + `ismulticopter` array | `LIFT_ROTOR` includes helicopters | | PyThrust | Data only, vendored with attribution; self-contained gen script; nothing at runtime | Prop CSVs already tabulate thrust & power; keeps dependency tree untouched (Apache 2.0 permits) | | Perf evaluation | Precomputed per-type maps, vectorised interp | Keeps the numpy discipline; fleet-size independent; regen convention already exists in repo | From e928ac297337cea70a003e01c893b647c2e18410 Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:19:16 +0200 Subject: [PATCH 03/16] Add per-phase checklists to the multicopter plan Checkbox task lists per phase so progress can be marked off as implementation lands. Co-Authored-By: Claude Fable 5 --- docs/multicopter-plan.md | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md index 4e0d876..3a30426 100644 --- a/docs/multicopter-plan.md +++ b/docs/multicopter-plan.md @@ -107,6 +107,19 @@ class Kinematics(TrafficArrays): base implementation; a trivial subclass registered from a test can be selected and reverts on reset (mirror the existing `tests/integration/test_plugin.py` replaceable test). +### Phase 1 checklist + +- [ ] Create `minisky/traffic/kinematics.py`: `Kinematics(TrafficArrays)` with + `update_airspeed` / `update_groundspeed` / `update_pos` and the `ax`, `az`, `swhdgsel`, + `swaltsel` arrays moved over from `Traffic` +- [ ] Instantiate as `self.kinematics = Kinematics()` inside `Traffic.__init__`'s + `settrafarrays()` block; `Traffic.update()` calls `self.kinematics.update()` +- [ ] Grep external readers of the moved arrays (`streaming.py`, `perfoap.py`, tests) and expose + delegating properties on `Traffic` where needed +- [ ] Verify `SELECTIMPL KINEMATICS` lists the base implementation +- [ ] Test: register a trivial subclass, select it, verify it takes effect and reverts on reset +- [ ] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green + ## Phase 2 — the `multicopter` plugin: membership + kinematics New file `plugins/multicopter.py` (plugin name `MULTICOPTER`), no core changes. @@ -200,6 +213,22 @@ subclass calling `super()` and adjusting only the masked multicopter rows. - A fixed-wing aircraft in the same simulation behaves byte-identically to `main` (regression guard for the fleet-wide hooks). +### Phase 2 checklist + +- [ ] `plugins/multicopter.py` skeleton: `init_plugin()`, plugin name `MULTICOPTER` +- [ ] `MULTICOPTER_TYPES` set + `Entity` with `ismulticopter`, `selhdg`, `yawrate` arrays, + auto-set from typecode in `create()` +- [ ] Stack commands: `MCOPT`, `YAW`, `YAWRATE` +- [ ] `MulticopterKinematics(Kinematics)`: yaw-rate-limited heading, track-driven velocity + vector, single `update_pos()` pass +- [ ] `MulticopterAPorASAS(APorASAS)`: skip trk→hdg coupling for multicopter rows +- [ ] `MulticopterAutopilot(Autopilot)`: `HOVER`, `DELIVER`, capture-radius clamp, + fly-over route defaults +- [ ] Plugin issues the three `SELECTIMPL` swaps on load; defaults restored on reset +- [ ] Integration tests: hover-hold, yaw at gs = 0, strafe (fixed nose, moving track), + leg-to-leg course capture, `HOVER`, `DELIVER`, fixed-wing regression guard +- [ ] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green + ## Phase 3 — `MulticopterPerf`: electric performance from PyThrust *data* `class MulticopterPerf(OpenAP)`, selected with `SELECTIMPL OPENAP MULTICOPTERPERF`. Fixed-wing @@ -257,6 +286,22 @@ simulator. `BATT` reports monotonically decreasing SoC; envelope shrinks below a SoC threshold; unit tests for the map interpolation against a few hand-computed points from the source CSV. +### Phase 3 checklist + +- [ ] Vendor the needed prop CSVs + motor JSONs under `plugins/data/multicopter/pythrust/` + with PyThrust's LICENSE and an attribution note +- [ ] Per-typecode config: `{prop, motor, cell, series/parallel, n_rotors, mass, CdS}` +- [ ] `scripts/gen_multicopter_perf.py` (numpy-only, no pythrust import) emitting per-type + `(airspeed, thrust) → (power, current, feasible)` maps + battery curves +- [ ] Check in the generated artifacts (`plugins/data/multicopter/*.npz`) +- [ ] `MulticopterPerf(OpenAP)`: required-thrust model, vectorised map interpolation, + per-aircraft SoC integration, envelope feedback in `limits()` +- [ ] Stack command: `BATT` +- [ ] Unit tests: interpolation vs hand-computed CSV points; SoC monotonically decreasing; + envelope tightens below SoC threshold +- [ ] Sanity: MAVIC-class hover endurance in the 20–35 min range +- [ ] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green + ## Phase 4 — docs, scenarios, cleanup - New guide `docs/guides/multicopters.md`: creating multicopters, hover/yaw commands, battery @@ -268,6 +313,14 @@ for the map interpolation against a few hand-computed points from the source CSV stack commands (`MCOPT`, `YAW`, `YAWRATE`, `HOVER`, `DELIVER`, `BATT`). - `ruff`, `pyright`, full test suite green at every phase boundary. +### Phase 4 checklist + +- [ ] `docs/guides/multicopters.md` (usage, commands, battery model, adding a new type) +- [ ] Update `docs/architecture.md`: `Kinematics` entity + replaceable list +- [ ] `scenarios/multicopter_delivery.scn` exercising create → route → `DELIVER` → return +- [ ] Regenerate `docs/reference/commands.md` (`uv run minisky commands docs`) +- [ ] Final sweep: `uv run pytest`, `uv run ruff check .`, `uv run pyright` + ## Sequencing and effort | Phase | Scope | Risk | Depends on | From 693a8a665cfbe8b45b30dce76e27fd31b8bd86d9 Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:23:56 +0200 Subject: [PATCH 04/16] Correct sequencing note: phases land on this branch, not separate PRs Co-Authored-By: Claude Fable 5 --- docs/multicopter-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md index 3a30426..f2be17b 100644 --- a/docs/multicopter-plan.md +++ b/docs/multicopter-plan.md @@ -330,8 +330,8 @@ for the map interpolation against a few hand-computed points from the source CSV | 3 | Perf: data vendoring, gen script, `MulticopterPerf`, battery | Medium — model calibration/sanity | 2 (usable after 1) | | 4 | Docs, scenario, polish | Low | 2, 3 | -Each phase is a separately reviewable PR; phase 1 is intentionally the only one touching -`minisky/`. +Implementation lands on this branch phase by phase, checking off the checklists above as items +complete; phase 1 is intentionally the only one touching `minisky/`. ## Decision log From 3860bcf8900158f513cc64ab6e9c8d0df81d3b4d Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:48:09 +0200 Subject: [PATCH 05/16] Extract flight integration from Traffic into a Kinematics entity Move update_airspeed / update_groundspeed / update_pos out of Traffic into a new Kinematics(TrafficArrays) entity, along with the ax, az, swhdgsel and swaltsel per-aircraft arrays. Traffic.update() now calls self.kinematics.update(), and perfoap.py reads traf.kinematics.ax. Because Kinematics is a first-level TrafficArrays entity, flight-state integration becomes hot-swappable: plugins can subclass it and activate it with SELECTIMPL KINEMATICS , which is what the multicopter plugin needs to fly rotorcraft differently from fixed wing. This is Phase 1 of docs/multicopter-plan.md; behaviour is unchanged. --- docs/multicopter-plan.md | 18 +- .../minisky/minisky/traffic/kinematics.py | 215 ++++++++++++++++++ .../minisky/traffic/performance/perfoap.py | 3 +- packages/minisky/minisky/traffic/traffic.py | 147 +----------- tests/integration/test_kinematics.py | 62 +++++ 5 files changed, 295 insertions(+), 150 deletions(-) create mode 100644 packages/minisky/minisky/traffic/kinematics.py create mode 100644 tests/integration/test_kinematics.py diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md index f2be17b..365b6d1 100644 --- a/docs/multicopter-plan.md +++ b/docs/multicopter-plan.md @@ -109,16 +109,18 @@ reset (mirror the existing `tests/integration/test_plugin.py` replaceable test). ### Phase 1 checklist -- [ ] Create `minisky/traffic/kinematics.py`: `Kinematics(TrafficArrays)` with +- [x] Create `minisky/traffic/kinematics.py`: `Kinematics(TrafficArrays)` with `update_airspeed` / `update_groundspeed` / `update_pos` and the `ax`, `az`, `swhdgsel`, - `swaltsel` arrays moved over from `Traffic` -- [ ] Instantiate as `self.kinematics = Kinematics()` inside `Traffic.__init__`'s + `swaltsel` arrays moved over from `Traffic` (`ax`/`swhdgsel` registered in `settrafarrays`; + `az`/`swaltsel` remain step-computed as before) +- [x] Instantiate as `self.kinematics = Kinematics()` inside `Traffic.__init__`'s `settrafarrays()` block; `Traffic.update()` calls `self.kinematics.update()` -- [ ] Grep external readers of the moved arrays (`streaming.py`, `perfoap.py`, tests) and expose - delegating properties on `Traffic` where needed -- [ ] Verify `SELECTIMPL KINEMATICS` lists the base implementation -- [ ] Test: register a trivial subclass, select it, verify it takes effect and reverts on reset -- [ ] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green +- [x] Grep external readers of the moved arrays: only `perfoap.py` reads `ax` + (`streaming.py` does not); pointed it at `traf.kinematics.ax` (no property needed) +- [x] Verify `SELECTIMPL KINEMATICS` lists the base implementation +- [x] Test: register a trivial subclass, select it, verify it takes effect and reverts on reset + (`tests/integration/test_kinematics.py`) +- [x] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green ## Phase 2 — the `multicopter` plugin: membership + kinematics diff --git a/packages/minisky/minisky/traffic/kinematics.py b/packages/minisky/minisky/traffic/kinematics.py new file mode 100644 index 0000000..0ba3bdd --- /dev/null +++ b/packages/minisky/minisky/traffic/kinematics.py @@ -0,0 +1,215 @@ +"""Aircraft kinematics integration. + +Defines :class:`Kinematics`, the first-level :class:`TrafficArrays` entity +that numerically integrates airspeed, heading, vertical speed, ground speed +and position of all aircraft each simulation step. The flight-integration +behaviour is a replaceable implementation: plugins may subclass it and +select it with ``SELECTIMPL KINEMATICS `` to change how (a subset of) +aircraft fly (e.g. yaw-rate-limited hover, thrust-redirected translation). + +The base implementation owns the acceleration and turn/altitude-select state +arrays (``ax``, ``az``, ``swhdgsel``, ``swaltsel``); all other per-aircraft +state is read and written on the owning traffic object. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +import numpy as np + +from minisky.core.trafficarrays import TrafficArrays +from minisky.tools.aero import Rearth, fpm, ft, g0, vtas2cas, vtas2mach + +if TYPE_CHECKING: + from minisky.simulation import Simulation + from minisky.traffic import Traffic + + +class Kinematics(TrafficArrays): + """Integrate airspeed, heading, vertical speed and position each step. + + Replaceable via ``SELECTIMPL KINEMATICS ``; plugins may subclass + to change how aircraft fly. Available at runtime as + ``runtime.traffic.kinematics``. + + Attributes: + ax (ndarray): Current longitudinal acceleration [m/s2]. + az (ndarray): Current vertical acceleration [m/s2]. + swhdgsel (ndarray): Bool switch: True while aircraft is turning. + swaltsel (ndarray): Bool switch: True while altitude capture is engaged. + """ + + def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) -> None: + super().__init__(traffic) + self.traffic = traffic + self._get_simulation = get_simulation + with self.settrafarrays(): + # Acceleration + self.ax = np.array([]) # [m/s2] current longitudinal acceleration + self.az = np.array([]) # [m/s2] current vertical acceleration + + # Turn/altitude-select switches + self.swhdgsel = np.array([], dtype=bool) # determines whether aircraft is turning + self.swaltsel = np.array([], dtype=bool) # determines whether altitude capture is on + + def new_implementation(self, implementation: Callable[..., TrafficArrays]) -> TrafficArrays: + """Construct a replacement with this runtime's traffic and simulation.""" + return implementation(self.traffic, self._get_simulation) + + def create(self, n: int = 1) -> None: + """Initialize the integration state for n newly created aircraft. + + New aircraft start in steady flight: no longitudinal or vertical + acceleration, and neither the turn nor the altitude-capture mode + engaged. Called from ``Traffic.create_children()`` after the traffic + state of the new aircraft has been set, so subclasses may seed their + own state from the owning traffic object. + + Args: + n: Number of aircraft that were appended to the traffic arrays. + """ + super().create(n) + self.ax[-n:] = 0.0 + self.az[-n:] = 0.0 + self.swhdgsel[-n:] = False + self.swaltsel[-n:] = False + + def update(self) -> None: + """Integrate airspeed, heading, ground speed and position one step. + + Runs the three integration stages in order. Subclasses that only + change how the aircraft accelerates or steers should override + :meth:`update_airspeed` / :meth:`update_groundspeed` and let a single + :meth:`update_pos` pass integrate the resulting velocity. + """ + self.update_airspeed() + self.update_groundspeed() + self.update_pos() + + def update_airspeed(self) -> None: + """Integrate true airspeed, heading and vertical speed over one step. + + Accelerates or decelerates towards the commanded TAS using the + performance-limited longitudinal acceleration, turns towards the + commanded heading with a turn rate that follows from the bank angle + (commanded turn bank or default bank limit), and updates the vertical + speed for the altitude select/capture/hold autopilot logic. Also + refreshes the derived CAS and Mach values. + """ + traf = self.traffic + simdt = self._get_simulation().simdt + # Compute horizontal acceleration + delta_spd = traf.aporasas.tas - traf.tas + need_ax = np.abs(delta_spd) > np.abs(simdt * traf.perf.axmax) + self.ax = need_ax * np.sign(delta_spd) * traf.perf.axmax + # Update velocities + traf.tas = np.where(need_ax, traf.tas + self.ax * simdt, traf.aporasas.tas) + traf.cas = vtas2cas(traf.tas, traf.alt) + traf.M = vtas2mach(traf.tas, traf.alt) + + # Turning bank triangle + # tan phi = a centrigugal/a grav = omega^2 * R / g = omega * V /g + # => omega = (g tan phi)/V + turnrate = np.degrees( + g0 + * np.tan( + np.where( + traf.ap.turnphi > traf.eps * traf.eps, + traf.ap.turnphi, + traf.ap.bankdef, + ) + ) + / np.maximum(traf.tas, traf.eps) + ) + delhdg = (traf.aporasas.hdg - traf.hdg + 180) % 360 - 180 # [deg] + self.swhdgsel = np.abs(delhdg) > np.abs(simdt * turnrate) + + # Update heading + traf.hdg = ( + np.where( + self.swhdgsel, + traf.hdg + simdt * turnrate * np.sign(delhdg), + traf.aporasas.hdg, + ) + % 360.0 + ) + + # Update vertical speed (alt select, capture and hold autopilot mode) + delta_alt = traf.aporasas.alt - traf.alt + # Old dead band version: + # self.swaltsel = np.abs(delta_alt) > np.maximum( + # 10 * ft, np.abs(2 * simdt * self.vs)) + + # Update version: time based engage of altitude capture (to adapt for UAV vs airliner scale) + self.swaltsel = np.abs(delta_alt) > 1.05 * np.maximum( + np.abs(simdt * traf.aporasas.vs), + np.abs(simdt * traf.vs), + ) + target_vs = self.swaltsel * np.sign(delta_alt) * np.abs(traf.aporasas.vs) + delta_vs = target_vs - traf.vs + # print(delta_vs / fpm) + need_az = np.abs(delta_vs) > 300 * fpm # small threshold + self.az = need_az * np.sign(delta_vs) * (300 * fpm) # fixed vertical acc approx 1.6 m/s^2 + traf.vs = np.where(need_az, traf.vs + self.az * simdt, target_vs) + traf.vs = np.where(np.isfinite(traf.vs), traf.vs, 0) # fix vs nan issue + + def update_groundspeed(self) -> None: + """Compute ground speed and track from heading, airspeed and wind. + + Without wind, ground speed equals TAS and track equals heading. With + a wind field defined, the wind vector at each aircraft position is + added to the airspeed vector (only when airborne, above 50 ft). Also + accumulates the work done by the engines [J] along the flown path. + """ + traf = self.traffic + simdt = self._get_simulation().simdt + # Compute ground speed and track from heading, airspeed and wind + if traf.wind.winddim == 0: # no wind + traf.gsnorth = traf.tas * np.cos(np.radians(traf.hdg)) + traf.gseast = traf.tas * np.sin(np.radians(traf.hdg)) + + traf.gs = traf.tas + traf.trk = traf.hdg + traf.windnorth[:], traf.windeast[:] = 0.0, 0.0 + + else: + applywind = traf.alt > 50.0 * ft # Only apply wind when airborne + + vnwnd, vewnd = traf.wind.getdata(traf.lat, traf.lon, traf.alt) + traf.windnorth[:], traf.windeast[:] = vnwnd, vewnd + traf.gsnorth = traf.tas * np.cos(np.radians(traf.hdg)) + traf.windnorth * applywind + traf.gseast = traf.tas * np.sin(np.radians(traf.hdg)) + traf.windeast * applywind + + traf.gs = np.logical_not(applywind) * traf.tas + applywind * np.sqrt( + traf.gsnorth**2 + traf.gseast**2 + ) + + traf.trk = ( + np.logical_not(applywind) * traf.hdg + + applywind * np.degrees(np.arctan2(traf.gseast, traf.gsnorth)) % 360.0 + ) + + traf.work += traf.perf.thrust * simdt * np.sqrt(traf.gs * traf.gs + traf.vs * traf.vs) + + def update_pos(self) -> None: + """Integrate altitude and lat/lon position over one time step. + + Altitude follows the vertical speed while the altitude-select mode is + engaged, and snaps to the commanded altitude otherwise. Latitude and + longitude are advanced with the ground speed components using a + spherical-Earth approximation, and the flown distance is accumulated. + """ + traf = self.traffic + simdt = self._get_simulation().simdt + # Update position + traf.alt = np.where( + self.swaltsel, + np.round(traf.alt + traf.vs * simdt, 6), + traf.aporasas.alt, + ) + traf.lat = traf.lat + np.degrees(simdt * traf.gsnorth / Rearth) + traf.coslat = np.cos(np.deg2rad(traf.lat)) + traf.lon = traf.lon + np.degrees(simdt * traf.gseast / traf.coslat / Rearth) + traf.distflown += traf.gs * simdt diff --git a/packages/minisky/minisky/traffic/performance/perfoap.py b/packages/minisky/minisky/traffic/performance/perfoap.py index 3b23285..e40f742 100644 --- a/packages/minisky/minisky/traffic/performance/perfoap.py +++ b/packages/minisky/minisky/traffic/performance/perfoap.py @@ -315,7 +315,8 @@ def update(self, dt: float = 1) -> None: # ----- compute net thrust ----- self.thrust[idx_fixwing] = ( - self.drag[idx_fixwing] + self.mass[idx_fixwing] * self.traffic.ax[idx_fixwing] + self.drag[idx_fixwing] + + self.mass[idx_fixwing] * self.traffic.kinematics.ax[idx_fixwing] ) # ----- compute fuel flow ----- diff --git a/packages/minisky/minisky/traffic/traffic.py b/packages/minisky/minisky/traffic/traffic.py index f18fead..3b9e59f 100644 --- a/packages/minisky/minisky/traffic/traffic.py +++ b/packages/minisky/minisky/traffic/traffic.py @@ -25,19 +25,14 @@ from minisky.tools import geo from minisky.tools.aero import ( DEFAULT_CASMACH_THRESHOLD, - Rearth, casormach, casormach2tas, - fpm, ft, - g0, kts, nm, tas2cas, vatmos, vcasormach, - vtas2cas, - vtas2mach, ) from minisky.tools.areafilter import AreaFilter from minisky.tools.convert import latlon2txt @@ -47,6 +42,7 @@ from .aporasas import APorASAS from .autopilot import Autopilot from .conditional import Condition +from .kinematics import Kinematics from .performance.perfoap import OpenAP from .trafficgroups import TrafficGroups from .trails import Trails @@ -96,7 +92,6 @@ class Traffic(TrafficArrays): cas (ndarray): Calibrated airspeed [m/s]. M (ndarray): Mach number [-]. vs (ndarray): Vertical speed [m/s]. - ax (ndarray): Current longitudinal acceleration [m/s2]. p (ndarray): Ambient air pressure [Pa]. rho (ndarray): Ambient air density [kg/m3]. Temp (ndarray): Ambient air temperature [K]. @@ -109,7 +104,6 @@ class Traffic(TrafficArrays): swlnav (ndarray): Bool switch: LNAV (lateral FMS guidance) on/off. swvnav (ndarray): Bool switch: VNAV (vertical FMS guidance) on/off. swvnavspd (ndarray): Bool switch: VNAV speed guidance on/off. - swhdgsel (ndarray): Bool switch: True while aircraft is turning. swats (ndarray): Bool switch: autothrottle on/off. thr (ndarray): Throttle setting [0.0-1.0]; negative = invalid/auto. work (ndarray): Work done by the engines during the flight [J]. @@ -125,6 +119,8 @@ class Traffic(TrafficArrays): cd (ConflictDetection): Conflict detection. cr (ConflictResolution): Conflict resolution. perf (OpenAP): Aircraft performance model. + kinematics (Kinematics): Flight-state integration (airspeed, heading, + vertical speed, ground speed and position). trails (Trails): Radar-display trails. groups (TrafficGroups): Aircraft group administration. @@ -190,9 +186,6 @@ def __init__( self.M = np.array([]) # mach number self.vs = np.array([]) # vertical speed [m/s] - # Acceleration - self.ax = np.array([]) # [m/s2] current longitudinal acceleration - # Atmosphere self.p = np.array([]) # air pressure [N/m2] self.rho = np.array([]) # air density [kg/m3] @@ -223,13 +216,11 @@ def __init__( self.trails = Trails(self, get_simulation) self.actwp = ActiveWaypoint(self) self.perf = OpenAP(self) + self.kinematics = Kinematics(self, get_simulation) # Group Logic self.groups = TrafficGroups(self, areas) - # Traffic autopilot data - self.swhdgsel = np.array([], dtype=bool) # determines whether aircraft is turning - # Traffic autothrottle settings self.swats = np.array( [], dtype=bool @@ -667,13 +658,11 @@ def update(self) -> None: # ---------- Limit commanded speeds based on performance ------------------------------ self.aporasas.tas, self.aporasas.vs, self.aporasas.alt = self.perf.limits( - self.aporasas.tas, self.aporasas.vs, self.aporasas.alt, self.ax + self.aporasas.tas, self.aporasas.vs, self.aporasas.alt, self.kinematics.ax ) # ---------- Kinematics -------------------------------- - self.update_airspeed() - self.update_groundspeed() - self.update_pos() + self.kinematics.update() # ---------- Simulate Turbulence ----------------------- self.turbulence.update() @@ -690,130 +679,6 @@ def update_asas(self) -> None: self.cd.update(self, self) self.cr.update(self.cd, self, self) - def update_airspeed(self) -> None: - """Integrate true airspeed, heading and vertical speed over one step. - - Accelerates or decelerates towards the commanded TAS using the - performance-limited longitudinal acceleration, turns towards the - commanded heading with a turn rate that follows from the bank angle - (commanded turn bank or default bank limit), and updates the vertical - speed for the altitude select/capture/hold autopilot logic. Also - refreshes the derived CAS and Mach values. - """ - # Compute horizontal acceleration - delta_spd = self.aporasas.tas - self.tas - need_ax = np.abs(delta_spd) > np.abs(self.simulation.simdt * self.perf.axmax) - self.ax = need_ax * np.sign(delta_spd) * self.perf.axmax - # Update velocities - self.tas = np.where(need_ax, self.tas + self.ax * self.simulation.simdt, self.aporasas.tas) - self.cas = vtas2cas(self.tas, self.alt) - self.M = vtas2mach(self.tas, self.alt) - - # Turning bank triangle - # tan phi = a centrigugal/a grav = omega^2 * R / g = omega * V /g - # => omega = (g tan phi)/V - turnrate = np.degrees( - g0 - * np.tan( - np.where( - self.ap.turnphi > self.eps * self.eps, - self.ap.turnphi, - self.ap.bankdef, - ) - ) - / np.maximum(self.tas, self.eps) - ) - delhdg = (self.aporasas.hdg - self.hdg + 180) % 360 - 180 # [deg] - self.swhdgsel = np.abs(delhdg) > np.abs(self.simulation.simdt * turnrate) - - # Update heading - self.hdg = ( - np.where( - self.swhdgsel, - self.hdg + self.simulation.simdt * turnrate * np.sign(delhdg), - self.aporasas.hdg, - ) - % 360.0 - ) - - # Update vertical speed (alt select, capture and hold autopilot mode) - delta_alt = self.aporasas.alt - self.alt - # Old dead band version: - # self.swaltsel = np.abs(delta_alt) > np.maximum( - # 10 * ft, np.abs(2 * self.simulation.simdt * self.vs)) - - # Update version: time based engage of altitude capture (to adapt for UAV vs airliner scale) - self.swaltsel = np.abs(delta_alt) > 1.05 * np.maximum( - np.abs(self.simulation.simdt * self.aporasas.vs), - np.abs(self.simulation.simdt * self.vs), - ) - target_vs = self.swaltsel * np.sign(delta_alt) * np.abs(self.aporasas.vs) - delta_vs = target_vs - self.vs - # print(delta_vs / fpm) - need_az = np.abs(delta_vs) > 300 * fpm # small threshold - self.az = need_az * np.sign(delta_vs) * (300 * fpm) # fixed vertical acc approx 1.6 m/s^2 - self.vs = np.where(need_az, self.vs + self.az * self.simulation.simdt, target_vs) - self.vs = np.where(np.isfinite(self.vs), self.vs, 0) # fix vs nan issue - - def update_groundspeed(self) -> None: - """Compute ground speed and track from heading, airspeed and wind. - - Without wind, ground speed equals TAS and track equals heading. With - a wind field defined, the wind vector at each aircraft position is - added to the airspeed vector (only when airborne, above 50 ft). Also - accumulates the work done by the engines [J] along the flown path. - """ - # Compute ground speed and track from heading, airspeed and wind - if self.wind.winddim == 0: # no wind - self.gsnorth = self.tas * np.cos(np.radians(self.hdg)) - self.gseast = self.tas * np.sin(np.radians(self.hdg)) - - self.gs = self.tas - self.trk = self.hdg - self.windnorth[:], self.windeast[:] = 0.0, 0.0 - - else: - applywind = self.alt > 50.0 * ft # Only apply wind when airborne - - vnwnd, vewnd = self.wind.getdata(self.lat, self.lon, self.alt) - self.windnorth[:], self.windeast[:] = vnwnd, vewnd - self.gsnorth = self.tas * np.cos(np.radians(self.hdg)) + self.windnorth * applywind - self.gseast = self.tas * np.sin(np.radians(self.hdg)) + self.windeast * applywind - - self.gs = np.logical_not(applywind) * self.tas + applywind * np.sqrt( - self.gsnorth**2 + self.gseast**2 - ) - - self.trk = ( - np.logical_not(applywind) * self.hdg - + applywind * np.degrees(np.arctan2(self.gseast, self.gsnorth)) % 360.0 - ) - - self.work += ( - self.perf.thrust - * self.simulation.simdt - * np.sqrt(self.gs * self.gs + self.vs * self.vs) - ) - - def update_pos(self) -> None: - """Integrate altitude and lat/lon position over one time step. - - Altitude follows the vertical speed while the altitude-select mode is - engaged, and snaps to the commanded altitude otherwise. Latitude and - longitude are advanced with the ground speed components using a - spherical-Earth approximation, and the flown distance is accumulated. - """ - # Update position - self.alt = np.where( - self.swaltsel, - np.round(self.alt + self.vs * self.simulation.simdt, 6), - self.aporasas.alt, - ) - self.lat = self.lat + np.degrees(self.simulation.simdt * self.gsnorth / Rearth) - self.coslat = np.cos(np.deg2rad(self.lat)) - self.lon = self.lon + np.degrees(self.simulation.simdt * self.gseast / self.coslat / Rearth) - self.distflown += self.gs * self.simulation.simdt - @overload def idx(self, callsign: str) -> int: ... @overload diff --git a/tests/integration/test_kinematics.py b/tests/integration/test_kinematics.py new file mode 100644 index 0000000..33e13e0 --- /dev/null +++ b/tests/integration/test_kinematics.py @@ -0,0 +1,62 @@ +"""Integration tests for the replaceable Kinematics entity (Phase 1). + +Kinematics was factored out of Traffic so that flight-state integration +becomes hot-swappable via SELECTIMPL KINEMATICS . These tests cover +the base entity wiring and the replaceable round-trip (select + revert). +""" + +from __future__ import annotations + +from minisky import MiniSky +from minisky.simulation import Simulation +from minisky.traffic.kinematics import Kinematics + + +class TaggedKinematics(Kinematics): + """Trivial Kinematics subclass used to exercise SELECTIMPL KINEMATICS. + + Defining it registers it as the 'TAGGEDKINEMATICS' implementation; it + only becomes active when explicitly selected. + """ + + def update(self) -> None: + super().update() + self.tag = "tagged" + + +class TestKinematicsEntity: + def test_kinematics_owns_ax_array(self, runtime: MiniSky, sim: Simulation) -> None: + runtime.traffic.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) + sim.step() + # acceleration array lives on the kinematics entity, sized per-aircraft + assert len(runtime.traffic.kinematics.ax) == 1 + + def test_base_kinematics_integrates_position(self, runtime: MiniSky, sim: Simulation) -> None: + runtime.traffic.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) + lon0 = float(runtime.traffic.lon[0]) + sim.step() + # heading 090 at positive ground speed advances longitude eastward + assert runtime.traffic.lon[0] > lon0 + + def test_selectimpl_lists_base_implementation(self, runtime: MiniSky, sim: Simulation) -> None: + ok, msg = runtime.replaceables.select("KINEMATICS") + assert ok + assert "KINEMATICS" in msg.upper() + + def test_select_subclass_takes_effect_and_reverts_on_reset( + self, runtime: MiniSky, sim: Simulation + ) -> None: + runtime.traffic.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) + + ok, msg = runtime.replaceables.select("KINEMATICS", "TAGGEDKINEMATICS") + assert ok, msg + assert isinstance(runtime.traffic.kinematics, TaggedKinematics) + # per-aircraft arrays carry over to the new instance + assert len(runtime.traffic.kinematics.ax) == 1 + + sim.step() + assert getattr(runtime.traffic.kinematics, "tag", None) == "tagged" + + # reset restores the default implementation + runtime.simulation.reset() + assert type(runtime.traffic.kinematics) is Kinematics From 1f76b4c434aef923b5a9d7dbbf94a463af25ee5f Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:51:04 +0200 Subject: [PATCH 06/16] Correct Phase 1 checklist: all four kinematics arrays are trafarrays --- docs/multicopter-plan.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md index 365b6d1..dcd190c 100644 --- a/docs/multicopter-plan.md +++ b/docs/multicopter-plan.md @@ -111,8 +111,9 @@ reset (mirror the existing `tests/integration/test_plugin.py` replaceable test). - [x] Create `minisky/traffic/kinematics.py`: `Kinematics(TrafficArrays)` with `update_airspeed` / `update_groundspeed` / `update_pos` and the `ax`, `az`, `swhdgsel`, - `swaltsel` arrays moved over from `Traffic` (`ax`/`swhdgsel` registered in `settrafarrays`; - `az`/`swaltsel` remain step-computed as before) + `swaltsel` arrays moved over from `Traffic` (all four registered in `settrafarrays` and + seeded in `create()`; `az`/`swaltsel` were previously undeclared attributes materialised + by `update_airspeed`) - [x] Instantiate as `self.kinematics = Kinematics()` inside `Traffic.__init__`'s `settrafarrays()` block; `Traffic.update()` calls `self.kinematics.update()` - [x] Grep external readers of the moved arrays: only `perfoap.py` reads `ax` From cf5949d6c2a23de202dfc52933c8b9815acdb85a Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:58:47 +0200 Subject: [PATCH 07/16] make multicopter plugins multi file --- docs/multicopter-plan.md | 62 +++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md index dcd190c..436fef7 100644 --- a/docs/multicopter-plan.md +++ b/docs/multicopter-plan.md @@ -125,7 +125,26 @@ reset (mirror the existing `tests/integration/test_plugin.py` replaceable test). ## Phase 2 — the `multicopter` plugin: membership + kinematics -New file `plugins/multicopter.py` (plugin name `MULTICOPTER`), no core changes. +New package `example_plugins/multicopter/` (plugin name `MULTICOPTER`), no core changes. One +module per class, so each piece stays small and readable: + +``` +example_plugins/multicopter/ +├── plugin.py # init_plugin(): plugin config, SELECTIMPL swaps on load, reset handling +├── entity.py # MULTICOPTER_TYPES + Multicopter Entity (ismulticopter, selhdg, yawrate) +│ # and its stack commands: MCOPT, YAW, YAWRATE +├── kinematics.py # MulticopterKinematics(Kinematics) +├── aporasas.py # MulticopterAPorASAS(APorASAS) +├── autopilot.py # MulticopterAutopilot(Autopilot): HOVER, DELIVER, capture-radius clamp +├── perf.py # MulticopterPerf(OpenAP) + BATT (Phase 3) +└── data/ # generated perf maps + vendored PyThrust data (Phase 3) +``` + +Loader notes: plugin discovery scans `**/*.py` under `plugin_path` recursively and skips +`_`-prefixed files, so `__init__.py` cannot be the entry point — `plugin.py` is the one module +defining `init_plugin()`; the sibling modules are parsed but not registered (no `init_plugin`). +The folder is imported as a package (`example_plugins.multicopter.plugin`), so `plugin.py` +imports the class modules with relative imports (`from .kinematics import ...`). ### Membership @@ -218,15 +237,17 @@ subclass calling `super()` and adjusting only the masked multicopter rows. ### Phase 2 checklist -- [ ] `plugins/multicopter.py` skeleton: `init_plugin()`, plugin name `MULTICOPTER` -- [ ] `MULTICOPTER_TYPES` set + `Entity` with `ismulticopter`, `selhdg`, `yawrate` arrays, - auto-set from typecode in `create()` -- [ ] Stack commands: `MCOPT`, `YAW`, `YAWRATE` -- [ ] `MulticopterKinematics(Kinematics)`: yaw-rate-limited heading, track-driven velocity - vector, single `update_pos()` pass -- [ ] `MulticopterAPorASAS(APorASAS)`: skip trk→hdg coupling for multicopter rows -- [ ] `MulticopterAutopilot(Autopilot)`: `HOVER`, `DELIVER`, capture-radius clamp, - fly-over route defaults +- [ ] `example_plugins/multicopter/` package skeleton with `plugin.py` (`init_plugin()`, + plugin name `MULTICOPTER`) +- [ ] `entity.py`: `MULTICOPTER_TYPES` set + `Entity` with `ismulticopter`, `selhdg`, + `yawrate` arrays, auto-set from typecode in `create()`; stack commands `MCOPT`, + `YAW`, `YAWRATE` +- [ ] `kinematics.py`: `MulticopterKinematics(Kinematics)` — yaw-rate-limited heading, + track-driven velocity vector, single `update_pos()` pass +- [ ] `aporasas.py`: `MulticopterAPorASAS(APorASAS)` — skip trk→hdg coupling for + multicopter rows +- [ ] `autopilot.py`: `MulticopterAutopilot(Autopilot)` — `HOVER`, `DELIVER`, + capture-radius clamp, fly-over route defaults - [ ] Plugin issues the three `SELECTIMPL` swaps on load; defaults restored on reset - [ ] Integration tests: hover-hold, yaw at gs = 0, strafe (fixed nose, moving track), leg-to-leg course capture, `HOVER`, `DELIVER`, fixed-wing regression guard @@ -260,10 +281,10 @@ Pipeline, following the existing regen conventions (navdb parquet, `minisky comm {prop, motor, cell, series/parallel, n_rotors, mass}), and emits one small artifact per type: a grid `(airspeed, thrust) → (power_w, current_a, feasible)` (~30 KB float32 npz/parquet) plus the battery curves. -2. Artifacts are **checked in** next to the plugin (`plugins/data/multicopter/`). The handful of - vendored source CSV/JSONs (~1 MB) live under `plugins/data/multicopter/pythrust/` together - with PyThrust's LICENSE and an attribution note (the prop tables are repackaged APC published - performance data). +2. Artifacts are **checked in** inside the plugin package (`example_plugins/multicopter/data/`). + The handful of vendored source CSV/JSONs (~1 MB) live under + `example_plugins/multicopter/data/pythrust/` together with PyThrust's LICENSE and an + attribution note (the prop tables are repackaged APC published performance data). 3. Runtime: `MulticopterPerf` loads the artifacts at plugin load and evaluates with vectorised `np.interp`/`RegularGridInterpolator`. Zero per-step Python loops, zero new dependencies. @@ -291,15 +312,16 @@ for the map interpolation against a few hand-computed points from the source CSV ### Phase 3 checklist -- [ ] Vendor the needed prop CSVs + motor JSONs under `plugins/data/multicopter/pythrust/` - with PyThrust's LICENSE and an attribution note +- [ ] Vendor the needed prop CSVs + motor JSONs under + `example_plugins/multicopter/data/pythrust/` with PyThrust's LICENSE and an + attribution note - [ ] Per-typecode config: `{prop, motor, cell, series/parallel, n_rotors, mass, CdS}` - [ ] `scripts/gen_multicopter_perf.py` (numpy-only, no pythrust import) emitting per-type `(airspeed, thrust) → (power, current, feasible)` maps + battery curves -- [ ] Check in the generated artifacts (`plugins/data/multicopter/*.npz`) -- [ ] `MulticopterPerf(OpenAP)`: required-thrust model, vectorised map interpolation, - per-aircraft SoC integration, envelope feedback in `limits()` -- [ ] Stack command: `BATT` +- [ ] Check in the generated artifacts (`example_plugins/multicopter/data/*.npz`) +- [ ] `perf.py`: `MulticopterPerf(OpenAP)` — required-thrust model, vectorised map + interpolation, per-aircraft SoC integration, envelope feedback in `limits()`; + stack command `BATT` - [ ] Unit tests: interpolation vs hand-computed CSV points; SoC monotonically decreasing; envelope tightens below SoC threshold - [ ] Sanity: MAVIC-class hover endurance in the 20–35 min range From ab7ea58c55d023175aaec267f08bbcea03fee4ce Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:08:41 +0200 Subject: [PATCH 08/16] Add the multicopter plugin skeleton Scaffold example_plugins/multicopter/ with one module per class, as Phase 2 of docs/multicopter-plan.md describes: Multicopter (membership plus selhdg/yawrate arrays), MulticopterKinematics, MulticopterAPorASAS and MulticopterAutopilot. Classes, per-aircraft arrays, create() and update() signatures and the stack commands are in place; the bodies are TODOs, so behaviour is still that of the core implementations. plugin.py selects the three implementations through select_implementation rather than the select() classmethod, so the live instances on traf are swapped and not just the generator for future ones, and re-selects them from its reset hook because a reset reverts every replaceable to its default. Commands are registered as bound methods so they get rebound when an instance is replaced. --- example_plugins/multicopter/aporasas.py | 31 +++++++ example_plugins/multicopter/autopilot.py | 103 ++++++++++++++++++++++ example_plugins/multicopter/entity.py | 101 +++++++++++++++++++++ example_plugins/multicopter/kinematics.py | 48 ++++++++++ example_plugins/multicopter/plugin.py | 101 +++++++++++++++++++++ 5 files changed, 384 insertions(+) create mode 100644 example_plugins/multicopter/aporasas.py create mode 100644 example_plugins/multicopter/autopilot.py create mode 100644 example_plugins/multicopter/entity.py create mode 100644 example_plugins/multicopter/kinematics.py create mode 100644 example_plugins/multicopter/plugin.py diff --git a/example_plugins/multicopter/aporasas.py b/example_plugins/multicopter/aporasas.py new file mode 100644 index 0000000..a2961fa --- /dev/null +++ b/example_plugins/multicopter/aporasas.py @@ -0,0 +1,31 @@ +"""Multicopter pilot-logic override. + +The core :class:`APorASAS` derives the desired *heading* from the desired +*track* (with a wind-drift correction), baking the fixed-wing assumption +"the aircraft flies where its nose points" into the command path. A +multicopter redirects thrust instead, so for multicopter rows the desired +heading is the commanded body heading and the desired track is left to the +FMS / conflict resolution. + +Selected with ``SELECTIMPL APORASAS MULTICOPTERAPORASAS``. +""" + +from __future__ import annotations + +from minisky.traffic.aporasas import APorASAS + + +class MulticopterAPorASAS(APorASAS): + """Skip the track-to-heading coupling for multicopter rows.""" + + def update(self) -> None: + """Select the desired states, then decouple heading from track. + + Runs the base selection for the whole fleet and afterwards + overwrites ``self.hdg`` on the multicopter rows with the commanded + body heading, leaving ``self.trk`` (which the kinematics now flies) + untouched. + """ + super().update() + # TODO: self.hdg[m] = commanded body heading, falling back to + # self.trk[m] where no body heading was ever commanded. diff --git a/example_plugins/multicopter/autopilot.py b/example_plugins/multicopter/autopilot.py new file mode 100644 index 0000000..99409a3 --- /dev/null +++ b/example_plugins/multicopter/autopilot.py @@ -0,0 +1,103 @@ +"""Multicopter mission autopilot. + +A thin subclass of the core :class:`Autopilot`: LNAV already emits a *track* +command, which is exactly what the decoupled multicopter kinematics +consumes, so no guidance rewrite is needed. What the stock FMS cannot +express is added here — the ``HOVER`` and ``DELIVER`` mission primitives, +a fixed waypoint capture radius (the bank- and speed-based turn distance +degenerates at creeping speeds), and fly-over route defaults. + +Selected with ``SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT``. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +import numpy as np + +from minisky.traffic.autopilot import Autopilot + +if TYPE_CHECKING: + from minisky.simulation import Simulation + from minisky.traffic import Traffic + +#: Waypoint capture radius for multicopters [m]. +CAPTURE_RADIUS = 10.0 + +# Mission states +MISSION_NONE = 0 +MISSION_HOVER = 1 +MISSION_DELIVER = 2 + + +class MulticopterAutopilot(Autopilot): + """Autopilot with multicopter mission primitives. + + Attributes: + mission (ndarray): Current mission state (one of ``MISSION_*``). + missiontimer (ndarray): Remaining dwell time of the active mission + state [s]. + missionalt (ndarray): Target altitude of an active DELIVER [m]. + resumealt (ndarray): Altitude to climb back to after a DELIVER [m]. + """ + + def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) -> None: + super().__init__(traffic, get_simulation) + with self.settrafarrays(): + self.mission = np.array([], dtype=int) + self.missiontimer = np.array([]) + self.missionalt = np.array([]) + self.resumealt = np.array([]) + + def create(self, n: int = 1) -> None: + """Seed the mission state of n newly created aircraft. + + New aircraft start with no mission active. + + Args: + n: Number of aircraft that were appended to the traffic arrays. + """ + super().create(n) + # TODO: mission = MISSION_NONE, timers/altitudes zeroed. + + def update(self) -> None: + """Run the FMS, then the multicopter mission state machine. + + After the base autopilot update, advances any active HOVER/DELIVER + state (counting the dwell timers down and resuming the route when + they expire) and clamps the waypoint turn distance of multicopter + rows to a fixed capture radius. + """ + super().update() + # TODO: advance mission timers/state machine for multicopter rows. + # TODO: clamp actwp.turndist[m] to CAPTURE_RADIUS. + + def hover(self, idx: int, duration: float | None = None) -> tuple[bool, str]: + """Hold position, optionally for a fixed duration. + + Suspends LNAV and commands zero ground speed. With a duration, the + route resumes automatically once it has elapsed; without one, the + aircraft hovers until LNAV is re-engaged. + + Arguments: + - idx: Aircraft callsign + - duration: Hold time [s] (optional, omit to hover indefinitely) + """ + # TODO: enter MISSION_HOVER for idx + return False, "HOVER: not implemented yet" + + def deliver(self, idx: int, alt: float, dwell: float | None = None) -> tuple[bool, str]: + """Descend vertically to an altitude, dwell, climb back, resume route. + + The horizontal position is held throughout, so lat/lon are unchanged + for the whole manoeuvre. + + Arguments: + - idx: Aircraft callsign + - alt: Delivery altitude [ft or FL] + - dwell: Time spent at the delivery altitude [s] (optional) + """ + # TODO: enter MISSION_DELIVER for idx + return False, "DELIVER: not implemented yet" diff --git a/example_plugins/multicopter/entity.py b/example_plugins/multicopter/entity.py new file mode 100644 index 0000000..cd1a39d --- /dev/null +++ b/example_plugins/multicopter/entity.py @@ -0,0 +1,101 @@ +"""Multicopter membership and per-aircraft state. + +Holds the plugin-owned per-aircraft arrays that mark which aircraft are +multicopters and carry their decoupled body heading and yaw rate, plus the +stack commands that read and write them (``MCOPT``, ``YAW``, ``YAWRATE``). + +Membership is deliberately *not* ``traf.perf.lifttype == LIFT_ROTOR``: that +set also contains the EC35, a crewed helicopter, which this plugin does not +model. It is a fixed typecode set instead, overridable per aircraft. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from minisky import plugin + +if TYPE_CHECKING: + from minisky.traffic import Traffic + +#: OpenAP rotor typecodes minus helicopters (the EC35 is excluded on purpose). +MULTICOPTER_TYPES = frozenset( + {"MAVIC", "PHAN4", "M100", "M200", "M600", "MNET", "AMZN", "HORSEFLY"} +) + +#: Default yaw rate for a newly created multicopter [deg/s]. +DEFAULT_YAWRATE = 90.0 + + +class Multicopter(plugin.Entity): + """Per-aircraft multicopter state. + + Attributes: + ismulticopter (ndarray): Bool switch: aircraft is flown as a + multicopter (yaw-rate-limited heading, track-driven velocity). + selhdg (ndarray): Commanded body heading [deg], decoupled from track. + swselhdg (ndarray): Bool switch: a body heading was commanded. While + False the nose follows the track (nose-along-course default). + yawrate (ndarray): Maximum yaw rate [deg/s]. + """ + + def __init__(self, traffic: Traffic) -> None: + super().__init__(traffic) + with self.settrafarrays(): + self.ismulticopter = np.array([], dtype=bool) + self.selhdg = np.array([]) + self.swselhdg = np.array([], dtype=bool) + self.yawrate = np.array([]) + + def create(self, n: int = 1) -> None: + """Seed multicopter state for n newly created aircraft. + + Membership follows from the typecode; the body heading starts + unconstrained (nose follows track) at the default yaw rate. + + Args: + n: Number of aircraft that were appended to the traffic arrays. + """ + super().create(n) + # TODO: set ismulticopter from self.traffic.typecode[-n:], seed + # selhdg from the current heading, swselhdg False, yawrate default. + + def mask(self) -> np.ndarray: + """Return the boolean row mask of aircraft flown as multicopters.""" + return self.ismulticopter + + def mcopt(self, idx: int, flag: bool | None = None) -> tuple[bool, str]: + """Mark an aircraft as a multicopter (or report its current setting). + + Arguments: + - idx: Aircraft callsign + - flag: ON to fly it as a multicopter, OFF for normal fixed-wing + kinematics (optional, omit to query) + """ + # TODO: report or set self.ismulticopter[idx] + return False, "MCOPT: not implemented yet" + + def yaw(self, idx: int, hdg: float) -> tuple[bool, str]: + """Command the body heading (nose direction) of a multicopter. + + The velocity vector keeps following the track command from the FMS + or conflict resolution, so this rotates the aircraft in place. + + Arguments: + - idx: Aircraft callsign + - hdg: Commanded body heading [deg] + """ + # TODO: set self.selhdg[idx] / self.swselhdg[idx] + return False, "YAW: not implemented yet" + + def setyawrate(self, idx: int, yawrate: float | None = None) -> tuple[bool, str]: + """Set or report the maximum yaw rate of a multicopter. + + Arguments: + - idx: Aircraft callsign + - yawrate: Maximum yaw rate [deg/s] (optional, omit to query) + """ + # TODO: report or set self.yawrate[idx] + return False, "YAWRATE: not implemented yet" diff --git a/example_plugins/multicopter/kinematics.py b/example_plugins/multicopter/kinematics.py new file mode 100644 index 0000000..a6eb975 --- /dev/null +++ b/example_plugins/multicopter/kinematics.py @@ -0,0 +1,48 @@ +"""Multicopter flight integration. + +Replaces the bank-to-turn kinematics of the core :class:`Kinematics` entity +for multicopter rows: heading slews at a fixed yaw rate (valid at zero +airspeed, so hover-yaw works), and the velocity vector follows the +*commanded track* rather than the heading, so track and heading are +decoupled. + +Selected with ``SELECTIMPL KINEMATICS MULTICOPTERKINEMATICS``; fixed-wing +rows keep the base-class behaviour untouched. +""" + +from __future__ import annotations + +from minisky.traffic.kinematics import Kinematics + + +class MulticopterKinematics(Kinematics): + """Yaw-rate-limited, track-driven integration for multicopter rows. + + Only :meth:`update_airspeed` and :meth:`update_groundspeed` are + overridden; the inherited :meth:`Kinematics.update` still runs a single + :meth:`Kinematics.update_pos` pass afterwards, so position is integrated + exactly once from the corrected velocity. + """ + + def update_airspeed(self) -> None: + """Integrate TAS, heading and vertical speed one step. + + Runs the base implementation for the whole fleet, then re-integrates + the heading of multicopter rows at their yaw rate instead of the + bank-angle turn rate (which explodes as TAS approaches zero). + """ + super().update_airspeed() + # TODO: slew traf.hdg[m] towards the commanded body heading, + # clipped to yawrate * simdt. + + def update_groundspeed(self) -> None: + """Compute ground speed and track from the velocity vector. + + Runs the base implementation for the whole fleet, then rebuilds the + ground-speed components of multicopter rows from the *commanded + track* (``traf.aporasas.trk``) plus wind, and derives ``gs``/``trk`` + from them. + """ + super().update_groundspeed() + # TODO: recompute traf.gsnorth/gseast/gs/trk for the multicopter + # rows from aporasas.trk instead of traf.hdg. diff --git a/example_plugins/multicopter/plugin.py b/example_plugins/multicopter/plugin.py new file mode 100644 index 0000000..1c6bba4 --- /dev/null +++ b/example_plugins/multicopter/plugin.py @@ -0,0 +1,101 @@ +"""MULTICOPTER — simulate small electric multirotors. + +Makes MiniSky fly DJI MAVIC/M600/PHAN4-class and Amazon/Matternet-style +delivery drones with multicopter behaviour: hover and yaw at zero airspeed, +and a velocity vector decoupled from the body heading (the aircraft can +strafe — change course without rotating the nose). + +Everything is implemented as replaceable subclasses of core entities, which +the plugin selects on load: + +- ``KINEMATICS`` -> :class:`MulticopterKinematics` — yaw-rate-limited + heading, track-driven velocity vector. +- ``APORASAS`` -> :class:`MulticopterAPorASAS` — no track-to-heading + coupling for multicopter rows. +- ``AUTOPILOT`` -> :class:`MulticopterAutopilot` — HOVER/DELIVER mission + primitives and a fixed waypoint capture radius. + +Fixed-wing aircraft in the same simulation are untouched: every override +calls ``super()`` and adjusts only the multicopter rows. Helicopters are out +of scope — membership is a typecode set, not ``LIFT_ROTOR``. + +Stack commands: MCOPT, YAW, YAWRATE, HOVER, DELIVER. +""" + +from __future__ import annotations + +import minisky +from minisky import stack +from minisky.core.trafficarrays import select_implementation + +from .aporasas import MulticopterAPorASAS +from .autopilot import MulticopterAutopilot +from .entity import Multicopter +from .kinematics import MulticopterKinematics + +#: The plugin's per-aircraft state entity; None until init_plugin() ran. +multicopter: Multicopter | None = None + +#: Replaceable base -> multicopter implementation, selected on load and reset. +IMPLEMENTATIONS = ( + ("KINEMATICS", MulticopterKinematics), + ("APORASAS", MulticopterAPorASAS), + ("AUTOPILOT", MulticopterAutopilot), +) + + +def init_plugin(): + """Initialise the multicopter plugin. + + Creates the per-aircraft state entity, swaps in the three multicopter + implementations, and registers the stack commands. + """ + global multicopter + + multicopter = mc = Multicopter() + _select_implementations() + + ap = minisky.traf.ap # the MulticopterAutopilot instance just selected + if not isinstance(ap, MulticopterAutopilot): + raise RuntimeError("MULTICOPTER: could not select MulticopterAutopilot") + _register_commands(mc, ap) + + config = { + "plugin_name": "MULTICOPTER", + "reset": reset, + } + return config + + +def _select_implementations() -> None: + """Swap the multicopter implementations onto ``traf``. + + Equivalent to issuing ``SELECTIMPL `` for each entry of + :data:`IMPLEMENTATIONS`; replaces the live instance immediately and + rebinds any stack commands bound to the old one. + """ + for basename, impl in IMPLEMENTATIONS: + select_implementation(basename, impl.__name__) + + +def _register_commands(mc: Multicopter, ap: MulticopterAutopilot) -> None: + """Register the plugin's stack commands. + + Registered as bound methods, so a later SELECTIMPL swap rebinds them to + the new instance. Argument specifications are given explicitly, so they + override the plain Python annotations on the callbacks. + """ + stack.command(mc.mcopt, name="MCOPT", arguments="callsign,[onoff]") + stack.command(mc.yaw, name="YAW", arguments="callsign,hdg") + stack.command(mc.setyawrate, name="YAWRATE", arguments="callsign,[float]") + stack.command(ap.hover, name="HOVER", arguments="callsign,[time]") + stack.command(ap.deliver, name="DELIVER", arguments="callsign,alt,[time]") + + +def reset() -> None: + """Re-arm the plugin after a simulation reset. + + A reset reverts every replaceable to its core default, so the + multicopter implementations have to be selected again. + """ + _select_implementations() From 8bfabe31bf33db71ea5d25235f79241faecf0f98 Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:13:39 +0200 Subject: [PATCH 09/16] Add the multicopter plugin skeleton Scaffold example_plugins/multicopter/ with one module per class, as docs/multicopter-plan.md describes: Multicopter (membership plus the selhdg/yawrate arrays), MulticopterKinematics, MulticopterAPorASAS, MulticopterAutopilot and MulticopterPerf. Classes, per-aircraft arrays, create()/update() signatures and the stack commands MCOPT, YAW, YAWRATE, HOVER, DELIVER and BATT are in place; the bodies are TODOs, so behaviour is still that of the core implementations. perf.py belongs to Phase 3 and is scaffolded early so the whole plugin shape is visible in one place; its generated data/ maps do not exist yet. plugin.py selects the four implementations through select_implementation rather than the select() classmethod, so the live instances on traf are swapped and not just the generator for future ones, and re-selects them from its reset hook because a reset reverts every replaceable to its default. Commands are registered as bound methods so they get rebound when an instance is replaced. --- example_plugins/multicopter/perf.py | 139 ++++++++++++++++++++++++++ example_plugins/multicopter/plugin.py | 101 +++++++++++-------- 2 files changed, 197 insertions(+), 43 deletions(-) create mode 100644 example_plugins/multicopter/perf.py diff --git a/example_plugins/multicopter/perf.py b/example_plugins/multicopter/perf.py new file mode 100644 index 0000000..4c5bcf0 --- /dev/null +++ b/example_plugins/multicopter/perf.py @@ -0,0 +1,139 @@ +"""Electric performance for multicopters. + +Fills the ``# TODO: implement thrust computation for rotor aircraft`` gap in +the core :class:`OpenAP` model for multicopter rows: required thrust from +the mass and acceleration, power and current from a precomputed +``(airspeed, thrust) -> (power, current, feasible)`` map per typecode, and a +battery state of charge that is integrated each step and feeds back into the +flight envelope as the pack voltage sags. + +Fixed-wing rows keep the ``super()`` behaviour untouched. Selected with +``SELECTIMPL OPENAP MULTICOPTERPERF``. + +The maps are generated offline by ``scripts/gen_multicopter_perf.py`` from +propeller, motor and battery data vendored under ``data/pythrust/``, and +checked in under ``data/``. PyThrust itself is *not* a runtime dependency: +only its data is used, and only through ``np.interp``-style lookups. + +Fidelity caveat: the APC propeller coefficients are axial-flow, so +forward-flight power for a translating multicopter is approximate. Hover +figures and the qualitative trends (power against speed, voltage sag) are +sound — the right level for a traffic simulator. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np + +from minisky.traffic.performance.perfoap import OpenAP + +if TYPE_CHECKING: + from minisky.traffic import Traffic + +#: Directory holding the generated per-typecode performance maps. +DATA_PATH = Path(__file__).parent / "data" + + +class MulticopterPerf(OpenAP): + """OpenAP performance with an electric model for multicopter rows. + + Attributes: + soc (ndarray): Battery state of charge [0-1]. + capacity (ndarray): Usable pack capacity [As]. + current (ndarray): Current battery current draw [A]. + voltage (ndarray): Current battery terminal voltage [V]. + power (ndarray): Current electrical power draw [W] — the electric + analogue of ``fuelflow``. + nrotors (ndarray): Number of rotors [-]. + cds (ndarray): Flat-plate parasite drag area [m2]. + """ + + def __init__(self, traffic: Traffic) -> None: + super().__init__(traffic) + # TODO: load the generated maps and battery curves from DATA_PATH + with self.settrafarrays(): + self.soc = np.array([]) + self.capacity = np.array([]) + self.current = np.array([]) + self.voltage = np.array([]) + self.power = np.array([]) + self.nrotors = np.array([]) + self.cds = np.array([]) + + def create(self, n: int = 1) -> None: + """Seed the electric state of n newly created aircraft. + + Multicopters start on a full battery with the pack, rotor count and + drag area of their typecode; other aircraft get zeros. + + Args: + n: Number of aircraft that were appended to the traffic arrays. + """ + super().create(n) + # TODO: look up the per-typecode config, seed soc = 1.0, capacity, + # nrotors and cds. + + def update(self, dt: float = 1) -> None: + """Update performance, then the electric model for multicopter rows. + + After the base update, computes the thrust each multicopter needs to + hold its current acceleration and overcome parasite drag, reads power + and current off the per-typecode map, and integrates the battery + state of charge. + + Args: + dt: Update timestep [s]. + """ + super().update(dt) + # TODO: required thrust -> map lookup -> self.thrust/power/current + # TODO: integrate self.soc; update self.voltage from the OCV/R curves + + def limits( + self, + intent_v_tas: np.ndarray, + intent_vs: np.ndarray, + intent_h: np.ndarray, + ax: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Clip the intended state to the flight envelope. + + Runs the base envelope, then tightens the speed and climb-rate limits + of multicopter rows wherever the performance map is infeasible at the + current pack voltage, so performance genuinely degrades as the + battery empties. + + Args: + intent_v_tas: Intended true airspeed [m/s]. + intent_vs: Intended vertical speed [m/s]. + intent_h: Intended altitude [m]. + ax: Current longitudinal acceleration [m/s2]. + + Returns: + Allowed TAS [m/s], vertical speed [m/s] and altitude [m]. + """ + allow_v_tas, allow_vs, allow_h = super().limits(intent_v_tas, intent_vs, intent_h, ax) + # TODO: shrink vmax/vsmax for multicopter rows at low state of charge + return allow_v_tas, allow_vs, allow_h + + def required_thrust(self) -> np.ndarray: + """Return the thrust each multicopter needs right now [N]. + + Hover and climb need ``m * sqrt(g^2 + a^2)`` spread over the rotors; + translating additionally costs a flat-plate parasite term + ``0.5 * rho * v^2 * CdS``. + """ + # TODO + return np.zeros(len(self.mass)) + + def batt(self, idx: int) -> tuple[bool, str]: + """Report battery state of charge, power draw and endurance estimate. + + Arguments: + - idx: Aircraft callsign + """ + # TODO: report soc/voltage/current/power and a remaining-endurance + # estimate at the current draw + return False, "BATT: not implemented yet" diff --git a/example_plugins/multicopter/plugin.py b/example_plugins/multicopter/plugin.py index 1c6bba4..7bd0685 100644 --- a/example_plugins/multicopter/plugin.py +++ b/example_plugins/multicopter/plugin.py @@ -14,88 +14,103 @@ coupling for multicopter rows. - ``AUTOPILOT`` -> :class:`MulticopterAutopilot` — HOVER/DELIVER mission primitives and a fixed waypoint capture radius. +- ``OPENAP`` -> :class:`MulticopterPerf` — electric performance: power + from a propeller/motor map, battery state of charge, sagging envelope. Fixed-wing aircraft in the same simulation are untouched: every override calls ``super()`` and adjusts only the multicopter rows. Helicopters are out of scope — membership is a typecode set, not ``LIFT_ROTOR``. -Stack commands: MCOPT, YAW, YAWRATE, HOVER, DELIVER. +Stack commands: MCOPT, YAW, YAWRATE, HOVER, DELIVER, BATT. """ from __future__ import annotations -import minisky -from minisky import stack -from minisky.core.trafficarrays import select_implementation +from typing import TYPE_CHECKING, Any from .aporasas import MulticopterAPorASAS from .autopilot import MulticopterAutopilot from .entity import Multicopter from .kinematics import MulticopterKinematics +from .perf import MulticopterPerf -#: The plugin's per-aircraft state entity; None until init_plugin() ran. -multicopter: Multicopter | None = None +if TYPE_CHECKING: + from minisky import MiniSky #: Replaceable base -> multicopter implementation, selected on load and reset. IMPLEMENTATIONS = ( ("KINEMATICS", MulticopterKinematics), ("APORASAS", MulticopterAPorASAS), ("AUTOPILOT", MulticopterAutopilot), + ("OPENAP", MulticopterPerf), ) -def init_plugin(): - """Initialise the multicopter plugin. +def init_plugin(runtime: MiniSky) -> tuple[dict[str, Any], dict[str, list[Any]]]: + """Initialise the multicopter plugin for one MiniSky runtime. - Creates the per-aircraft state entity, swaps in the three multicopter - implementations, and registers the stack commands. - """ - global multicopter + Creates the per-aircraft state entity on the runtime's traffic tree, + swaps in the multicopter implementations, and returns the stack commands. + + Args: + runtime: MiniSky runtime loading this plugin. - multicopter = mc = Multicopter() - _select_implementations() + Returns: + A `(config, stack_functions)` tuple consumed by the plugin manager. + """ + mc = Multicopter(runtime.traffic) + _select_implementations(runtime) - ap = minisky.traf.ap # the MulticopterAutopilot instance just selected - if not isinstance(ap, MulticopterAutopilot): - raise RuntimeError("MULTICOPTER: could not select MulticopterAutopilot") - _register_commands(mc, ap) + # The instances just selected onto traf; commands bind to these + ap = runtime.traffic.ap + perf = runtime.traffic.perf + if not isinstance(ap, MulticopterAutopilot) or not isinstance(perf, MulticopterPerf): + raise RuntimeError("MULTICOPTER: could not select the multicopter implementations") config = { "plugin_name": "MULTICOPTER", - "reset": reset, + "reset": lambda: _select_implementations(runtime), + "state": mc, } - return config + return config, _stack_functions(mc, ap, perf) -def _select_implementations() -> None: - """Swap the multicopter implementations onto ``traf``. +def _select_implementations(runtime: MiniSky) -> None: + """Swap the multicopter implementations onto the runtime's ``traf``. Equivalent to issuing ``SELECTIMPL `` for each entry of :data:`IMPLEMENTATIONS`; replaces the live instance immediately and - rebinds any stack commands bound to the old one. + rebinds any stack commands bound to the old one. Also called from the + reset hook, since a reset reverts every replaceable to its core default. """ for basename, impl in IMPLEMENTATIONS: - select_implementation(basename, impl.__name__) - + runtime.replaceables.select(basename, impl.__name__) -def _register_commands(mc: Multicopter, ap: MulticopterAutopilot) -> None: - """Register the plugin's stack commands. - - Registered as bound methods, so a later SELECTIMPL swap rebinds them to - the new instance. Argument specifications are given explicitly, so they - override the plain Python annotations on the callbacks. - """ - stack.command(mc.mcopt, name="MCOPT", arguments="callsign,[onoff]") - stack.command(mc.yaw, name="YAW", arguments="callsign,hdg") - stack.command(mc.setyawrate, name="YAWRATE", arguments="callsign,[float]") - stack.command(ap.hover, name="HOVER", arguments="callsign,[time]") - stack.command(ap.deliver, name="DELIVER", arguments="callsign,alt,[time]") +def _stack_functions( + mc: Multicopter, ap: MulticopterAutopilot, perf: MulticopterPerf +) -> dict[str, list[Any]]: + """Build the plugin's stack-command table. -def reset() -> None: - """Re-arm the plugin after a simulation reset. - - A reset reverts every replaceable to its core default, so the - multicopter implementations have to be selected again. + Bound to the freshly selected instances; a later SELECTIMPL swap rebinds + them to the new instance. Argument specifications are given explicitly, + so they override the plain Python annotations on the callbacks. """ - _select_implementations() + return { + "MCOPT": [mc.mcopt, "callsign,[onoff]", "MCOPT callsign,[onoff]", mc.mcopt.__doc__], + "YAW": [mc.yaw, "callsign,hdg", "YAW callsign,hdg", mc.yaw.__doc__], + "YAWRATE": [ + mc.setyawrate, + "callsign,[float]", + "YAWRATE callsign,[rate]", + mc.setyawrate.__doc__, + ], + "HOVER": [ap.hover, "callsign,[time]", "HOVER callsign,[time]", ap.hover.__doc__], + "DELIVER": [ + ap.deliver, + "callsign,alt,[time]", + "DELIVER callsign,alt,[time]", + ap.deliver.__doc__, + ], + "BATT": [perf.batt, "callsign", "BATT callsign", perf.batt.__doc__], + } From 13a17b7419ae57fcab49a6e94bbc37bcbe77ce13 Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:59:30 +0200 Subject: [PATCH 10/16] inititlaise multicopter classes --- docs/multicopter-plan.md | 76 +++--- example_plugins/multicopter/activewp.py | 70 ++++++ example_plugins/multicopter/aporasas.py | 16 +- example_plugins/multicopter/autopilot.py | 194 ++++++++++---- example_plugins/multicopter/entity.py | 56 ++++- example_plugins/multicopter/kinematics.py | 57 ++++- example_plugins/multicopter/plugin.py | 66 ++--- tests/integration/test_multicopter.py | 292 ++++++++++++++++++++++ 8 files changed, 700 insertions(+), 127 deletions(-) create mode 100644 example_plugins/multicopter/activewp.py create mode 100644 tests/integration/test_multicopter.py diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md index 436fef7..a24c957 100644 --- a/docs/multicopter-plan.md +++ b/docs/multicopter-plan.md @@ -1,6 +1,6 @@ # Multicopter support plan -Status: **proposal** — nothing in this document is implemented yet. +Status: **in progress** — Phases 1 and 2 are implemented on this branch. ## Goal @@ -135,7 +135,8 @@ example_plugins/multicopter/ │ # and its stack commands: MCOPT, YAW, YAWRATE ├── kinematics.py # MulticopterKinematics(Kinematics) ├── aporasas.py # MulticopterAPorASAS(APorASAS) -├── autopilot.py # MulticopterAutopilot(Autopilot): HOVER, DELIVER, capture-radius clamp +├── autopilot.py # MulticopterAutopilot(Autopilot): HOVER, fly-over route defaults +├── activewp.py # MulticopterActiveWaypoint(ActiveWaypoint): fixed capture radius ├── perf.py # MulticopterPerf(OpenAP) + BATT (Phase 3) └── data/ # generated perf maps + vendored PyThrust data (Phase 3) ``` @@ -207,21 +208,27 @@ activates for `FLYTURN` waypoints, which multicopters won't use. A thin subclass (`SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT`) covers what the stock FMS cannot: -- **Mission primitives** the FMS has no concept of: - - `HOVER acid [time]` — suspend LNAV, hold position (commanded gs = 0), auto-resume the route - after the optional duration. The conditional-command machinery (ATALT/ATDIST) cannot express - "hold for 90 s". - - `DELIVER acid alt [time]` — at the current position: vertical descent to `alt`, dwell, climb - back, continue the route. Implemented as a small per-aircraft state machine on top of - `super().update()`. -- **Low-speed guards**: `calcturn()` and the turn-distance/deceleration formulas are bank- and - speed-based; clamp `actwp.turndist` for multicopter rows to a fixed capture radius (~5–10 m) - so waypoint switching stays sane at creeping speeds and at hover on top of a waypoint. -- **Route defaults**: set fly-over + capture radius automatically for `ismulticopter` aircraft - when waypoints are added, so scenario authors need no extra commands. - -With this, the plugin issues three swaps on load — `KINEMATICS`, `APORASAS`, `AUTOPILOT` — each -subclass calling `super()` and adjusting only the masked multicopter rows. +- **A hover primitive** the FMS has no concept of — deliberately *composable*, not a scripted + manoeuvre (a "delivery" is written in the scenario from `HOVER` + `ALT` + `LNAV`): + - `HOVER acid [time] [alt]` — suspend LNAV/VNAV, hold position (commanded gs = 0), optionally + at a commanded altitude (moved to vertically, at a fixed position). With a `time` the route + auto-resumes once position and altitude have been held that long (the conditional-command + machinery cannot express "hold for 90 s"); without one the aircraft hovers until LNAV is + re-engaged. Repeating `HOVER` while hovering updates the hold; a plain `ALT` changes the + hover altitude too. +- **`HDG` semantics**: for multicopter rows `HDG` becomes an alias of `YAW` — it rotates the + nose only and leaves LNAV engaged. +- **Route defaults**: fly-over waypoints automatically for multicopter aircraft, so scenario + authors need no extra commands. +- **Low-speed capture (in `MulticopterActiveWaypoint`)**: `calcturn()` and the turn-distance + formulas are bank- and speed-based and degenerate at multicopter speeds; multicopter rows use + a fixed capture radius (10 m) instead. This must live in an `ActiveWaypoint` subclass, because + `ActiveWaypoint.reached()` recomputes `turndist` every step — clamping it from the autopilot + update would be overwritten before it is ever used. + +With this, the plugin issues four swaps on load — `KINEMATICS`, `APORASAS`, `AUTOPILOT`, +`ACTIVEWAYPOINT` — each subclass calling `super()` and adjusting only the masked multicopter +rows. **Acceptance (integration tests, driven through the stack like `test_stack.py`):** @@ -230,28 +237,33 @@ subclass calling `super()` and adjusting only the masked multicopter rows. - In cruise, `YAW D1 0` while flying track 090 → `trk` stays 090, `hdg` goes to 0. - Waypoint passage: course changes leg-to-leg with no overshoot arc. - `HOVER D1 90` mid-route → position frozen for 90 s of sim time, then the route resumes. -- `DELIVER D1 50 30` → vertical descent to 50 ft, 30 s dwell, climb back, route resumes; - lat/lon unchanged throughout. +- `HOVER D1 30 100` mid-route → vertical descent to 100 ft at a fixed position, 30 s hold, + route resumes at the hover altitude; a delivery profile composes from `HOVER`, `ALT` and + `LNAV ON` with lat/lon unchanged throughout. - A fixed-wing aircraft in the same simulation behaves byte-identically to `main` (regression guard for the fleet-wide hooks). ### Phase 2 checklist -- [ ] `example_plugins/multicopter/` package skeleton with `plugin.py` (`init_plugin()`, +- [x] `example_plugins/multicopter/` package skeleton with `plugin.py` (`init_plugin()`, plugin name `MULTICOPTER`) -- [ ] `entity.py`: `MULTICOPTER_TYPES` set + `Entity` with `ismulticopter`, `selhdg`, +- [x] `entity.py`: `MULTICOPTER_TYPES` set + `Entity` with `ismulticopter`, `selhdg`, `yawrate` arrays, auto-set from typecode in `create()`; stack commands `MCOPT`, `YAW`, `YAWRATE` -- [ ] `kinematics.py`: `MulticopterKinematics(Kinematics)` — yaw-rate-limited heading, +- [x] `kinematics.py`: `MulticopterKinematics(Kinematics)` — yaw-rate-limited heading, track-driven velocity vector, single `update_pos()` pass -- [ ] `aporasas.py`: `MulticopterAPorASAS(APorASAS)` — skip trk→hdg coupling for +- [x] `aporasas.py`: `MulticopterAPorASAS(APorASAS)` — skip trk→hdg coupling for multicopter rows -- [ ] `autopilot.py`: `MulticopterAutopilot(Autopilot)` — `HOVER`, `DELIVER`, - capture-radius clamp, fly-over route defaults -- [ ] Plugin issues the three `SELECTIMPL` swaps on load; defaults restored on reset -- [ ] Integration tests: hover-hold, yaw at gs = 0, strafe (fixed nose, moving track), - leg-to-leg course capture, `HOVER`, `DELIVER`, fixed-wing regression guard -- [ ] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green +- [x] `autopilot.py`: `MulticopterAutopilot(Autopilot)` — composable `HOVER [time] [alt]` + (the planned `DELIVER` was dropped as too use-case specific), `HDG`-yaws-the-nose, + fly-over route defaults; `activewp.py`: `MulticopterActiveWaypoint` fixed capture + radius +- [x] Plugin issues the four `SELECTIMPL` swaps on load; defaults restored on reset + (re-selected by the plugin's reset hook) +- [x] Integration tests: hover-hold, yaw at gs = 0, strafe (fixed nose, moving track), + leg-to-leg course capture, `HOVER` (timed, at altitude, composed with `ALT`/`LNAV`), + fixed-wing regression guard +- [x] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green ## Phase 3 — `MulticopterPerf`: electric performance from PyThrust *data* @@ -335,7 +347,7 @@ for the map interpolation against a few hand-computed points from the source CSV - Example scenario `scenarios/multicopter_delivery.scn`: create, fly a route, hover at a delivery point, yaw for "camera", return; exercises everything above. - Regenerate `docs/reference/commands.md` (`uv run minisky commands docs`) after adding the - stack commands (`MCOPT`, `YAW`, `YAWRATE`, `HOVER`, `DELIVER`, `BATT`). + stack commands (`MCOPT`, `YAW`, `YAWRATE`, `HOVER`, `BATT`). - `ruff`, `pyright`, full test suite green at every phase boundary. ### Phase 4 checklist @@ -365,7 +377,9 @@ complete; phase 1 is intentionally the only one touching `minisky/`. | Name | **multicopter** (not drone/rotorcraft) | Names the lift/control type actually modelled; scope excludes helicopters (EC35) | | Where behaviour lives | Plugin + replaceable subclasses | Matches "minimal core, hack from outside"; hot-swappable via `SELECTIMPL`; reverts on reset | | Kinematics override mechanism | New first-level `Kinematics` entity (Phase 1) | `Traffic` itself can't be hot-swapped (root object); post-hoc plugin-hook correction would double-integrate state | -| Custom autopilot | Thin `MulticopterAutopilot` for mission primitives (HOVER/DELIVER), capture-radius clamp and fly-over defaults only | LNAV's track output already suits decoupled kinematics; no guidance rewrite needed | +| Custom autopilot | Thin `MulticopterAutopilot` for the hover primitive, HDG semantics and fly-over defaults only | LNAV's track output already suits decoupled kinematics; no guidance rewrite needed | +| Mission primitive | One composable `HOVER acid [time] [alt]`; no `DELIVER` | Delivery choreography belongs in scenarios (`HOVER` + `ALT` + `LNAV ON`); keeps the primitive abstract | +| Capture radius | `MulticopterActiveWaypoint` subclass (fourth swap) | `ActiveWaypoint.reached()` recomputes `turndist` every step, so clamping it from the autopilot is overwritten before use | | Membership predicate | Plugin-owned typecode set + `ismulticopter` array | `LIFT_ROTOR` includes helicopters | | PyThrust | Data only, vendored with attribution; self-contained gen script; nothing at runtime | Prop CSVs already tabulate thrust & power; keeps dependency tree untouched (Apache 2.0 permits) | | Perf evaluation | Precomputed per-type maps, vectorised interp | Keeps the numpy discipline; fleet-size independent; regen convention already exists in repo | diff --git a/example_plugins/multicopter/activewp.py b/example_plugins/multicopter/activewp.py new file mode 100644 index 0000000..dab25cd --- /dev/null +++ b/example_plugins/multicopter/activewp.py @@ -0,0 +1,70 @@ +"""Multicopter waypoint capture. + +The stock waypoint-switching criterion turns at a distance derived from the +bank-angle turn radius, which degenerates at multicopter speeds: it shrinks +to nothing at creeping speeds, and a hovering aircraft sitting on top of its +waypoint would never switch at all. Multicopter rows use a fixed capture +radius instead. + +This must live in an :class:`ActiveWaypoint` subclass (selected with +``SELECTIMPL ACTIVEWAYPOINT MULTICOPTERACTIVEWAYPOINT``) because +:meth:`ActiveWaypoint.reached` recomputes ``turndist`` from the bank-angle +formula every step — clamping it from the autopilot update would be +overwritten before it is ever used. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from minisky.traffic.activewpdata import ActiveWaypoint + +from .entity import get_multicopter + +#: Waypoint capture radius for multicopters [m]. +CAPTURE_RADIUS = 10.0 + + +class MulticopterActiveWaypoint(ActiveWaypoint): + """Active-waypoint data with a fixed capture radius for multicopters.""" + + def reached( + self, + qdr: Any, + dist: np.ndarray, + flyby: np.ndarray, + flyturn: np.ndarray, + turnrad: np.ndarray, + turnhdgr: np.ndarray, + swlastwp: np.ndarray, + ) -> np.ndarray: + """Determine which aircraft have reached their active waypoint. + + Runs the base criterion for the whole fleet, then overrides the turn + distance of multicopter rows with the fixed capture radius and also + counts them as reached when within it. + + Args: + qdr: Bearing from each aircraft to its active waypoint [deg]. + dist: Distance to the active waypoint [m]. + flyby: Fly-by switch per aircraft. + flyturn: Fly-turn switch per aircraft. + turnrad: Specified turn radius [m] (<0 = not specified). + turnhdgr: Specified turn heading rate [deg/s] + (<0 = not specified). + swlastwp: Switch: active waypoint is the last waypoint. + + Returns: + ndarray: Indices of the aircraft that reached their waypoint. + """ + swreached = super().reached(qdr, dist, flyby, flyturn, turnrad, turnhdgr, swlastwp) + mc = get_multicopter(self.traffic) + if mc is None or not mc.ismulticopter.any(): + return swreached + + m = mc.ismulticopter + self.turndist[m] = CAPTURE_RADIUS + captured = np.where(m & self.traffic.swlnav & (dist < CAPTURE_RADIUS))[0] + return np.union1d(swreached, captured).astype(int) diff --git a/example_plugins/multicopter/aporasas.py b/example_plugins/multicopter/aporasas.py index a2961fa..f02353d 100644 --- a/example_plugins/multicopter/aporasas.py +++ b/example_plugins/multicopter/aporasas.py @@ -12,8 +12,12 @@ from __future__ import annotations +import numpy as np + from minisky.traffic.aporasas import APorASAS +from .entity import get_multicopter + class MulticopterAPorASAS(APorASAS): """Skip the track-to-heading coupling for multicopter rows.""" @@ -24,8 +28,14 @@ def update(self) -> None: Runs the base selection for the whole fleet and afterwards overwrites ``self.hdg`` on the multicopter rows with the commanded body heading, leaving ``self.trk`` (which the kinematics now flies) - untouched. + untouched. Where no body heading was ever commanded the nose follows + the track — without the wind-drift correction, since a multicopter + does not need to point its nose into the relative wind. """ super().update() - # TODO: self.hdg[m] = commanded body heading, falling back to - # self.trk[m] where no body heading was ever commanded. + mc = get_multicopter(self.traffic) + if mc is None or not mc.ismulticopter.any(): + return + + m = mc.ismulticopter + self.hdg[m] = np.where(mc.swselhdg[m], mc.selhdg[m], self.trk[m]) % 360.0 diff --git a/example_plugins/multicopter/autopilot.py b/example_plugins/multicopter/autopilot.py index 99409a3..0b24a5e 100644 --- a/example_plugins/multicopter/autopilot.py +++ b/example_plugins/multicopter/autopilot.py @@ -3,101 +3,193 @@ A thin subclass of the core :class:`Autopilot`: LNAV already emits a *track* command, which is exactly what the decoupled multicopter kinematics consumes, so no guidance rewrite is needed. What the stock FMS cannot -express is added here — the ``HOVER`` and ``DELIVER`` mission primitives, -a fixed waypoint capture radius (the bank- and speed-based turn distance -degenerates at creeping speeds), and fly-over route defaults. +express is added here — the ``HOVER`` primitive, rerouted ``HDG`` semantics +(nose only), and fly-over route defaults. The fixed waypoint capture radius +lives in :class:`~example_plugins.multicopter.activewp.MulticopterActiveWaypoint`. + +``HOVER`` is deliberately composable rather than a scripted manoeuvre: it +brakes to a stop and holds position, optionally at a commanded altitude, and +hands control back to the route after the optional hold time. Anything more +elaborate (a delivery profile, say) is written in the scenario from +``HOVER``, ``ALT`` and ``LNAV`` commands. Selected with ``SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT``. """ from __future__ import annotations -from collections.abc import Callable from typing import TYPE_CHECKING import numpy as np +from minisky.stack.argparser import Hdg from minisky.traffic.autopilot import Autopilot +from .entity import MULTICOPTER_TYPES, get_multicopter + if TYPE_CHECKING: + from collections.abc import Callable + from minisky.simulation import Simulation from minisky.traffic import Traffic -#: Waypoint capture radius for multicopters [m]. -CAPTURE_RADIUS = 10.0 +#: Ground speed below which a multicopter counts as stopped [m/s]. +GS_HOVER = 0.1 -# Mission states -MISSION_NONE = 0 -MISSION_HOVER = 1 -MISSION_DELIVER = 2 +#: Altitude tolerance for holding the selected hover altitude [m]. +ALT_CAPTURE = 0.5 class MulticopterAutopilot(Autopilot): - """Autopilot with multicopter mission primitives. + """Autopilot with a multicopter hover primitive. Attributes: - mission (ndarray): Current mission state (one of ``MISSION_*``). - missiontimer (ndarray): Remaining dwell time of the active mission - state [s]. - missionalt (ndarray): Target altitude of an active DELIVER [m]. - resumealt (ndarray): Altitude to climb back to after a DELIVER [m]. + swhover (ndarray): Bool switch: aircraft is in a commanded hover. + hovertimer (ndarray): Remaining hold time of an active hover [s]; + negative = hold indefinitely. + resumespd (ndarray): Selected speed to restore on resume + (CAS [m/s] or Mach [-]). + resumelnav (ndarray): LNAV switch state to restore on resume. + resumevnav (ndarray): VNAV switch state to restore on resume. + resumevnavspd (ndarray): VNAV-speed switch state to restore. """ def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) -> None: super().__init__(traffic, get_simulation) with self.settrafarrays(): - self.mission = np.array([], dtype=int) - self.missiontimer = np.array([]) - self.missionalt = np.array([]) - self.resumealt = np.array([]) + self.swhover = np.array([], dtype=bool) + self.hovertimer = np.array([]) + self.resumespd = np.array([]) + self.resumelnav = np.array([], dtype=bool) + self.resumevnav = np.array([], dtype=bool) + self.resumevnavspd = np.array([], dtype=bool) def create(self, n: int = 1) -> None: - """Seed the mission state of n newly created aircraft. + """Seed the hover state of n newly created aircraft. - New aircraft start with no mission active. + New aircraft start with no hover active; new multicopters get + fly-over waypoints by default (they fly point-to-point, without a + turn-anticipation arc). Args: n: Number of aircraft that were appended to the traffic arrays. """ super().create(n) - # TODO: mission = MISSION_NONE, timers/altitudes zeroed. + self.swhover[-n:] = False + self.hovertimer[-n:] = 0.0 + self.resumespd[-n:] = 0.0 + self.resumelnav[-n:] = False + self.resumevnav[-n:] = False + self.resumevnavspd[-n:] = False + + # Membership by typecode: the Multicopter entity may be created + # after this autopilot in the traffic tree, so its arrays cannot be + # relied upon here. + for offset, typecode in enumerate(self.traffic.typecode[-n:], start=-n): + if typecode.upper() in MULTICOPTER_TYPES: + self.route[offset].swflyby = False def update(self) -> None: - """Run the FMS, then the multicopter mission state machine. + """Run the FMS, then advance any active hovers (vectorized). - After the base autopilot update, advances any active HOVER/DELIVER - state (counting the dwell timers down and resuming the route when - they expire) and clamps the waypoint turn distance of multicopter - rows to a fixed capture radius. + A timed hover counts its hold time down only while the position is + actually held: stopped, at the selected altitude. A hover ends when + that timer expires (the saved route state is restored; the selected + altitude stays at the hover altitude) or when LNAV is re-engaged + externally (LNAV is then left as commanded). """ super().update() - # TODO: advance mission timers/state machine for multicopter rows. - # TODO: clamp actwp.turndist[m] to CAPTURE_RADIUS. + if not self.swhover.any() or get_multicopter(self.traffic) is None: + return + + traf = self.traffic + # LNAV was re-engaged externally: cancel those hovers. + cancel = self.swhover & traf.swlnav + # Timed hovers holding position and altitude: count the timer down. + holding = ( + self.swhover + & ~traf.swlnav + & (self.hovertimer >= 0.0) + & (traf.gs < GS_HOVER) + & (np.abs(traf.alt - traf.selalt) < ALT_CAPTURE) + ) + self.hovertimer = np.where(holding, self.hovertimer - self.simulation.simdt, self.hovertimer) + expired = holding & (self.hovertimer <= 0.0) + + # Restore the saved route state; expiry also re-engages LNAV/VNAV. + resume = cancel | expired + traf.selspd = np.where(resume, self.resumespd, traf.selspd) + traf.swvnavspd = np.where(resume, self.resumevnavspd, traf.swvnavspd) + traf.swvnav = np.where(cancel, self.resumevnav, traf.swvnav) + traf.swlnav = np.where(expired, self.resumelnav, traf.swlnav) + traf.swvnav = np.where(expired, self.resumevnav & self.resumelnav, traf.swvnav) + self.swhover = self.swhover & ~resume + + def selhdgcmd(self, idx: int, hdg: Hdg) -> tuple[bool, str]: + """Select the autopilot heading; for multicopters, yaw the nose only. + + For a multicopter row the HDG stack command is an alias of ``YAW``: + it rotates the body without touching the track, and LNAV stays + engaged — the velocity vector keeps following the FMS or conflict + resolution. Other aircraft keep the stock behaviour. - def hover(self, idx: int, duration: float | None = None) -> tuple[bool, str]: - """Hold position, optionally for a fixed duration. - - Suspends LNAV and commands zero ground speed. With a duration, the - route resumes automatically once it has elapsed; without one, the - aircraft hovers until LNAV is re-engaged. + Args: + idx: Aircraft index. + hdg: Selected heading [deg]. - Arguments: - - idx: Aircraft callsign - - duration: Hold time [s] (optional, omit to hover indefinitely) + Returns: + tuple: (success flag, confirmation message). """ - # TODO: enter MISSION_HOVER for idx - return False, "HOVER: not implemented yet" - - def deliver(self, idx: int, alt: float, dwell: float | None = None) -> tuple[bool, str]: - """Descend vertically to an altitude, dwell, climb back, resume route. - - The horizontal position is held throughout, so lat/lon are unchanged - for the whole manoeuvre. + mc = get_multicopter(self.traffic) + if mc is not None and mc.ismulticopter[idx]: + return mc.yaw(idx, float(hdg)) + return super().selhdgcmd(idx, hdg) + + def hover( + self, idx: int, duration: float | None = None, alt: float | None = None + ) -> tuple[bool, str]: + """Hold position, optionally for a fixed time at a given altitude. + + Suspends LNAV/VNAV, commands zero ground speed, and holds the given + altitude (the current one when omitted) — with an altitude the + aircraft moves there vertically, at a fixed position. With a + duration, the route resumes once position and altitude have been + held that long; without one, the aircraft hovers until LNAV is + re-engaged. Repeating the command while hovering updates the hold + time and altitude, and a plain ALT command changes the hover + altitude as well. Arguments: - idx: Aircraft callsign - - alt: Delivery altitude [ft or FL] - - dwell: Time spent at the delivery altitude [s] (optional) + - duration: Hold time [s] (optional, omit to hover indefinitely) + - alt: Hover altitude [ft or FL] (optional, default: hold current) """ - # TODO: enter MISSION_DELIVER for idx - return False, "DELIVER: not implemented yet" + callsign = self.traffic.callsign[idx] + mc = get_multicopter(self.traffic) + if mc is None or not mc.ismulticopter[idx]: + return False, f"HOVER: {callsign} is not a multicopter (use MCOPT {callsign} ON)" + + if not self.swhover[idx]: + # Entering the hover: save the route state to resume later. + self._suspend_route(idx) + self.swhover[idx] = True + if alt is not None: + self.selaltcmd(idx, alt) + self.hovertimer[idx] = -1.0 if duration is None else duration + + if duration is None: + return True, f"HOVER {callsign}: holding position (resume with LNAV {callsign} ON)" + return True, f"HOVER {callsign}: holding position for {duration:.0f} s" + + def _suspend_route(self, idx: int) -> None: + """Save the route state of one aircraft and command a hover.""" + traf = self.traffic + self.resumelnav[idx] = traf.swlnav[idx] + self.resumevnav[idx] = traf.swvnav[idx] + self.resumevnavspd[idx] = traf.swvnavspd[idx] + self.resumespd[idx] = traf.selspd[idx] + traf.swlnav[idx] = False + traf.swvnav[idx] = False + traf.swvnavspd[idx] = False + traf.selspd[idx] = 0.0 + traf.selalt[idx] = traf.alt[idx] diff --git a/example_plugins/multicopter/entity.py b/example_plugins/multicopter/entity.py index cd1a39d..54b67e8 100644 --- a/example_plugins/multicopter/entity.py +++ b/example_plugins/multicopter/entity.py @@ -29,6 +29,20 @@ DEFAULT_YAWRATE = 90.0 +def get_multicopter(traffic: Traffic) -> Multicopter | None: + """Return the Multicopter entity attached to a traffic tree, if any. + + The entity is created by ``init_plugin()`` as a child node of ``traffic``. + The replaceable subclasses use this lookup so that, when one of them is + selected without the plugin loaded, they degrade to base behaviour + instead of crashing. + """ + return next( + (child for child in traffic._children if isinstance(child, Multicopter)), + None, + ) + + class Multicopter(plugin.Entity): """Per-aircraft multicopter state. @@ -59,8 +73,12 @@ def create(self, n: int = 1) -> None: n: Number of aircraft that were appended to the traffic arrays. """ super().create(n) - # TODO: set ismulticopter from self.traffic.typecode[-n:], seed - # selhdg from the current heading, swselhdg False, yawrate default. + self.ismulticopter[-n:] = [ + typecode.upper() in MULTICOPTER_TYPES for typecode in self.traffic.typecode[-n:] + ] + self.selhdg[-n:] = self.traffic.hdg[-n:] + self.swselhdg[-n:] = False + self.yawrate[-n:] = DEFAULT_YAWRATE def mask(self) -> np.ndarray: """Return the boolean row mask of aircraft flown as multicopters.""" @@ -74,8 +92,19 @@ def mcopt(self, idx: int, flag: bool | None = None) -> tuple[bool, str]: - flag: ON to fly it as a multicopter, OFF for normal fixed-wing kinematics (optional, omit to query) """ - # TODO: report or set self.ismulticopter[idx] - return False, "MCOPT: not implemented yet" + callsign = self.traffic.callsign[idx] + if flag is None: + return True, f"MCOPT {callsign}: {'ON' if self.ismulticopter[idx] else 'OFF'}" + + self.ismulticopter[idx] = flag + # Multicopters fly point-to-point: newly added waypoints default to + # fly-over (restored to fly-by when switched back off). + self.traffic.ap.route[idx].swflyby = not flag + if flag: + # Start with the nose unconstrained, following the track. + self.selhdg[idx] = self.traffic.hdg[idx] + self.swselhdg[idx] = False + return True, f"MCOPT {callsign}: {'ON' if flag else 'OFF'}" def yaw(self, idx: int, hdg: float) -> tuple[bool, str]: """Command the body heading (nose direction) of a multicopter. @@ -87,8 +116,13 @@ def yaw(self, idx: int, hdg: float) -> tuple[bool, str]: - idx: Aircraft callsign - hdg: Commanded body heading [deg] """ - # TODO: set self.selhdg[idx] / self.swselhdg[idx] - return False, "YAW: not implemented yet" + if not self.ismulticopter[idx]: + callsign = self.traffic.callsign[idx] + return False, f"YAW: {callsign} is not a multicopter (use MCOPT {callsign} ON)" + + self.selhdg[idx] = hdg % 360.0 + self.swselhdg[idx] = True + return True, f"YAW {self.traffic.callsign[idx]}: nose to {hdg % 360.0:.0f} deg" def setyawrate(self, idx: int, yawrate: float | None = None) -> tuple[bool, str]: """Set or report the maximum yaw rate of a multicopter. @@ -97,5 +131,11 @@ def setyawrate(self, idx: int, yawrate: float | None = None) -> tuple[bool, str] - idx: Aircraft callsign - yawrate: Maximum yaw rate [deg/s] (optional, omit to query) """ - # TODO: report or set self.yawrate[idx] - return False, "YAWRATE: not implemented yet" + callsign = self.traffic.callsign[idx] + if yawrate is None: + return True, f"YAWRATE {callsign}: {self.yawrate[idx]:.0f} deg/s" + if yawrate <= 0.0: + return False, "YAWRATE: yaw rate must be positive" + + self.yawrate[idx] = yawrate + return True, f"YAWRATE {callsign}: {yawrate:.0f} deg/s" diff --git a/example_plugins/multicopter/kinematics.py b/example_plugins/multicopter/kinematics.py index a6eb975..3f61c83 100644 --- a/example_plugins/multicopter/kinematics.py +++ b/example_plugins/multicopter/kinematics.py @@ -12,8 +12,13 @@ from __future__ import annotations +import numpy as np + +from minisky.tools.aero import ft from minisky.traffic.kinematics import Kinematics +from .entity import get_multicopter + class MulticopterKinematics(Kinematics): """Yaw-rate-limited, track-driven integration for multicopter rows. @@ -31,9 +36,27 @@ def update_airspeed(self) -> None: the heading of multicopter rows at their yaw rate instead of the bank-angle turn rate (which explodes as TAS approaches zero). """ + traf = self.traffic + mc = get_multicopter(traf) + if mc is None or not mc.ismulticopter.any(): + super().update_airspeed() + return + + m = mc.ismulticopter + hdg0 = traf.hdg[m] # fancy indexing copies: heading before this step super().update_airspeed() - # TODO: slew traf.hdg[m] towards the commanded body heading, - # clipped to yawrate * simdt. + + # Yaw at a fixed rate towards the desired body heading, from the + # pre-update heading (the base class snapped it, because its + # bank-angle turn rate goes to infinity at tas -> 0). + simdt = self._get_simulation().simdt + delhdg = (traf.aporasas.hdg[m] - hdg0 + 180.0) % 360.0 - 180.0 + maxdel = mc.yawrate[m] * simdt + turning = np.abs(delhdg) > maxdel + traf.hdg[m] = ( + np.where(turning, hdg0 + np.sign(delhdg) * maxdel, traf.aporasas.hdg[m]) % 360.0 + ) + self.swhdgsel[m] = turning def update_groundspeed(self) -> None: """Compute ground speed and track from the velocity vector. @@ -41,8 +64,32 @@ def update_groundspeed(self) -> None: Runs the base implementation for the whole fleet, then rebuilds the ground-speed components of multicopter rows from the *commanded track* (``traf.aporasas.trk``) plus wind, and derives ``gs``/``trk`` - from them. + from them: thrust is redirected without rotating the body, and + course changes have no turn radius. """ + traf = self.traffic super().update_groundspeed() - # TODO: recompute traf.gsnorth/gseast/gs/trk for the multicopter - # rows from aporasas.trk instead of traf.hdg. + mc = get_multicopter(traf) + if mc is None or not mc.ismulticopter.any(): + return + + # Note: the base class already accumulated traf.work from its + # heading-driven gs; without wind the magnitudes are identical, and + # with wind the difference is negligible for the energy bookkeeping. + m = mc.ismulticopter + trkcmd = np.radians(traf.aporasas.trk) + airborne = traf.alt > 50.0 * ft # windnorth/east are zero without wind + traf.gsnorth[m] = (traf.tas * np.cos(trkcmd) + traf.windnorth * airborne)[m] + traf.gseast[m] = (traf.tas * np.sin(trkcmd) + traf.windeast * airborne)[m] + # In the no-wind branch the base class aliases traf.gs to traf.tas + # and traf.trk to traf.hdg (plain assignment of the same ndarray), so + # writing them in place would corrupt tas and hdg. Rebuild instead. + gs = np.hypot(traf.gsnorth, traf.gseast) + traf.gs = np.where(m, gs, traf.gs) + # The track angle is undefined at hover; hold the commanded track. + trk = np.where( + gs > 0.01, + np.degrees(np.arctan2(traf.gseast, traf.gsnorth)) % 360.0, + traf.aporasas.trk % 360.0, + ) + traf.trk = np.where(m, trk, traf.trk) diff --git a/example_plugins/multicopter/plugin.py b/example_plugins/multicopter/plugin.py index 7bd0685..b79e152 100644 --- a/example_plugins/multicopter/plugin.py +++ b/example_plugins/multicopter/plugin.py @@ -8,41 +8,42 @@ Everything is implemented as replaceable subclasses of core entities, which the plugin selects on load: -- ``KINEMATICS`` -> :class:`MulticopterKinematics` — yaw-rate-limited +- ``KINEMATICS`` -> :class:`MulticopterKinematics` — yaw-rate-limited heading, track-driven velocity vector. -- ``APORASAS`` -> :class:`MulticopterAPorASAS` — no track-to-heading +- ``APORASAS`` -> :class:`MulticopterAPorASAS` — no track-to-heading coupling for multicopter rows. -- ``AUTOPILOT`` -> :class:`MulticopterAutopilot` — HOVER/DELIVER mission - primitives and a fixed waypoint capture radius. -- ``OPENAP`` -> :class:`MulticopterPerf` — electric performance: power - from a propeller/motor map, battery state of charge, sagging envelope. +- ``AUTOPILOT`` -> :class:`MulticopterAutopilot` — HOVER primitive, + HDG-yaws-the-nose semantics, fly-over route defaults. +- ``ACTIVEWAYPOINT`` -> :class:`MulticopterActiveWaypoint` — fixed waypoint + capture radius (the bank-angle turn distance degenerates at hover speeds). Fixed-wing aircraft in the same simulation are untouched: every override calls ``super()`` and adjusts only the multicopter rows. Helicopters are out of scope — membership is a typecode set, not ``LIFT_ROTOR``. -Stack commands: MCOPT, YAW, YAWRATE, HOVER, DELIVER, BATT. +Stack commands: MCOPT, YAW, YAWRATE, HOVER. """ from __future__ import annotations from typing import TYPE_CHECKING, Any +from .activewp import MulticopterActiveWaypoint from .aporasas import MulticopterAPorASAS from .autopilot import MulticopterAutopilot from .entity import Multicopter from .kinematics import MulticopterKinematics -from .perf import MulticopterPerf if TYPE_CHECKING: from minisky import MiniSky + from minisky.traffic import Traffic #: Replaceable base -> multicopter implementation, selected on load and reset. IMPLEMENTATIONS = ( ("KINEMATICS", MulticopterKinematics), ("APORASAS", MulticopterAPorASAS), ("AUTOPILOT", MulticopterAutopilot), - ("OPENAP", MulticopterPerf), + ("ACTIVEWAYPOINT", MulticopterActiveWaypoint), ) @@ -60,11 +61,7 @@ def init_plugin(runtime: MiniSky) -> tuple[dict[str, Any], dict[str, list[Any]]] """ mc = Multicopter(runtime.traffic) _select_implementations(runtime) - - # The instances just selected onto traf; commands bind to these - ap = runtime.traffic.ap - perf = runtime.traffic.perf - if not isinstance(ap, MulticopterAutopilot) or not isinstance(perf, MulticopterPerf): + if not isinstance(runtime.traffic.ap, MulticopterAutopilot): raise RuntimeError("MULTICOPTER: could not select the multicopter implementations") config = { @@ -72,7 +69,7 @@ def init_plugin(runtime: MiniSky) -> tuple[dict[str, Any], dict[str, list[Any]]] "reset": lambda: _select_implementations(runtime), "state": mc, } - return config, _stack_functions(mc, ap, perf) + return config, _stack_functions(runtime.traffic, mc) def _select_implementations(runtime: MiniSky) -> None: @@ -87,17 +84,30 @@ def _select_implementations(runtime: MiniSky) -> None: runtime.replaceables.select(basename, impl.__name__) -def _stack_functions( - mc: Multicopter, ap: MulticopterAutopilot, perf: MulticopterPerf -) -> dict[str, list[Any]]: +def _stack_functions(traffic: Traffic, mc: Multicopter) -> dict[str, list[Any]]: """Build the plugin's stack-command table. - Bound to the freshly selected instances; a later SELECTIMPL swap rebinds - them to the new instance. Argument specifications are given explicitly, - so they override the plain Python annotations on the callbacks. + HOVER is a free function that looks up ``traffic.ap`` at call time + rather than a bound method: a reset replaces the autopilot instance + twice (revert to base, then the reset hook reselects), and the command + rebinding cannot follow methods that only exist on the subclass across + that double swap. The entity commands bind to ``mc`` directly, which + lives for the whole plugin lifetime. + + Argument specifications are given explicitly, so they override the plain + Python annotations on the callbacks. """ + + def hover( + idx: int, duration: float | None = None, alt: float | None = None + ) -> tuple[bool, str]: + ap = traffic.ap + if not isinstance(ap, MulticopterAutopilot): + return False, "HOVER: SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT first" + return ap.hover(idx, duration, alt) + return { - "MCOPT": [mc.mcopt, "callsign,[onoff]", "MCOPT callsign,[onoff]", mc.mcopt.__doc__], + "MCOPT": [mc.mcopt, "callsign,[onoff]", "MCOPT callsign,[ON/OFF]", mc.mcopt.__doc__], "YAW": [mc.yaw, "callsign,hdg", "YAW callsign,hdg", mc.yaw.__doc__], "YAWRATE": [ mc.setyawrate, @@ -105,12 +115,10 @@ def _stack_functions( "YAWRATE callsign,[rate]", mc.setyawrate.__doc__, ], - "HOVER": [ap.hover, "callsign,[time]", "HOVER callsign,[time]", ap.hover.__doc__], - "DELIVER": [ - ap.deliver, - "callsign,alt,[time]", - "DELIVER callsign,alt,[time]", - ap.deliver.__doc__, + "HOVER": [ + hover, + "callsign,[time,alt]", + "HOVER callsign,[time,alt]", + MulticopterAutopilot.hover.__doc__, ], - "BATT": [perf.batt, "callsign", "BATT callsign", perf.batt.__doc__], } diff --git a/tests/integration/test_multicopter.py b/tests/integration/test_multicopter.py new file mode 100644 index 0000000..9d2ac45 --- /dev/null +++ b/tests/integration/test_multicopter.py @@ -0,0 +1,292 @@ +"""Integration tests for the MULTICOPTER plugin (Phase 2). + +Driven through the stack, like test_stack.py. The plugin swaps replaceable +implementations on load and re-selects them from its reset hook, so these +tests run on their own runtime instead of the shared session runtime — the +other integration tests keep the core implementations. + +The default simulation timestep is 1 s; yaw rates are lowered where a slew +must be observable across steps. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator + +import pytest + +from example_plugins.multicopter.autopilot import MulticopterAutopilot +from minisky import MiniSky +from minisky.core.settings import DEFAULT_SETTINGS_FILE, MiniSkySettings +from minisky.simulation import Simulation +from minisky.tools.aero import ft +from tests._types import RunCommand, StepUntil + + +@pytest.fixture(scope="module") +def mcruntime() -> Iterator[MiniSky]: + """Module-wide MiniSky runtime with the MULTICOPTER plugin loaded.""" + instance = MiniSky(MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE)) + ok, message = instance.plugins.load("MULTICOPTER") + assert ok, message + yield instance + instance.close() + + +@pytest.fixture +def mcsim(mcruntime: MiniSky) -> Simulation: + """Fresh simulation state; the plugin reset hook re-selects the impls.""" + mcruntime.simulation.reset() + mcruntime.console.read_output_buffer() # drain "Simulation reset" echo + return mcruntime.simulation + + +@pytest.fixture +def run_mc(mcruntime: MiniSky, mcsim: Simulation) -> RunCommand: + """Queue a stack command, step the sim, and return the last echoed output.""" + + def _run(cmd: str, steps: int = 1) -> str: + mcruntime.commands.stack(cmd) + for _ in range(steps): + mcruntime.simulation.step() + return mcruntime.console.read_output_buffer() + + return _run + + +@pytest.fixture +def step_mc(mcruntime: MiniSky) -> StepUntil: + """Step the simulation until a predicate holds, failing after max_steps.""" + + def _step(pred: Callable[[], bool], max_steps: int = 600) -> int: + for i in range(max_steps): + mcruntime.simulation.step() + if pred(): + return i + pytest.fail(f"condition not met within {max_steps} simulation steps") + + return _step + + +class TestPluginWiring: + def test_implementations_selected_after_reset( + self, mcruntime: MiniSky, mcsim: Simulation + ) -> None: + # the reset in the fixture reverted to base; the reset hook reselects + assert type(mcruntime.traffic.kinematics).__name__ == "MulticopterKinematics" + assert type(mcruntime.traffic.aporasas).__name__ == "MulticopterAPorASAS" + assert type(mcruntime.traffic.ap).__name__ == "MulticopterAutopilot" + assert type(mcruntime.traffic.actwp).__name__ == "MulticopterActiveWaypoint" + + def test_membership_from_typecode(self, mcruntime: MiniSky, run_mc: RunCommand) -> None: + run_mc("CRE D1,MAVIC,52,4,90,100,20") + run_mc("CRE KL001,A320,53,4,90,FL100,250") + assert "ON" in run_mc("MCOPT D1") + assert "OFF" in run_mc("MCOPT KL001") + + def test_mcopt_overrides_membership(self, mcruntime: MiniSky, run_mc: RunCommand) -> None: + run_mc("CRE KL001,A320,53,4,90,FL100,250") + run_mc("MCOPT KL001 ON") + assert "ON" in run_mc("MCOPT KL001") + + def test_yaw_rejects_non_multicopter(self, mcruntime: MiniSky, run_mc: RunCommand) -> None: + run_mc("CRE KL001,A320,53,4,90,FL100,250") + assert "not a multicopter" in run_mc("YAW KL001 90") + assert "not a multicopter" in run_mc("HOVER KL001") + + def test_yawrate_set_and_report(self, mcruntime: MiniSky, run_mc: RunCommand) -> None: + run_mc("CRE D1,MAVIC,52,4,90,100,20") + run_mc("YAWRATE D1 45") + assert "45" in run_mc("YAWRATE D1") + + +class TestHoverAndYaw: + def test_spd_zero_holds_position( + self, mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil + ) -> None: + traf = mcruntime.traffic + run_mc("CRE D1,MAVIC,52,4,90,100,20") + run_mc("SPD D1 0") + step_mc(lambda: traf.gs[0] == 0.0, 20) + + lat0, lon0 = float(traf.lat[0]), float(traf.lon[0]) + for _ in range(30): + mcruntime.simulation.step() + assert traf.gs[0] == 0.0 + assert float(traf.lat[0]) == lat0 + assert float(traf.lon[0]) == lon0 + + def test_yaw_at_hover_slews_at_yawrate( + self, mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil + ) -> None: + traf = mcruntime.traffic + run_mc("CRE D1,MAVIC,52,4,0,100,20") + run_mc("SPD D1 0") + step_mc(lambda: traf.gs[0] == 0.0, 20) + run_mc("YAWRATE D1 10") + lat0, lon0 = float(traf.lat[0]), float(traf.lon[0]) + + # HDG on a multicopter yaws the nose, rate-limited (not instant) + run_mc("HDG D1 90") + assert 5.0 < traf.hdg[0] < 15.0 + step_mc(lambda: abs(traf.hdg[0] - 90.0) < 0.1, 20) + + # rotated in place: no translation while hovering + assert traf.gs[0] == 0.0 + assert float(traf.lat[0]) == lat0 + assert float(traf.lon[0]) == lon0 + + def test_strafe_decouples_track_and_heading( + self, mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil + ) -> None: + traf = mcruntime.traffic + run_mc("CRE D1,MAVIC,52,4,90,100,20") + run_mc("YAWRATE D1 30") + lon0 = float(traf.lon[0]) + + # nose to north while the velocity vector keeps flying east + run_mc("YAW D1 0") + step_mc(lambda: abs(traf.hdg[0]) < 0.1 or abs(traf.hdg[0] - 360.0) < 0.1, 10) + assert abs(traf.trk[0] - 90.0) < 0.1 + assert traf.gs[0] > 5.0 + assert float(traf.lon[0]) > lon0 + + +class TestRouteFollowing: + def test_leg_to_leg_course_capture( + self, mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil + ) -> None: + traf = mcruntime.traffic + run_mc("CRE D1,MAVIC,52,4,90,100,30") + run_mc("ADDWPT D1 52,4.005") + run_mc("ADDWPT D1 52.005,4.005") + run_mc("LNAV D1 ON") + + # fly-over route default for multicopters + assert traf.ap.route[0].swflyby is False + + # reach the corner waypoint: active waypoint switches to the second + step_mc(lambda: traf.actwp.lat[0] > 52.004, 60) + lonmax = float(traf.lon[0]) + + # course snaps to the new leg with no turn-anticipation overshoot arc + mcruntime.simulation.step() + mcruntime.simulation.step() + assert min(traf.trk[0], 360.0 - traf.trk[0]) < 2.0 # northbound + # never went further east than the corner + capture radius + one step + assert lonmax < 4.005 + 0.0005 + step_mc(lambda: traf.lat[0] > 52.001, 30) + assert abs(float(traf.lon[0]) - lonmax) < 0.0005 + + def test_hover_mission_freezes_position_then_resumes( + self, mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil + ) -> None: + traf = mcruntime.traffic + run_mc("CRE D1,MAVIC,52,4,90,100,30") + run_mc("ADDWPT D1 52,4.02") + run_mc("LNAV D1 ON") + step_mc(lambda: traf.gs[0] > 5.0, 10) + + run_mc("HOVER D1 15") + assert not traf.swlnav[0] + step_mc(lambda: traf.gs[0] == 0.0, 20) + + # position frozen while the hold timer runs + lat0, lon0 = float(traf.lat[0]), float(traf.lon[0]) + for _ in range(10): + mcruntime.simulation.step() + assert float(traf.lat[0]) == lat0 + assert float(traf.lon[0]) == lon0 + + # after 15 s of held position the route resumes + step_mc(lambda: bool(traf.swlnav[0]), 30) + step_mc(lambda: traf.gs[0] > 5.0, 20) + + def test_hover_at_altitude_descends_in_place_and_resumes( + self, mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil + ) -> None: + traf = mcruntime.traffic + ap = traf.ap + assert isinstance(ap, MulticopterAutopilot) + run_mc("CRE D1,MAVIC,52,4,90,400,20") + run_mc("ADDWPT D1 52,4.02") + run_mc("LNAV D1 ON") + step_mc(lambda: traf.gs[0] > 5.0, 10) + + # hold 5 s at 100 ft: brakes to a hover, then moves vertically + run_mc("HOVER D1 5 100") + step_mc(lambda: traf.gs[0] == 0.0, 20) + lat0, lon0 = float(traf.lat[0]), float(traf.lon[0]) + + step_mc(lambda: abs(traf.alt[0] - 100.0 * ft) < 0.5, 120) + assert float(traf.lat[0]) == lat0 + assert float(traf.lon[0]) == lon0 + + # after 5 s held at altitude, the route resumes at the hover altitude + step_mc(lambda: not ap.swhover[0], 30) + assert abs(traf.alt[0] - 100.0 * ft) < 0.5 + assert float(traf.lat[0]) == lat0 + assert float(traf.lon[0]) == lon0 + assert traf.swlnav[0] + step_mc(lambda: traf.gs[0] > 5.0, 20) + + def test_hover_composes_with_alt_and_lnav( + self, mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil + ) -> None: + """A delivery profile written in scenario commands: hover, ALT down, + ALT back up, LNAV ON to resume.""" + traf = mcruntime.traffic + run_mc("CRE D1,MAVIC,52,4,90,400,20") + run_mc("ADDWPT D1 52,4.02") + run_mc("LNAV D1 ON") + step_mc(lambda: traf.gs[0] > 5.0, 10) + + run_mc("HOVER D1") # indefinite + step_mc(lambda: traf.gs[0] == 0.0, 20) + lat0, lon0 = float(traf.lat[0]), float(traf.lon[0]) + + run_mc("ALT D1 100") # plain ALT works inside a hover + step_mc(lambda: abs(traf.alt[0] - 100.0 * ft) < 0.5, 120) + run_mc("ALT D1 400") + step_mc(lambda: abs(traf.alt[0] - 400.0 * ft) < 0.5, 120) + assert traf.gs[0] == 0.0 + assert float(traf.lat[0]) == lat0 + assert float(traf.lon[0]) == lon0 + + run_mc("LNAV D1 ON") # cancels the indefinite hover + step_mc(lambda: traf.gs[0] > 5.0, 20) + + +class TestFixedWingRegression: + def test_fixed_wing_unaffected_by_plugin( + self, + mcruntime: MiniSky, + mcsim: Simulation, + runtime: MiniSky, + sim: Simulation, + ) -> None: + """An A320 flies identically with and without the plugin loaded. + + The reference runtime is the shared session runtime (core + implementations); the plugin runtime also carries a hovering + multicopter to exercise the fleet-wide override paths. + """ + commands = [ + "CRE KL001,A320,52,4,90,FL100,250", + "ALT KL001 FL120", + "SPD KL001 280", + ] + mcruntime.commands.stack("CRE D2,MAVIC,52.1,4,90,100,20") + mcruntime.commands.stack("SPD D2 0") + for cmd in commands: + mcruntime.commands.stack(cmd) + runtime.commands.stack(cmd) + + for _ in range(60): + mcruntime.simulation.step() + runtime.simulation.step() + + i = mcruntime.traffic.idx("KL001") + j = runtime.traffic.idx("KL001") + for name in ("lat", "lon", "alt", "hdg", "trk", "tas", "gs", "vs"): + assert getattr(mcruntime.traffic, name)[i] == getattr(runtime.traffic, name)[j], name From fdc2f9597519549ed62bb57973ccd229613db894 Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:39:48 +0200 Subject: [PATCH 11/16] refactor(multicopter): port plugin to the packaged plugin API Move example_plugins/multicopter to the minisky-multicopter workspace package with a minisky.plugins entry point. Commands, hooks, and replacements now use the plugin declaration API, and the entity keeps the multicopter implementations selected on load and after reset. Register Kinematics, APorASAS, and ActiveWaypoint as replaceable bases in the runtime, and register test replacements runtime-locally. --- docs/multicopter-plan.md | 98 ++++++++------ example_plugins/multicopter/plugin.py | 124 ------------------ packages/minisky-multicopter/pyproject.toml | 21 +++ .../src/minisky_multicopter/__init__.py | 66 ++++++++++ .../src/minisky_multicopter}/activewp.py | 5 +- .../src/minisky_multicopter}/aporasas.py | 5 +- .../src/minisky_multicopter}/autopilot.py | 35 ++--- .../src/minisky_multicopter}/entity.py | 90 ++++++++++++- .../src/minisky_multicopter}/kinematics.py | 5 +- .../src/minisky_multicopter}/perf.py | 10 +- packages/minisky/minisky/runtime.py | 13 +- pyproject.toml | 4 +- tests/integration/test_kinematics.py | 31 +++-- tests/integration/test_multicopter.py | 27 ++-- uv.lock | 17 +++ 15 files changed, 328 insertions(+), 223 deletions(-) delete mode 100644 example_plugins/multicopter/plugin.py create mode 100644 packages/minisky-multicopter/pyproject.toml create mode 100644 packages/minisky-multicopter/src/minisky_multicopter/__init__.py rename {example_plugins/multicopter => packages/minisky-multicopter/src/minisky_multicopter}/activewp.py (95%) rename {example_plugins/multicopter => packages/minisky-multicopter/src/minisky_multicopter}/aporasas.py (92%) rename {example_plugins/multicopter => packages/minisky-multicopter/src/minisky_multicopter}/autopilot.py (88%) rename {example_plugins/multicopter => packages/minisky-multicopter/src/minisky_multicopter}/entity.py (58%) rename {example_plugins/multicopter => packages/minisky-multicopter/src/minisky_multicopter}/kinematics.py (97%) rename {example_plugins/multicopter => packages/minisky-multicopter/src/minisky_multicopter}/perf.py (93%) diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md index a24c957..a0fb21b 100644 --- a/docs/multicopter-plan.md +++ b/docs/multicopter-plan.md @@ -40,14 +40,16 @@ The exploration that produced this plan found MiniSky closer to multicopter-read - **Zero speed already passes the performance clamp.** Rotor envelopes have *negative* `vmin` (e.g. M600: −18 m/s), and `OpenAP.limits()` clamps rotor TAS directly against `[vmin, vmax]`, so `SPD D1 0` survives. Fixed-wing aircraft are clamped to stall speed and cannot do this. -- **The replaceable pattern.** Every first-level `TrafficArrays` subclass auto-registers for - `SELECTIMPL` (`minisky/core/trafficarrays.py`), and `_replace_instance_on_traf()` hot-swaps the - instance on `traf`, carrying per-aircraft arrays over and rebinding stack commands. `Autopilot`, - `OpenAP`, `APorASAS`, `ConflictDetection`, `ConflictResolution` are all swappable today. - `example_plugins/customautopilot.py` demonstrates the pattern. -- **Plugin machinery.** Timed `preupdate`/`update`/`reset` hooks, `plugin.Entity` + - `settrafarrays()` for per-aircraft state that grows/shrinks with the fleet, and - `@stack.command` for new commands. +- **The replaceable pattern.** `ReplaceableManager` (`minisky/core/trafficarrays.py`) owns a + curated set of replaceable bases per runtime (registered in `MiniSky.__init__`) and hot-swaps + the instance on `traf` via `SELECTIMPL`, carrying per-aircraft arrays over and dispatching + stack commands through the current instance. Plugins register implementations runtime-locally + with `@plugin.replacement` + `context.finish(replacements=...)`; + `packages/minisky-example-customautopilot` demonstrates the pattern. +- **Plugin machinery.** Plugins are packages exposing a `Plugin` declaration through the + `minisky.plugins` entry-point group. Timed `preupdate`/`update`/`reset` hooks via + `@plugin.hook`, `plugin.Entity` + `settrafarrays()` for per-aircraft state that grows/shrinks + with the fleet, and `@plugin.command` for new commands. ## What blocks the two manoeuvring behaviours @@ -95,7 +97,8 @@ class Kinematics(TrafficArrays): - Instantiated as `self.kinematics = Kinematics()` inside `Traffic.__init__`'s `settrafarrays()` block; `Traffic.update()` calls `self.kinematics.update()` in place of the three method calls. -- Because it is a first-level `TrafficArrays` subclass it **auto-registers** as replaceable — +- Registered as a replaceable base in the runtime's `ReplaceableManager` (`minisky/runtime.py`, + together with `APorASAS` and `ActiveWaypoint`, which Phase 2 also swaps) — `SELECTIMPL KINEMATICS MULTICOPTERKINEMATICS` then hot-swaps mid-simulation exactly like the custom-autopilot example, with no further core support needed. - Keep thin delegating properties on `Traffic` only if anything external reads `traf.ax` etc. @@ -118,34 +121,41 @@ reset (mirror the existing `tests/integration/test_plugin.py` replaceable test). `settrafarrays()` block; `Traffic.update()` calls `self.kinematics.update()` - [x] Grep external readers of the moved arrays: only `perfoap.py` reads `ax` (`streaming.py` does not); pointed it at `traf.kinematics.ax` (no property needed) -- [x] Verify `SELECTIMPL KINEMATICS` lists the base implementation -- [x] Test: register a trivial subclass, select it, verify it takes effect and reverts on reset - (`tests/integration/test_kinematics.py`) +- [x] Register `Kinematics` (plus `APorASAS` and `ActiveWaypoint` for Phase 2) as replaceable + bases in `MiniSky.__init__`; verify `SELECTIMPL KINEMATICS` lists the base implementation +- [x] Test: install a trivial subclass runtime-locally, select it, verify it takes effect and + reverts on reset (`tests/integration/test_kinematics.py`) - [x] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green ## Phase 2 — the `multicopter` plugin: membership + kinematics -New package `example_plugins/multicopter/` (plugin name `MULTICOPTER`), no core changes. One -module per class, so each piece stays small and readable: +New workspace package `packages/minisky-multicopter/` (plugin ID `multicopter`), no core changes +beyond the Phase 1 base registration. One module per class, so each piece stays small and +readable: ``` -example_plugins/multicopter/ -├── plugin.py # init_plugin(): plugin config, SELECTIMPL swaps on load, reset handling -├── entity.py # MULTICOPTER_TYPES + Multicopter Entity (ismulticopter, selhdg, yawrate) -│ # and its stack commands: MCOPT, YAW, YAWRATE -├── kinematics.py # MulticopterKinematics(Kinematics) -├── aporasas.py # MulticopterAPorASAS(APorASAS) -├── autopilot.py # MulticopterAutopilot(Autopilot): HOVER, fly-over route defaults -├── activewp.py # MulticopterActiveWaypoint(ActiveWaypoint): fixed capture radius -├── perf.py # MulticopterPerf(OpenAP) + BATT (Phase 3) -└── data/ # generated perf maps + vendored PyThrust data (Phase 3) +packages/minisky-multicopter/ +├── pyproject.toml # workspace member; minisky.plugins entry point "multicopter" +└── src/minisky_multicopter/ + ├── __init__.py # Plugin declaration: build() mounts the entity, registers replacements + ├── entity.py # MULTICOPTER_TYPES + Multicopter Entity (ismulticopter, selhdg, yawrate), + │ # its stack commands (MCOPT, YAW, YAWRATE, HOVER) and the selection hooks + ├── kinematics.py # MulticopterKinematics(Kinematics) + ├── aporasas.py # MulticopterAPorASAS(APorASAS) + ├── autopilot.py # MulticopterAutopilot(Autopilot): hover primitive, fly-over defaults + ├── activewp.py # MulticopterActiveWaypoint(ActiveWaypoint): fixed capture radius + ├── perf.py # MulticopterPerf(OpenAP) + BATT (Phase 3) + └── data/ # generated perf maps + vendored PyThrust data (Phase 3) ``` -Loader notes: plugin discovery scans `**/*.py` under `plugin_path` recursively and skips -`_`-prefixed files, so `__init__.py` cannot be the entry point — `plugin.py` is the one module -defining `init_plugin()`; the sibling modules are parsed but not registered (no `init_plugin`). -The folder is imported as a package (`example_plugins.multicopter.plugin`), so `plugin.py` -imports the class modules with relative imports (`from .kinematics import ...`). +Loader notes: the plugin manager discovers installed packages through the `minisky.plugins` +entry-point group without importing them; `__init__.py` exports the `Plugin` declaration and +imports the class modules. Replacements are registered runtime-locally when the plugin loads +(`@plugin.replacement` classes passed to `context.finish(replacements=...)`) and removed again +on shutdown. Selection is *not* automatic on load: the entity's `preupdate` hook selects the +four implementations on the first step after loading (via `traffic.select_implementation`), and +its `reset` hook re-selects them after every reset, which reverts all replaceables to their core +defaults. ### Membership @@ -161,8 +171,8 @@ Selection must **not** be `traf.perf.lifttype == LIFT_ROTOR` — that would swee ### `MulticopterKinematics(Kinematics)` -Selected with `SELECTIMPL KINEMATICS MULTICOPTERKINEMATICS` (the plugin issues this on load / -documents it). Calls `super().update()` for the whole fleet, then re-integrates the multicopter +Selected with `SELECTIMPL KINEMATICS MULTICOPTERKINEMATICS` (the plugin's hooks keep this +selected). Calls `super().update()` for the whole fleet, then re-integrates the multicopter rows (mask `m`): ```python @@ -226,9 +236,9 @@ A thin subclass (`SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT`) covers what the st `ActiveWaypoint.reached()` recomputes `turndist` every step — clamping it from the autopilot update would be overwritten before it is ever used. -With this, the plugin issues four swaps on load — `KINEMATICS`, `APORASAS`, `AUTOPILOT`, -`ACTIVEWAYPOINT` — each subclass calling `super()` and adjusting only the masked multicopter -rows. +With this, the plugin registers and keeps selected four swaps — `KINEMATICS`, `APORASAS`, +`AUTOPILOT`, `ACTIVEWAYPOINT` — each subclass calling `super()` and adjusting only the masked +multicopter rows. **Acceptance (integration tests, driven through the stack like `test_stack.py`):** @@ -245,11 +255,12 @@ rows. ### Phase 2 checklist -- [x] `example_plugins/multicopter/` package skeleton with `plugin.py` (`init_plugin()`, - plugin name `MULTICOPTER`) +- [x] `packages/minisky-multicopter/` workspace package with a `minisky.plugins` entry point + (`multicopter = "minisky_multicopter:plugin"`) and the `Plugin` declaration in + `__init__.py` - [x] `entity.py`: `MULTICOPTER_TYPES` set + `Entity` with `ismulticopter`, `selhdg`, `yawrate` arrays, auto-set from typecode in `create()`; stack commands `MCOPT`, - `YAW`, `YAWRATE` + `YAW`, `YAWRATE`, `HOVER` (declared with `@plugin.command`) - [x] `kinematics.py`: `MulticopterKinematics(Kinematics)` — yaw-rate-limited heading, track-driven velocity vector, single `update_pos()` pass - [x] `aporasas.py`: `MulticopterAPorASAS(APorASAS)` — skip trk→hdg coupling for @@ -258,8 +269,9 @@ rows. (the planned `DELIVER` was dropped as too use-case specific), `HDG`-yaws-the-nose, fly-over route defaults; `activewp.py`: `MulticopterActiveWaypoint` fixed capture radius -- [x] Plugin issues the four `SELECTIMPL` swaps on load; defaults restored on reset - (re-selected by the plugin's reset hook) +- [x] Plugin registers the four replacements on load; the entity's `preupdate` hook selects + them on the first step, and its `reset` hook re-selects after every reset (which + reverts all replaceables to the core defaults) - [x] Integration tests: hover-hold, yaw at gs = 0, strafe (fixed nose, moving track), leg-to-leg course capture, `HOVER` (timed, at altitude, composed with `ALT`/`LNAV`), fixed-wing regression guard @@ -293,9 +305,9 @@ Pipeline, following the existing regen conventions (navdb parquet, `minisky comm {prop, motor, cell, series/parallel, n_rotors, mass}), and emits one small artifact per type: a grid `(airspeed, thrust) → (power_w, current_a, feasible)` (~30 KB float32 npz/parquet) plus the battery curves. -2. Artifacts are **checked in** inside the plugin package (`example_plugins/multicopter/data/`). +2. Artifacts are **checked in** inside the plugin package (`packages/minisky-multicopter/src/minisky_multicopter/data/`). The handful of vendored source CSV/JSONs (~1 MB) live under - `example_plugins/multicopter/data/pythrust/` together with PyThrust's LICENSE and an + `packages/minisky-multicopter/src/minisky_multicopter/data/pythrust/` together with PyThrust's LICENSE and an attribution note (the prop tables are repackaged APC published performance data). 3. Runtime: `MulticopterPerf` loads the artifacts at plugin load and evaluates with vectorised `np.interp`/`RegularGridInterpolator`. Zero per-step Python loops, zero new dependencies. @@ -325,12 +337,12 @@ for the map interpolation against a few hand-computed points from the source CSV ### Phase 3 checklist - [ ] Vendor the needed prop CSVs + motor JSONs under - `example_plugins/multicopter/data/pythrust/` with PyThrust's LICENSE and an + `packages/minisky-multicopter/src/minisky_multicopter/data/pythrust/` with PyThrust's LICENSE and an attribution note - [ ] Per-typecode config: `{prop, motor, cell, series/parallel, n_rotors, mass, CdS}` - [ ] `scripts/gen_multicopter_perf.py` (numpy-only, no pythrust import) emitting per-type `(airspeed, thrust) → (power, current, feasible)` maps + battery curves -- [ ] Check in the generated artifacts (`example_plugins/multicopter/data/*.npz`) +- [ ] Check in the generated artifacts (`packages/minisky-multicopter/src/minisky_multicopter/data/*.npz`) - [ ] `perf.py`: `MulticopterPerf(OpenAP)` — required-thrust model, vectorised map interpolation, per-aircraft SoC integration, envelope feedback in `limits()`; stack command `BATT` diff --git a/example_plugins/multicopter/plugin.py b/example_plugins/multicopter/plugin.py deleted file mode 100644 index b79e152..0000000 --- a/example_plugins/multicopter/plugin.py +++ /dev/null @@ -1,124 +0,0 @@ -"""MULTICOPTER — simulate small electric multirotors. - -Makes MiniSky fly DJI MAVIC/M600/PHAN4-class and Amazon/Matternet-style -delivery drones with multicopter behaviour: hover and yaw at zero airspeed, -and a velocity vector decoupled from the body heading (the aircraft can -strafe — change course without rotating the nose). - -Everything is implemented as replaceable subclasses of core entities, which -the plugin selects on load: - -- ``KINEMATICS`` -> :class:`MulticopterKinematics` — yaw-rate-limited - heading, track-driven velocity vector. -- ``APORASAS`` -> :class:`MulticopterAPorASAS` — no track-to-heading - coupling for multicopter rows. -- ``AUTOPILOT`` -> :class:`MulticopterAutopilot` — HOVER primitive, - HDG-yaws-the-nose semantics, fly-over route defaults. -- ``ACTIVEWAYPOINT`` -> :class:`MulticopterActiveWaypoint` — fixed waypoint - capture radius (the bank-angle turn distance degenerates at hover speeds). - -Fixed-wing aircraft in the same simulation are untouched: every override -calls ``super()`` and adjusts only the multicopter rows. Helicopters are out -of scope — membership is a typecode set, not ``LIFT_ROTOR``. - -Stack commands: MCOPT, YAW, YAWRATE, HOVER. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from .activewp import MulticopterActiveWaypoint -from .aporasas import MulticopterAPorASAS -from .autopilot import MulticopterAutopilot -from .entity import Multicopter -from .kinematics import MulticopterKinematics - -if TYPE_CHECKING: - from minisky import MiniSky - from minisky.traffic import Traffic - -#: Replaceable base -> multicopter implementation, selected on load and reset. -IMPLEMENTATIONS = ( - ("KINEMATICS", MulticopterKinematics), - ("APORASAS", MulticopterAPorASAS), - ("AUTOPILOT", MulticopterAutopilot), - ("ACTIVEWAYPOINT", MulticopterActiveWaypoint), -) - - -def init_plugin(runtime: MiniSky) -> tuple[dict[str, Any], dict[str, list[Any]]]: - """Initialise the multicopter plugin for one MiniSky runtime. - - Creates the per-aircraft state entity on the runtime's traffic tree, - swaps in the multicopter implementations, and returns the stack commands. - - Args: - runtime: MiniSky runtime loading this plugin. - - Returns: - A `(config, stack_functions)` tuple consumed by the plugin manager. - """ - mc = Multicopter(runtime.traffic) - _select_implementations(runtime) - if not isinstance(runtime.traffic.ap, MulticopterAutopilot): - raise RuntimeError("MULTICOPTER: could not select the multicopter implementations") - - config = { - "plugin_name": "MULTICOPTER", - "reset": lambda: _select_implementations(runtime), - "state": mc, - } - return config, _stack_functions(runtime.traffic, mc) - - -def _select_implementations(runtime: MiniSky) -> None: - """Swap the multicopter implementations onto the runtime's ``traf``. - - Equivalent to issuing ``SELECTIMPL `` for each entry of - :data:`IMPLEMENTATIONS`; replaces the live instance immediately and - rebinds any stack commands bound to the old one. Also called from the - reset hook, since a reset reverts every replaceable to its core default. - """ - for basename, impl in IMPLEMENTATIONS: - runtime.replaceables.select(basename, impl.__name__) - - -def _stack_functions(traffic: Traffic, mc: Multicopter) -> dict[str, list[Any]]: - """Build the plugin's stack-command table. - - HOVER is a free function that looks up ``traffic.ap`` at call time - rather than a bound method: a reset replaces the autopilot instance - twice (revert to base, then the reset hook reselects), and the command - rebinding cannot follow methods that only exist on the subclass across - that double swap. The entity commands bind to ``mc`` directly, which - lives for the whole plugin lifetime. - - Argument specifications are given explicitly, so they override the plain - Python annotations on the callbacks. - """ - - def hover( - idx: int, duration: float | None = None, alt: float | None = None - ) -> tuple[bool, str]: - ap = traffic.ap - if not isinstance(ap, MulticopterAutopilot): - return False, "HOVER: SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT first" - return ap.hover(idx, duration, alt) - - return { - "MCOPT": [mc.mcopt, "callsign,[onoff]", "MCOPT callsign,[ON/OFF]", mc.mcopt.__doc__], - "YAW": [mc.yaw, "callsign,hdg", "YAW callsign,hdg", mc.yaw.__doc__], - "YAWRATE": [ - mc.setyawrate, - "callsign,[float]", - "YAWRATE callsign,[rate]", - mc.setyawrate.__doc__, - ], - "HOVER": [ - hover, - "callsign,[time,alt]", - "HOVER callsign,[time,alt]", - MulticopterAutopilot.hover.__doc__, - ], - } diff --git a/packages/minisky-multicopter/pyproject.toml b/packages/minisky-multicopter/pyproject.toml new file mode 100644 index 0000000..39f94b4 --- /dev/null +++ b/packages/minisky-multicopter/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "minisky-multicopter" +version = "0.1.0" +description = "MiniSky plugin flying small electric multirotors with decoupled track and heading" +readme = { text = "A MiniSky plugin for simulating small electric multirotors.", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = ["minisky>=0.1.0", "numpy>=2.2.2"] +classifiers = ["Private :: Do Not Upload"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/minisky_multicopter"] + +[tool.uv.sources] +minisky = { workspace = true } + +[project.entry-points."minisky.plugins"] +multicopter = "minisky_multicopter:plugin" diff --git a/packages/minisky-multicopter/src/minisky_multicopter/__init__.py b/packages/minisky-multicopter/src/minisky_multicopter/__init__.py new file mode 100644 index 0000000..13a20be --- /dev/null +++ b/packages/minisky-multicopter/src/minisky_multicopter/__init__.py @@ -0,0 +1,66 @@ +"""MULTICOPTER — simulate small electric multirotors. + +Makes MiniSky fly DJI MAVIC/M600/PHAN4-class and Amazon/Matternet-style +delivery drones with multicopter behaviour: hover and yaw at zero airspeed, +and a velocity vector decoupled from the body heading (the aircraft can +strafe — change course without rotating the nose). + +Everything is implemented as replaceable subclasses of core entities, which +the plugin registers on load and keeps selected through its hooks (the first +simulation step after loading, and every reset): + +- ``KINEMATICS`` -> :class:`MulticopterKinematics` — yaw-rate-limited + heading, track-driven velocity vector. +- ``APORASAS`` -> :class:`MulticopterAPorASAS` — no track-to-heading + coupling for multicopter rows. +- ``AUTOPILOT`` -> :class:`MulticopterAutopilot` — HOVER primitive, + HDG-yaws-the-nose semantics, fly-over route defaults. +- ``ACTIVEWAYPOINT`` -> :class:`MulticopterActiveWaypoint` — fixed waypoint + capture radius (the bank-angle turn distance degenerates at hover speeds). + +Fixed-wing aircraft in the same simulation are untouched: every override +calls ``super()`` and adjusts only the multicopter rows. Helicopters are out +of scope — membership is a typecode set, not ``LIFT_ROTOR``. + +Stack commands: MCOPT, YAW, YAWRATE, HOVER. +""" + +from __future__ import annotations + +from minisky import plugin as plugin_api +from minisky_multicopter.activewp import MulticopterActiveWaypoint +from minisky_multicopter.aporasas import MulticopterAPorASAS +from minisky_multicopter.autopilot import MulticopterAutopilot +from minisky_multicopter.entity import Multicopter, get_multicopter +from minisky_multicopter.kinematics import MulticopterKinematics + +__all__ = ( + "Multicopter", + "MulticopterAPorASAS", + "MulticopterActiveWaypoint", + "MulticopterAutopilot", + "MulticopterKinematics", + "get_multicopter", + "plugin", +) + + +def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + """Build the multicopter components for one MiniSky runtime. + + Mounts the per-aircraft state entity (which also carries the stack + commands and the implementation-selection hooks) and registers the + multicopter implementations as runtime-local replacements. + """ + context.mount(Multicopter()) + return context.finish( + replacements=( + MulticopterKinematics, + MulticopterAPorASAS, + MulticopterAutopilot, + MulticopterActiveWaypoint, + ) + ) + + +plugin = plugin_api.Plugin(build=build) diff --git a/example_plugins/multicopter/activewp.py b/packages/minisky-multicopter/src/minisky_multicopter/activewp.py similarity index 95% rename from example_plugins/multicopter/activewp.py rename to packages/minisky-multicopter/src/minisky_multicopter/activewp.py index dab25cd..95264be 100644 --- a/example_plugins/multicopter/activewp.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/activewp.py @@ -19,14 +19,15 @@ import numpy as np +from minisky import plugin as plugin_api from minisky.traffic.activewpdata import ActiveWaypoint - -from .entity import get_multicopter +from minisky_multicopter.entity import get_multicopter #: Waypoint capture radius for multicopters [m]. CAPTURE_RADIUS = 10.0 +@plugin_api.replacement class MulticopterActiveWaypoint(ActiveWaypoint): """Active-waypoint data with a fixed capture radius for multicopters.""" diff --git a/example_plugins/multicopter/aporasas.py b/packages/minisky-multicopter/src/minisky_multicopter/aporasas.py similarity index 92% rename from example_plugins/multicopter/aporasas.py rename to packages/minisky-multicopter/src/minisky_multicopter/aporasas.py index f02353d..5630d04 100644 --- a/example_plugins/multicopter/aporasas.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/aporasas.py @@ -14,11 +14,12 @@ import numpy as np +from minisky import plugin as plugin_api from minisky.traffic.aporasas import APorASAS - -from .entity import get_multicopter +from minisky_multicopter.entity import get_multicopter +@plugin_api.replacement class MulticopterAPorASAS(APorASAS): """Skip the track-to-heading coupling for multicopter rows.""" diff --git a/example_plugins/multicopter/autopilot.py b/packages/minisky-multicopter/src/minisky_multicopter/autopilot.py similarity index 88% rename from example_plugins/multicopter/autopilot.py rename to packages/minisky-multicopter/src/minisky_multicopter/autopilot.py index 0b24a5e..0dd21f1 100644 --- a/example_plugins/multicopter/autopilot.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/autopilot.py @@ -5,7 +5,7 @@ consumes, so no guidance rewrite is needed. What the stock FMS cannot express is added here — the ``HOVER`` primitive, rerouted ``HDG`` semantics (nose only), and fly-over route defaults. The fixed waypoint capture radius -lives in :class:`~example_plugins.multicopter.activewp.MulticopterActiveWaypoint`. +lives in :class:`~minisky_multicopter.activewp.MulticopterActiveWaypoint`. ``HOVER`` is deliberately composable rather than a scripted manoeuvre: it brakes to a stop and holds position, optionally at a commanded altitude, and @@ -22,10 +22,10 @@ import numpy as np +from minisky import plugin as plugin_api from minisky.stack.argparser import Hdg from minisky.traffic.autopilot import Autopilot - -from .entity import MULTICOPTER_TYPES, get_multicopter +from minisky_multicopter.entity import MULTICOPTER_TYPES, get_multicopter if TYPE_CHECKING: from collections.abc import Callable @@ -40,6 +40,7 @@ ALT_CAPTURE = 0.5 +@plugin_api.replacement class MulticopterAutopilot(Autopilot): """Autopilot with a multicopter hover primitive. @@ -113,7 +114,9 @@ def update(self) -> None: & (traf.gs < GS_HOVER) & (np.abs(traf.alt - traf.selalt) < ALT_CAPTURE) ) - self.hovertimer = np.where(holding, self.hovertimer - self.simulation.simdt, self.hovertimer) + self.hovertimer = np.where( + holding, self.hovertimer - self.simulation.simdt, self.hovertimer + ) expired = holding & (self.hovertimer <= 0.0) # Restore the saved route state; expiry also re-engages LNAV/VNAV. @@ -150,19 +153,17 @@ def hover( ) -> tuple[bool, str]: """Hold position, optionally for a fixed time at a given altitude. - Suspends LNAV/VNAV, commands zero ground speed, and holds the given - altitude (the current one when omitted) — with an altitude the - aircraft moves there vertically, at a fixed position. With a - duration, the route resumes once position and altitude have been - held that long; without one, the aircraft hovers until LNAV is - re-engaged. Repeating the command while hovering updates the hold - time and altitude, and a plain ALT command changes the hover - altitude as well. - - Arguments: - - idx: Aircraft callsign - - duration: Hold time [s] (optional, omit to hover indefinitely) - - alt: Hover altitude [ft or FL] (optional, default: hold current) + Backs the ``HOVER`` stack command declared on the Multicopter + entity, which delegates here at call time so the command survives + the autopilot instance being swapped on reset. + + Args: + idx: Aircraft index. + duration: Hold time [s]; None holds indefinitely. + alt: Hover altitude [m]; None holds the current altitude. + + Returns: + tuple: (success flag, confirmation message). """ callsign = self.traffic.callsign[idx] mc = get_multicopter(self.traffic) diff --git a/example_plugins/multicopter/entity.py b/packages/minisky-multicopter/src/minisky_multicopter/entity.py similarity index 58% rename from example_plugins/multicopter/entity.py rename to packages/minisky-multicopter/src/minisky_multicopter/entity.py index 54b67e8..8dc7218 100644 --- a/example_plugins/multicopter/entity.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/entity.py @@ -2,7 +2,10 @@ Holds the plugin-owned per-aircraft arrays that mark which aircraft are multicopters and carry their decoupled body heading and yaw rate, plus the -stack commands that read and write them (``MCOPT``, ``YAW``, ``YAWRATE``). +stack commands that read and write them (``MCOPT``, ``YAW``, ``YAWRATE``, +``HOVER``) and the hooks that keep the multicopter implementations selected +(on the first simulation step after loading, and again after every reset, +which reverts all replaceables to their core defaults). Membership is deliberately *not* ``traf.perf.lifttype == LIFT_ROTOR``: that set also contains the EC35, a crewed helicopter, which this plugin does not @@ -15,7 +18,7 @@ import numpy as np -from minisky import plugin +from minisky import plugin as plugin_api if TYPE_CHECKING: from minisky.traffic import Traffic @@ -28,11 +31,21 @@ #: Default yaw rate for a newly created multicopter [deg/s]. DEFAULT_YAWRATE = 90.0 +#: Replaceable base -> multicopter implementation, selected on load and reset. +#: Kept as names (not classes) because every implementation module imports +#: this one for `get_multicopter`. +IMPLEMENTATIONS = ( + ("KINEMATICS", "MULTICOPTERKINEMATICS"), + ("APORASAS", "MULTICOPTERAPORASAS"), + ("AUTOPILOT", "MULTICOPTERAUTOPILOT"), + ("ACTIVEWAYPOINT", "MULTICOPTERACTIVEWAYPOINT"), +) + def get_multicopter(traffic: Traffic) -> Multicopter | None: """Return the Multicopter entity attached to a traffic tree, if any. - The entity is created by ``init_plugin()`` as a child node of ``traffic``. + The entity is mounted by the plugin build as a child node of ``traffic``. The replaceable subclasses use this lookup so that, when one of them is selected without the plugin loaded, they degrade to base behaviour instead of crashing. @@ -43,7 +56,7 @@ def get_multicopter(traffic: Traffic) -> Multicopter | None: ) -class Multicopter(plugin.Entity): +class Multicopter(plugin_api.Entity): """Per-aircraft multicopter state. Attributes: @@ -55,8 +68,9 @@ class Multicopter(plugin.Entity): yawrate (ndarray): Maximum yaw rate [deg/s]. """ - def __init__(self, traffic: Traffic) -> None: - super().__init__(traffic) + def __init__(self) -> None: + super().__init__() + self._selected = False with self.settrafarrays(): self.ismulticopter = np.array([], dtype=bool) self.selhdg = np.array([]) @@ -84,6 +98,40 @@ def mask(self) -> np.ndarray: """Return the boolean row mask of aircraft flown as multicopters.""" return self.ismulticopter + def select_implementations(self) -> None: + """Swap the multicopter implementations onto the owning traffic. + + Equivalent to issuing ``SELECTIMPL `` for each entry of + :data:`IMPLEMENTATIONS`; replaces the live instance immediately. + """ + for basename, implname in IMPLEMENTATIONS: + ok, message = self.traffic.select_implementation(basename, implname) + if not ok: + raise RuntimeError(f"MULTICOPTER: {message}") + self._selected = True + + @plugin_api.hook("preupdate") + def ensure_implementations(self) -> None: + """Select the multicopter implementations on the first step after load. + + Replacements are installed when the plugin loads but can only be + selected once the plugin is published, so the initial selection + happens here. A manual ``SELECTIMPL`` afterwards is respected until + the next reset. + """ + if not self._selected: + self.select_implementations() + + @plugin_api.hook("reset") + def reselect_implementations(self) -> None: + """Re-select the multicopter implementations after a reset. + + A reset reverts every replaceable to its core default; this hook runs + afterwards and restores the multicopter set. + """ + self.select_implementations() + + @plugin_api.command(arguments="callsign,[onoff]") def mcopt(self, idx: int, flag: bool | None = None) -> tuple[bool, str]: """Mark an aircraft as a multicopter (or report its current setting). @@ -106,6 +154,7 @@ def mcopt(self, idx: int, flag: bool | None = None) -> tuple[bool, str]: self.swselhdg[idx] = False return True, f"MCOPT {callsign}: {'ON' if flag else 'OFF'}" + @plugin_api.command(arguments="callsign,hdg") def yaw(self, idx: int, hdg: float) -> tuple[bool, str]: """Command the body heading (nose direction) of a multicopter. @@ -124,6 +173,7 @@ def yaw(self, idx: int, hdg: float) -> tuple[bool, str]: self.swselhdg[idx] = True return True, f"YAW {self.traffic.callsign[idx]}: nose to {hdg % 360.0:.0f} deg" + @plugin_api.command(name="YAWRATE", arguments="callsign,[float]") def setyawrate(self, idx: int, yawrate: float | None = None) -> tuple[bool, str]: """Set or report the maximum yaw rate of a multicopter. @@ -139,3 +189,31 @@ def setyawrate(self, idx: int, yawrate: float | None = None) -> tuple[bool, str] self.yawrate[idx] = yawrate return True, f"YAWRATE {callsign}: {yawrate:.0f} deg/s" + + @plugin_api.command(arguments="callsign,[time,alt]") + def hover( + self, idx: int, duration: float | None = None, alt: float | None = None + ) -> tuple[bool, str]: + """Hold position, optionally for a fixed time at a given altitude. + + Suspends LNAV/VNAV, commands zero ground speed, and holds the given + altitude (the current one when omitted) — with an altitude the + aircraft moves there vertically, at a fixed position. With a + duration, the route resumes once position and altitude have been + held that long; without one, the aircraft hovers until LNAV is + re-engaged. Repeating the command while hovering updates the hold + time and altitude, and a plain ALT command changes the hover + altitude as well. + + Arguments: + - idx: Aircraft callsign + - duration: Hold time [s] (optional, omit to hover indefinitely) + - alt: Hover altitude [ft or FL] (optional, default: hold current) + """ + # Deferred import: the autopilot module imports this one. + from minisky_multicopter.autopilot import MulticopterAutopilot + + ap = self.traffic.ap + if not isinstance(ap, MulticopterAutopilot): + return False, "HOVER: SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT first" + return ap.hover(idx, duration, alt) diff --git a/example_plugins/multicopter/kinematics.py b/packages/minisky-multicopter/src/minisky_multicopter/kinematics.py similarity index 97% rename from example_plugins/multicopter/kinematics.py rename to packages/minisky-multicopter/src/minisky_multicopter/kinematics.py index 3f61c83..7b624f3 100644 --- a/example_plugins/multicopter/kinematics.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/kinematics.py @@ -14,12 +14,13 @@ import numpy as np +from minisky import plugin as plugin_api from minisky.tools.aero import ft from minisky.traffic.kinematics import Kinematics - -from .entity import get_multicopter +from minisky_multicopter.entity import get_multicopter +@plugin_api.replacement class MulticopterKinematics(Kinematics): """Yaw-rate-limited, track-driven integration for multicopter rows. diff --git a/example_plugins/multicopter/perf.py b/packages/minisky-multicopter/src/minisky_multicopter/perf.py similarity index 93% rename from example_plugins/multicopter/perf.py rename to packages/minisky-multicopter/src/minisky_multicopter/perf.py index 4c5bcf0..7050d7e 100644 --- a/example_plugins/multicopter/perf.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/perf.py @@ -1,4 +1,4 @@ -"""Electric performance for multicopters. +"""Electric performance for multicopters (Phase 3 skeleton). Fills the ``# TODO: implement thrust computation for rotor aircraft`` gap in the core :class:`OpenAP` model for multicopter rows: required thrust from @@ -8,12 +8,14 @@ flight envelope as the pack voltage sags. Fixed-wing rows keep the ``super()`` behaviour untouched. Selected with -``SELECTIMPL OPENAP MULTICOPTERPERF``. +``SELECTIMPL OPENAP MULTICOPTERPERF`` once registered (it joins the plugin's +replacements when Phase 3 lands). The maps are generated offline by ``scripts/gen_multicopter_perf.py`` from propeller, motor and battery data vendored under ``data/pythrust/``, and -checked in under ``data/``. PyThrust itself is *not* a runtime dependency: -only its data is used, and only through ``np.interp``-style lookups. +checked in under this package's ``data/``. PyThrust itself is *not* a +runtime dependency: only its data is used, and only through +``np.interp``-style lookups. Fidelity caveat: the APC propeller coefficients are axial-flow, so forward-flight power for a translating multicopter is approximate. Hover diff --git a/packages/minisky/minisky/runtime.py b/packages/minisky/minisky/runtime.py index a351acf..8c13cb4 100644 --- a/packages/minisky/minisky/runtime.py +++ b/packages/minisky/minisky/runtime.py @@ -16,8 +16,11 @@ from minisky.tools.areafilter import AreaFilter from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic +from minisky.traffic.activewpdata import ActiveWaypoint +from minisky.traffic.aporasas import APorASAS from minisky.traffic.asas import MVP, ConflictDetection, ConflictResolution from minisky.traffic.autopilot import Autopilot +from minisky.traffic.kinematics import Kinematics from minisky.traffic.performance.perfoap import OpenAP @@ -61,7 +64,15 @@ def __init__( ) self.replaceables = ReplaceableManager( self.traffic, - bases=(Autopilot, ConflictDetection, ConflictResolution, OpenAP), + bases=( + ActiveWaypoint, + APorASAS, + Autopilot, + ConflictDetection, + ConflictResolution, + Kinematics, + OpenAP, + ), core=(MVP,), ) self.plugins = PluginManager( diff --git a/pyproject.toml b/pyproject.toml index 98c35d6..265ab4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,8 +48,8 @@ known-first-party = [ "minisky", "minisky_example", "minisky_example_customautopilot", + "minisky_multicopter", "minisky_tangram", - "example_plugins", "tests", ] @@ -57,6 +57,7 @@ known-first-party = [ include = [ "packages/minisky/minisky", "packages/minisky-example*/src", + "packages/minisky-multicopter/src", "packages/minisky-tangram/src", "packages/tangram-minisky/src", "tests", @@ -70,6 +71,7 @@ extraPaths = [ "packages/minisky", "packages/minisky-example/src", "packages/minisky-example-customautopilot/src", + "packages/minisky-multicopter/src", "packages/minisky-tangram/src", "packages/tangram-minisky/src", ] diff --git a/tests/integration/test_kinematics.py b/tests/integration/test_kinematics.py index 33e13e0..fa7f56d 100644 --- a/tests/integration/test_kinematics.py +++ b/tests/integration/test_kinematics.py @@ -15,8 +15,9 @@ class TaggedKinematics(Kinematics): """Trivial Kinematics subclass used to exercise SELECTIMPL KINEMATICS. - Defining it registers it as the 'TAGGEDKINEMATICS' implementation; it - only becomes active when explicitly selected. + Registered runtime-locally in the test (implementations no longer + auto-register on subclass definition); it only becomes active when + explicitly selected. """ def update(self) -> None: @@ -48,15 +49,21 @@ def test_select_subclass_takes_effect_and_reverts_on_reset( ) -> None: runtime.traffic.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) - ok, msg = runtime.replaceables.select("KINEMATICS", "TAGGEDKINEMATICS") - assert ok, msg - assert isinstance(runtime.traffic.kinematics, TaggedKinematics) - # per-aircraft arrays carry over to the new instance - assert len(runtime.traffic.kinematics.ax) == 1 + prepared = runtime.replaceables.prepare(TaggedKinematics) + runtime.replaceables.validate((prepared,)) + runtime.replaceables.install((prepared,)) + try: + ok, msg = runtime.replaceables.select("KINEMATICS", "TAGGEDKINEMATICS") + assert ok, msg + assert isinstance(runtime.traffic.kinematics, TaggedKinematics) + # per-aircraft arrays carry over to the new instance + assert len(runtime.traffic.kinematics.ax) == 1 - sim.step() - assert getattr(runtime.traffic.kinematics, "tag", None) == "tagged" + sim.step() + assert getattr(runtime.traffic.kinematics, "tag", None) == "tagged" - # reset restores the default implementation - runtime.simulation.reset() - assert type(runtime.traffic.kinematics) is Kinematics + # reset restores the default implementation + runtime.simulation.reset() + assert type(runtime.traffic.kinematics) is Kinematics + finally: + runtime.replaceables.remove((prepared,)) diff --git a/tests/integration/test_multicopter.py b/tests/integration/test_multicopter.py index 9d2ac45..0108715 100644 --- a/tests/integration/test_multicopter.py +++ b/tests/integration/test_multicopter.py @@ -1,9 +1,10 @@ """Integration tests for the MULTICOPTER plugin (Phase 2). -Driven through the stack, like test_stack.py. The plugin swaps replaceable -implementations on load and re-selects them from its reset hook, so these -tests run on their own runtime instead of the shared session runtime — the -other integration tests keep the core implementations. +Driven through the stack, like test_stack.py. The plugin registers +replaceable implementations on load and selects them from its hooks (first +step after load, and after every reset), so these tests run on their own +runtime instead of the shared session runtime — the other integration tests +keep the core implementations. The default simulation timestep is 1 s; yaw rates are lowered where a slew must be observable across steps. @@ -11,26 +12,27 @@ from __future__ import annotations +import asyncio from collections.abc import Callable, Iterator import pytest -from example_plugins.multicopter.autopilot import MulticopterAutopilot from minisky import MiniSky -from minisky.core.settings import DEFAULT_SETTINGS_FILE, MiniSkySettings +from minisky.core.config import MiniSkyConfig from minisky.simulation import Simulation from minisky.tools.aero import ft +from minisky_multicopter.autopilot import MulticopterAutopilot from tests._types import RunCommand, StepUntil @pytest.fixture(scope="module") def mcruntime() -> Iterator[MiniSky]: """Module-wide MiniSky runtime with the MULTICOPTER plugin loaded.""" - instance = MiniSky(MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE)) - ok, message = instance.plugins.load("MULTICOPTER") + instance = MiniSky(MiniSkyConfig()) + ok, message = asyncio.run(instance.plugins.load("MULTICOPTER")) assert ok, message yield instance - instance.close() + asyncio.run(instance.aclose()) @pytest.fixture @@ -41,6 +43,13 @@ def mcsim(mcruntime: MiniSky) -> Simulation: return mcruntime.simulation +class TestPluginDiscovery: + def test_plugin_listed_from_entry_point(self, mcruntime: MiniSky) -> None: + ok, text = mcruntime.plugins.listing() + assert ok + assert "MULTICOPTER" in text + + @pytest.fixture def run_mc(mcruntime: MiniSky, mcsim: Simulation) -> RunCommand: """Queue a stack command, step the sim, and return the last echoed output.""" diff --git a/uv.lock b/uv.lock index 04d50ce..b82201a 100644 --- a/uv.lock +++ b/uv.lock @@ -21,6 +21,7 @@ members = [ "minisky", "minisky-example", "minisky-example-customautopilot", + "minisky-multicopter", "minisky-tangram", "tangram-minisky", ] @@ -1316,6 +1317,22 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "minisky", editable = "packages/minisky" }] +[[package]] +name = "minisky-multicopter" +version = "0.1.0" +source = { editable = "packages/minisky-multicopter" } +dependencies = [ + { name = "minisky" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] + +[package.metadata] +requires-dist = [ + { name = "minisky", editable = "packages/minisky" }, + { name = "numpy", specifier = ">=2.2.2" }, +] + [[package]] name = "minisky-tangram" version = "0.1.0" From 79e64294cdc7ee879a099c9012bbcba38cdb1ffe Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:41:54 +0200 Subject: [PATCH 12/16] updat multicopter plan --- docs/multicopter-plan.md | 126 ++++++++++-------- .../src/minisky_multicopter/perf.py | 61 ++++----- 2 files changed, 94 insertions(+), 93 deletions(-) diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md index a0fb21b..4d79f63 100644 --- a/docs/multicopter-plan.md +++ b/docs/multicopter-plan.md @@ -26,8 +26,11 @@ the project direction: keep the core minimal, make behaviour hackable from outsi and not *rotorcraft* (would promise helicopter support). - **Aeroelastic / attitude-level dynamics.** We stay at the kinematic point-mass level of the rest of the simulator; "heading" is the only attitude state. -- **A runtime PyThrust dependency.** We use PyThrust's *data* (see Phase 3), never its code at - runtime, and it is not added to `pyproject.toml`. +- **PyThrust, for now.** The measured-prop-data pipeline is deferred entirely to future work + (see the last section). Phase 3 uses only the OpenAP rotor coefficients already shipped in + `data/performance/openap/rotor/aircraft.json` plus a few spec-sheet constants. If the pipeline + is ever built, it uses PyThrust's *data*, never its code at runtime, and it is not added to + `pyproject.toml`. ## What the codebase already provides @@ -277,76 +280,62 @@ multicopter rows. fixed-wing regression guard - [x] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green -## Phase 3 — `MulticopterPerf`: electric performance from PyThrust *data* +## Phase 3 — `MulticopterPerf`: analytic electric model from the OpenAP rotor data `class MulticopterPerf(OpenAP)`, selected with `SELECTIMPL OPENAP MULTICOPTERPERF`. Fixed-wing rows keep `super()` behaviour untouched; multicopter rows get an electric model. This fills the long-standing `# TODO: implement thrust computation for rotor aircraft` in `perfoap.py`. -### Data pipeline (no new runtime dependency — decided) +### Data: what ships already, and the one gap -[PyThrust](https://github.com/Setuav/PyThrust) (Apache 2.0) ships everything needed as plain -data: - -- **Propeller tables** (`data/propellers/apc_202602/*.csv`, 441 APC props): full performance - grids with `rpm, speed_mps, thrust_n, power_w, torque_nm, ct, cp, ...` — thrust *and* shaft - power are already tabulated, so the inverse question a perf model asks - ("required thrust at this airspeed → power") is pure interpolation. No solver needed. -- **Motor specs** (`data/motors/*.json`): `kv`, `resistance`, `io`, `max_current` — shaft-to- - electrical conversion is a few lines of textbook motor algebra. -- **Battery curves** (`data/batteries/*.json`): open-circuit voltage and internal resistance vs - depth-of-discharge — `np.interp` territory. (Their example cell is synthetic; real types - should get measured curves eventually.) - -Pipeline, following the existing regen conventions (navdb parquet, `minisky commands docs`): - -1. `scripts/gen_multicopter_perf.py` — **self-contained** (numpy only, no pythrust import): - reads vendored prop CSV + motor JSON per multicopter typecode (config mapping typecode → - {prop, motor, cell, series/parallel, n_rotors, mass}), and emits one small artifact per type: - a grid `(airspeed, thrust) → (power_w, current_a, feasible)` (~30 KB float32 npz/parquet) - plus the battery curves. -2. Artifacts are **checked in** inside the plugin package (`packages/minisky-multicopter/src/minisky_multicopter/data/`). - The handful of vendored source CSV/JSONs (~1 MB) live under - `packages/minisky-multicopter/src/minisky_multicopter/data/pythrust/` together with PyThrust's LICENSE and an - attribution note (the prop tables are repackaged APC published performance data). -3. Runtime: `MulticopterPerf` loads the artifacts at plugin load and evaluates with vectorised - `np.interp`/`RegularGridInterpolator`. Zero per-step Python loops, zero new dependencies. +`data/performance/openap/rotor/aircraft.json` (already loaded by `OpenAP.create()`) provides, +per rotor typecode: mass (`oew`/`mtow` → `traf.perf.mass`), `n_engines` (`engnum`), per-engine +max power in kW (`engpower`), and the flight envelope. That is enough for an analytic power +model with **no new data pipeline and no PyThrust anything**: + +- **Installed power** `P_max = engnum · engpower` is the model's anchor. +- **Battery capacity is the one thing the json lacks** (`mfc` is 0 for every rotor type). A + small hand-written per-typecode dict in `perf.py` supplies spec-sheet watt-hours (e.g. MAVIC + 43.6 Wh, PHAN4 81.3 Wh, M600 6×99.9 Wh), with a fallback that derives energy from + `d_range_max` at cruise speed for unlisted types. The same dict optionally carries `CdS` + (flat-plate area) and a thrust-to-weight ratio where the default is wrong. ### Runtime model (multicopter rows) -- **Required thrust:** per rotor, `T = m·√(g² + a²)/n_rotors` in hover/climb, plus a flat-plate - parasite term `½ρv²·CdS` in translation (edgewise-flow caveat below). -- **Power/current:** from the per-type map at `(tas, T)`; write `self.thrust` (total) and expose - `battery_power` as the electric analogue of `fuelflow`. -- **Battery:** per-aircraft `soc` array; integrate `soc -= I·dt / capacity`; terminal voltage - from OCV/R curves. -- **Envelope feedback:** where the map is infeasible at current battery voltage (sag at low SoC), - tighten `vmax`/`vsmax` in `limits()` — performance genuinely degrades as the battery empties. +- **Required thrust:** `T = m·√(g² + a_z²)`, plus a flat-plate parasite term `½ρv²·CdS` in + translation (small default `CdS`). +- **Power:** momentum-theory scaling referenced to installed power, + `P = P_max · (T / T_max)^1.5`, with `T_max = TWR · m·g` and a default thrust-to-weight ratio + of 2 (typical for camera/delivery multirotors). Sanity anchor: MAVIC installed power is + 4 × 66.9 W ≈ 268 W, giving ≈ 130 W in hover — matching published figures. Write `self.thrust` + and expose `battery_power` as the electric analogue of `fuelflow`. +- **Battery:** per-aircraft `soc` array, ideal-energy-tank integration + `soc -= P·dt / E_batt`. No terminal-voltage/current modelling — that needs the electrical + data (motor kv/resistance, OCV/R curves) deferred with PyThrust. +- **Envelope feedback:** below an SoC threshold, tighten `vmax`/`vsmax` in `limits()` — + keyed on SoC directly rather than physical voltage sag. - **Stack commands:** `BATT acid` (report SoC/power/endurance estimate), optional auto-RTH/land threshold via the conditional-command machinery later. -**Fidelity caveat (documented in the plugin):** APC coefficients are axial-flow; a translating -multicopter has edgewise inflow, so forward-flight power is approximate. Hover figures and the -qualitative trends (power vs speed, voltage sag) are sound — the right level for a traffic -simulator. +**Fidelity caveat (documented in the plugin):** the power curve is momentum-theory shape, not +measured prop data — absolute forward-flight power is approximate, and the model deliberately +ignores the induced-power *drop* in fast translation (power is monotone in required thrust +here). Hover figures and the qualitative trends (power vs thrust, endurance, envelope shrink at +low battery) are sound — the right level for a traffic simulator, upgradeable later without API +changes (see the future-work section). **Acceptance:** hover endurance for a MAVIC-class config lands within sanity bounds (~20–35 min); `BATT` reports monotonically decreasing SoC; envelope shrinks below a SoC threshold; unit tests -for the map interpolation against a few hand-computed points from the source CSV. +for the power model against a few hand-computed points. ### Phase 3 checklist -- [ ] Vendor the needed prop CSVs + motor JSONs under - `packages/minisky-multicopter/src/minisky_multicopter/data/pythrust/` with PyThrust's LICENSE and an - attribution note -- [ ] Per-typecode config: `{prop, motor, cell, series/parallel, n_rotors, mass, CdS}` -- [ ] `scripts/gen_multicopter_perf.py` (numpy-only, no pythrust import) emitting per-type - `(airspeed, thrust) → (power, current, feasible)` maps + battery curves -- [ ] Check in the generated artifacts (`packages/minisky-multicopter/src/minisky_multicopter/data/*.npz`) -- [ ] `perf.py`: `MulticopterPerf(OpenAP)` — required-thrust model, vectorised map - interpolation, per-aircraft SoC integration, envelope feedback in `limits()`; +- [ ] Per-typecode constants dict in `perf.py`: `{battery_wh, CdS?, twr?}` from public spec + sheets, `d_range_max`-derived fallback for unlisted rotor types +- [ ] `perf.py`: `MulticopterPerf(OpenAP)` — required-thrust model, momentum-theory power from + `engnum · engpower`, per-aircraft SoC integration, envelope feedback in `limits()`; stack command `BATT` -- [ ] Unit tests: interpolation vs hand-computed CSV points; SoC monotonically decreasing; +- [ ] Unit tests: power model vs hand-computed points; SoC monotonically decreasing; envelope tightens below SoC threshold - [ ] Sanity: MAVIC-class hover endurance in the 20–35 min range - [ ] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green @@ -354,7 +343,7 @@ for the map interpolation against a few hand-computed points from the source CSV ## Phase 4 — docs, scenarios, cleanup - New guide `docs/guides/multicopters.md`: creating multicopters, hover/yaw commands, battery - model, how to add a new type (config + regen script). + model, how to add a new type (rotor `aircraft.json` entry + constants dict). - Update `docs/architecture.md` with the `Kinematics` entity and the replaceable list. - Example scenario `scenarios/multicopter_delivery.scn`: create, fly a route, hover at a delivery point, yaw for "camera", return; exercises everything above. @@ -366,7 +355,8 @@ for the map interpolation against a few hand-computed points from the source CSV - [ ] `docs/guides/multicopters.md` (usage, commands, battery model, adding a new type) - [ ] Update `docs/architecture.md`: `Kinematics` entity + replaceable list -- [ ] `scenarios/multicopter_delivery.scn` exercising create → route → `DELIVER` → return +- [ ] `scenarios/multicopter_delivery.scn` exercising create → route → hover-delivery + (`HOVER` + `ALT` + `LNAV ON`) → return - [ ] Regenerate `docs/reference/commands.md` (`uv run minisky commands docs`) - [ ] Final sweep: `uv run pytest`, `uv run ruff check .`, `uv run pyright` @@ -376,7 +366,7 @@ for the map interpolation against a few hand-computed points from the source CSV |---|---|---|---| | 1 | Extract `Kinematics` (core, behaviour-preserving) | Low — mechanical move guarded by existing tests | — | | 2 | Plugin: membership + kinematics + commands | Medium — command semantics for HDG/YAW need care | 1 | -| 3 | Perf: data vendoring, gen script, `MulticopterPerf`, battery | Medium — model calibration/sanity | 2 (usable after 1) | +| 3 | Perf: analytic `MulticopterPerf` + battery from shipped OpenAP data | Low–medium — model calibration/sanity | 2 (usable after 1) | | 4 | Docs, scenario, polish | Low | 2, 3 | Implementation lands on this branch phase by phase, checking off the checklists above as items @@ -393,5 +383,25 @@ complete; phase 1 is intentionally the only one touching `minisky/`. | Mission primitive | One composable `HOVER acid [time] [alt]`; no `DELIVER` | Delivery choreography belongs in scenarios (`HOVER` + `ALT` + `LNAV ON`); keeps the primitive abstract | | Capture radius | `MulticopterActiveWaypoint` subclass (fourth swap) | `ActiveWaypoint.reached()` recomputes `turndist` every step, so clamping it from the autopilot is overwritten before use | | Membership predicate | Plugin-owned typecode set + `ismulticopter` array | `LIFT_ROTOR` includes helicopters | -| PyThrust | Data only, vendored with attribution; self-contained gen script; nothing at runtime | Prop CSVs already tabulate thrust & power; keeps dependency tree untouched (Apache 2.0 permits) | -| Perf evaluation | Precomputed per-type maps, vectorised interp | Keeps the numpy discipline; fleet-size independent; regen convention already exists in repo | +| PyThrust | **Deferred entirely to future work** (2026-08-02; was: vendor its data + gen script) | The shipped OpenAP rotor json (mass, engine power, envelope) supports an analytic model; only battery Wh needs a small constants dict. Avoids ~1 MB vendored data and a gen script until the fidelity is actually needed | +| Perf evaluation | Analytic momentum-theory scaling, pure numpy | Keeps the numpy discipline; fleet-size independent; no artifacts to regenerate | + +## Future work — PyThrust-data fidelity upgrade (deferred) + +If measured-data fidelity is ever needed, the original Phase 3 design still applies and slots in +behind the same `MulticopterPerf` API (only the power/current evaluation changes): + +[PyThrust](https://github.com/Setuav/PyThrust) (Apache 2.0) ships everything needed as plain +data — APC propeller performance grids (`rpm, speed_mps, thrust_n, power_w, ...`; thrust *and* +shaft power tabulated, so "required thrust at this airspeed → power" is pure interpolation), +motor specs (`kv`, `resistance`, `io` — shaft-to-electrical is textbook motor algebra), and +battery OCV/internal-resistance-vs-DoD curves. The pipeline: vendor the handful of needed +CSV/JSONs (~1 MB) under `packages/minisky-multicopter/src/minisky_multicopter/data/pythrust/` +with PyThrust's LICENSE and an attribution note; a **self-contained** numpy-only +`scripts/gen_multicopter_perf.py` (regen convention like navdb parquet) emits one ~30 KB +`(airspeed, thrust) → (power_w, current_a, feasible)` grid per typecode plus battery curves, +checked in next to the plugin; runtime evaluates with vectorised interpolation. Never a runtime +PyThrust dependency. This upgrade adds what the analytic model cannot do: current draw, +terminal-voltage sag, and envelope infeasibility driven by physical voltage collapse rather +than an SoC threshold. Fidelity caveat regardless: APC coefficients are axial-flow, so +forward-flight power in edgewise translation stays approximate. diff --git a/packages/minisky-multicopter/src/minisky_multicopter/perf.py b/packages/minisky-multicopter/src/minisky_multicopter/perf.py index 9f110cf..db34056 100644 --- a/packages/minisky-multicopter/src/minisky_multicopter/perf.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/perf.py @@ -2,30 +2,29 @@ Fills the ``# TODO: implement thrust computation for rotor aircraft`` gap in the core :class:`OpenAP` model for multicopter rows: required thrust from -the mass and acceleration, power and current from a precomputed -``(airspeed, thrust) -> (power, current, feasible)`` map per typecode, and a -battery state of charge that is integrated each step and feeds back into the -flight envelope as the pack voltage sags. +the mass and acceleration, electrical power from a momentum-theory scaling +anchored to the installed power already shipped in the OpenAP rotor +coefficients (``engnum * engpower``), and a battery state of charge that is +integrated each step and feeds back into the flight envelope. Fixed-wing rows keep the ``super()`` behaviour untouched. Selected with ``SELECTIMPL OPENAP MULTICOPTERPERF`` once registered (it joins the plugin's replacements when Phase 3 lands). -The maps are generated offline by ``scripts/gen_multicopter_perf.py`` from -propeller, motor and battery data vendored under ``data/pythrust/``, and -checked in under this package's ``data/``. PyThrust itself is *not* a -runtime dependency: only its data is used, and only through -``np.interp``-style lookups. +The only data the shipped rotor ``aircraft.json`` lacks is battery capacity, +supplied by a small per-typecode spec-sheet constants dict here. No PyThrust +anywhere: a measured-prop-data upgrade is future work (see the plan doc). -Fidelity caveat: the APC propeller coefficients are axial-flow, so -forward-flight power for a translating multicopter is approximate. Hover -figures and the qualitative trends (power against speed, voltage sag) are -sound — the right level for a traffic simulator. +Fidelity caveat: the power curve is momentum-theory shape +(``P = P_max * (T / T_max) ** 1.5``), not measured prop data, so absolute +forward-flight power is approximate and there is no terminal-voltage or +current modelling. Hover figures and the qualitative trends (power against +thrust, endurance, envelope shrink at low battery) are sound — the right +level for a traffic simulator. """ from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING import numpy as np @@ -34,18 +33,13 @@ if TYPE_CHECKING: from minisky.traffic import Traffic -#: Directory holding the generated per-typecode performance maps. -DATA_PATH = Path(__file__).parent / "data" - class MulticopterPerf(OpenAP): """OpenAP performance with an electric model for multicopter rows. Attributes: soc (ndarray): Battery state of charge [0-1]. - capacity (ndarray): Usable pack capacity [As]. - current (ndarray): Current battery current draw [A]. - voltage (ndarray): Current battery terminal voltage [V]. + capacity (ndarray): Usable pack energy [J]. power (ndarray): Current electrical power draw [W] — the electric analogue of ``fuelflow``. nrotors (ndarray): Number of rotors [-]. @@ -54,12 +48,9 @@ class MulticopterPerf(OpenAP): def __init__(self, traffic: Traffic) -> None: super().__init__(traffic) - # TODO: load the generated maps and battery curves from DATA_PATH with self.settrafarrays(): self.soc = np.array([]) self.capacity = np.array([]) - self.current = np.array([]) - self.voltage = np.array([]) self.power = np.array([]) self.nrotors = np.array([]) self.cds = np.array([]) @@ -74,23 +65,24 @@ def create(self, n: int = 1) -> None: n: Number of aircraft that were appended to the traffic arrays. """ super().create(n) - # TODO: look up the per-typecode config, seed soc = 1.0, capacity, - # nrotors and cds. + # TODO: look up the per-typecode constants dict (battery Wh, CdS, + # thrust-to-weight ratio), seed soc = 1.0, capacity, nrotors and cds. def update(self, dt: float = 1) -> None: """Update performance, then the electric model for multicopter rows. After the base update, computes the thrust each multicopter needs to - hold its current acceleration and overcome parasite drag, reads power - and current off the per-typecode map, and integrates the battery - state of charge. + hold its current acceleration and overcome parasite drag, derives the + electrical power from the momentum-theory scaling, and integrates the + battery state of charge. Args: dt: Update timestep [s]. """ super().update(dt) - # TODO: required thrust -> map lookup -> self.thrust/power/current - # TODO: integrate self.soc; update self.voltage from the OCV/R curves + # TODO: required thrust -> P = Pmax * (T / Tmax) ** 1.5 -> + # self.thrust/self.power + # TODO: integrate self.soc (ideal energy tank: soc -= P * dt / capacity) def limits( self, @@ -102,9 +94,8 @@ def limits( """Clip the intended state to the flight envelope. Runs the base envelope, then tightens the speed and climb-rate limits - of multicopter rows wherever the performance map is infeasible at the - current pack voltage, so performance genuinely degrades as the - battery empties. + of multicopter rows below a state-of-charge threshold, so performance + degrades as the battery empties. Args: intent_v_tas: Intended true airspeed [m/s]. @@ -135,6 +126,6 @@ def batt(self, idx: int) -> tuple[bool, str]: Arguments: - idx: Aircraft callsign """ - # TODO: report soc/voltage/current/power and a remaining-endurance - # estimate at the current draw + # TODO: report soc/power and a remaining-endurance estimate at the + # current draw return False, "BATT: not implemented yet" From 3c3cfe362bc161eeb1e047454b175a59a6ed2913 Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:57:38 +0200 Subject: [PATCH 13/16] add multicopter performance model --- docs/multicopter-plan.md | 23 +- .../src/minisky_multicopter/__init__.py | 8 +- .../src/minisky_multicopter/entity.py | 21 +- .../src/minisky_multicopter/perf.py | 215 ++++++++++++++---- .../tests/integration/conftest.py | 64 ++++++ .../tests/integration/test_multicopter.py | 59 +---- .../tests/integration/test_perf.py | 174 ++++++++++++++ 7 files changed, 454 insertions(+), 110 deletions(-) create mode 100644 packages/minisky-multicopter/tests/integration/conftest.py create mode 100644 packages/minisky-multicopter/tests/integration/test_perf.py diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md index 4d79f63..d31218e 100644 --- a/docs/multicopter-plan.md +++ b/docs/multicopter-plan.md @@ -1,6 +1,6 @@ # Multicopter support plan -Status: **in progress** — Phases 1 and 2 are implemented on this branch. +Status: **in progress** — Phases 1, 2 and 3 are implemented on this branch. ## Goal @@ -330,15 +330,18 @@ for the power model against a few hand-computed points. ### Phase 3 checklist -- [ ] Per-typecode constants dict in `perf.py`: `{battery_wh, CdS?, twr?}` from public spec - sheets, `d_range_max`-derived fallback for unlisted rotor types -- [ ] `perf.py`: `MulticopterPerf(OpenAP)` — required-thrust model, momentum-theory power from - `engnum · engpower`, per-aircraft SoC integration, envelope feedback in `limits()`; - stack command `BATT` -- [ ] Unit tests: power model vs hand-computed points; SoC monotonically decreasing; - envelope tightens below SoC threshold -- [ ] Sanity: MAVIC-class hover endurance in the 20–35 min range -- [ ] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green +- [x] Per-typecode constants dict in `perf.py`: `{battery_wh, cds?, twr?}` from public spec + sheets (MAVIC, PHAN4, M100, M200, M600), `d_range_max`-derived fallback for unlisted + rotor types (MNET, AMZN, HORSEFLY) +- [x] `perf.py`: `MulticopterPerf(OpenAP)` — required-thrust model, momentum-theory power from + `engnum · engpower` (stored in kW — converted at the model boundary), per-aircraft SoC + integration, envelope feedback in `limits()` (descent deliberately unrestricted); the + `BATT` command lives on the Multicopter entity and delegates at call time, like `HOVER`, + so it survives the reset double-swap; fifth entry in the plugin's `IMPLEMENTATIONS` +- [x] Unit tests: power model vs hand-computed points; SoC monotonically decreasing; + envelope tightens below SoC threshold (`tests/integration/test_perf.py`) +- [x] Sanity: MAVIC-class hover endurance in the 20–35 min range (≈27.7 min analytic) +- [x] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green ## Phase 4 — docs, scenarios, cleanup diff --git a/packages/minisky-multicopter/src/minisky_multicopter/__init__.py b/packages/minisky-multicopter/src/minisky_multicopter/__init__.py index 42b0adb..b4a499b 100644 --- a/packages/minisky-multicopter/src/minisky_multicopter/__init__.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/__init__.py @@ -17,12 +17,15 @@ HDG-yaws-the-nose semantics, fly-over route defaults. - ``ACTIVEWAYPOINT`` -> :class:`MulticopterActiveWaypoint` — fixed waypoint capture radius (the bank-angle turn distance degenerates at hover speeds). +- ``OPENAP`` -> :class:`MulticopterPerf` — electric performance: + required thrust, momentum-theory power, battery state of charge with + envelope feedback at low charge. Fixed-wing aircraft in the same simulation are untouched: every override calls ``super()`` and adjusts only the multicopter rows. Helicopters are out of scope — membership is a typecode set, not ``LIFT_ROTOR``. -Stack commands: MCOPT, YAW, YAWRATE, HOVER. +Stack commands: MCOPT, YAW, YAWRATE, HOVER, BATT. """ from __future__ import annotations @@ -34,6 +37,7 @@ from minisky_multicopter.autopilot import MulticopterAutopilot from minisky_multicopter.entity import Multicopter, get_multicopter from minisky_multicopter.kinematics import MulticopterKinematics +from minisky_multicopter.perf import MulticopterPerf __all__ = ( "Multicopter", @@ -41,6 +45,7 @@ "MulticopterActiveWaypoint", "MulticopterAutopilot", "MulticopterKinematics", + "MulticopterPerf", "get_multicopter", "plugin", ) @@ -60,6 +65,7 @@ def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: MulticopterAPorASAS, MulticopterAutopilot, MulticopterActiveWaypoint, + MulticopterPerf, ) ) diff --git a/packages/minisky-multicopter/src/minisky_multicopter/entity.py b/packages/minisky-multicopter/src/minisky_multicopter/entity.py index 524152c..f00541f 100644 --- a/packages/minisky-multicopter/src/minisky_multicopter/entity.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/entity.py @@ -3,7 +3,7 @@ Holds the plugin-owned per-aircraft arrays that mark which aircraft are multicopters and carry their decoupled body heading and yaw rate, plus the stack commands that read and write them (``MCOPT``, ``YAW``, ``YAWRATE``, -``HOVER``) and the hooks that keep the multicopter implementations selected +``HOVER``, ``BATT``) and the hooks that keep the multicopter implementations selected (on the first simulation step after loading, and again after every reset, which reverts all replaceables to their core defaults). @@ -38,6 +38,7 @@ ("APORASAS", "MULTICOPTERAPORASAS"), ("AUTOPILOT", "MULTICOPTERAUTOPILOT"), ("ACTIVEWAYPOINT", "MULTICOPTERACTIVEWAYPOINT"), + ("OPENAP", "MULTICOPTERPERF"), ) @@ -216,3 +217,21 @@ def hover( if not isinstance(ap, MulticopterAutopilot): return False, "HOVER: SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT first" return ap.hover(idx, duration, alt) + + @plugin_api.command(arguments="callsign") + def batt(self, idx: int) -> tuple[bool, str]: + """Report the battery state of charge, power draw and endurance. + + Arguments: + - idx: Aircraft callsign + """ + # Deferred import: the perf module imports this one. + from minisky_multicopter.perf import MulticopterPerf + + callsign = self.traffic.callsign[idx] + if not self.ismulticopter[idx]: + return False, f"BATT: {callsign} is not a multicopter (use MCOPT {callsign} ON)" + perf = self.traffic.perf + if not isinstance(perf, MulticopterPerf): + return False, "BATT: SELECTIMPL OPENAP MULTICOPTERPERF first" + return perf.batt(idx) diff --git a/packages/minisky-multicopter/src/minisky_multicopter/perf.py b/packages/minisky-multicopter/src/minisky_multicopter/perf.py index db34056..a93ec56 100644 --- a/packages/minisky-multicopter/src/minisky_multicopter/perf.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/perf.py @@ -1,4 +1,4 @@ -"""Electric performance for multicopters (Phase 3 skeleton). +"""Electric performance for multicopters. Fills the ``# TODO: implement thrust computation for rotor aircraft`` gap in the core :class:`OpenAP` model for multicopter rows: required thrust from @@ -8,19 +8,22 @@ integrated each step and feeds back into the flight envelope. Fixed-wing rows keep the ``super()`` behaviour untouched. Selected with -``SELECTIMPL OPENAP MULTICOPTERPERF`` once registered (it joins the plugin's -replacements when Phase 3 lands). +``SELECTIMPL OPENAP MULTICOPTERPERF`` (the plugin's hooks keep this +selected, like the other multicopter implementations). -The only data the shipped rotor ``aircraft.json`` lacks is battery capacity, -supplied by a small per-typecode spec-sheet constants dict here. No PyThrust +The only data the shipped rotor ``aircraft.json`` lacks is battery capacity +(``mfc`` is 0 for every rotor type), supplied by the small per-typecode +spec-sheet constants dict below; types without a public pack spec get an +energy derived from their ``d_range_max`` at cruise speed. No PyThrust anywhere: a measured-prop-data upgrade is future work (see the plan doc). Fidelity caveat: the power curve is momentum-theory shape (``P = P_max * (T / T_max) ** 1.5``), not measured prop data, so absolute forward-flight power is approximate and there is no terminal-voltage or -current modelling. Hover figures and the qualitative trends (power against -thrust, endurance, envelope shrink at low battery) are sound — the right -level for a traffic simulator. +current modelling — the envelope feedback is keyed on state of charge +directly. Hover figures and the qualitative trends (power against thrust, +endurance, envelope shrink at low battery) are sound — the right level for +a traffic simulator. """ from __future__ import annotations @@ -28,21 +31,58 @@ from typing import TYPE_CHECKING import numpy as np +from minisky import plugin as plugin_api +from minisky.tools import aero from minisky.traffic.performance.perfoap import OpenAP +from minisky_multicopter.entity import MULTICOPTER_TYPES, get_multicopter + if TYPE_CHECKING: from minisky.traffic import Traffic +#: State of charge below which the flight envelope is tightened [-]. +SOC_LOW = 0.2 + +#: Maximum-speed factor applied to low-battery multicopters [-]. +LOWBATT_SPD_FACTOR = 0.6 + +#: Maximum-climb-rate factor applied to low-battery multicopters [-]. +LOWBATT_VS_FACTOR = 0.5 + +#: Default thrust-to-weight ratio, typical for camera/delivery multirotors [-]. +DEFAULT_TWR = 2.0 + +#: Default flat-plate parasite drag area [m2]. +DEFAULT_CDS = 0.01 + +#: Cruise speed as a fraction of the envelope maximum, for the +#: range-derived battery-energy fallback [-]. +CRUISE_SPEED_FRACTION = 0.8 + +#: Spec-sheet constants per multicopter typecode: usable pack energy +#: ``battery_wh`` [Wh] (the one datum missing from the OpenAP rotor +#: ``aircraft.json``), and optional ``cds`` [m2] / ``twr`` [-] overrides. +#: MNET, AMZN and HORSEFLY have no public pack spec and fall back to an +#: energy derived from ``d_range_max`` at cruise speed. +CONSTANTS: dict[str, dict[str, float]] = { + "MAVIC": {"battery_wh": 43.6}, # 3830 mAh 11.4 V + "PHAN4": {"battery_wh": 81.3}, # 5350 mAh 15.2 V + "M100": {"battery_wh": 99.9}, # TB47D + "M200": {"battery_wh": 349.2}, # 2x TB55 + "M600": {"battery_wh": 599.4}, # 6x TB47S +} + +@plugin_api.replacement class MulticopterPerf(OpenAP): """OpenAP performance with an electric model for multicopter rows. Attributes: soc (ndarray): Battery state of charge [0-1]. - capacity (ndarray): Usable pack energy [J]. + capacity (ndarray): Usable pack energy [J]; 0 = no battery model. power (ndarray): Current electrical power draw [W] — the electric analogue of ``fuelflow``. - nrotors (ndarray): Number of rotors [-]. + twr (ndarray): Thrust-to-weight ratio at maximum thrust [-]. cds (ndarray): Flat-plate parasite drag area [m2]. """ @@ -52,37 +92,109 @@ def __init__(self, traffic: Traffic) -> None: self.soc = np.array([]) self.capacity = np.array([]) self.power = np.array([]) - self.nrotors = np.array([]) + self.twr = np.array([]) self.cds = np.array([]) def create(self, n: int = 1) -> None: """Seed the electric state of n newly created aircraft. - Multicopters start on a full battery with the pack, rotor count and - drag area of their typecode; other aircraft get zeros. + Multicopters start on a full battery with the pack energy, drag area + and thrust-to-weight ratio of their typecode; other aircraft keep + zeros (no battery model). Seeded per row from the typecode — unlike + the base class this does not assume one type per batch, so a swap + onto an existing mixed fleet stays correct. Membership is checked by + typecode because the Multicopter entity may sit after this object in + the traffic tree, so its arrays cannot be relied upon here. Args: n: Number of aircraft that were appended to the traffic arrays. """ super().create(n) - # TODO: look up the per-typecode constants dict (battery Wh, CdS, - # thrust-to-weight ratio), seed soc = 1.0, capacity, nrotors and cds. + for offset, typecode in enumerate(self.traffic.typecode[-n:], start=-n): + actype = typecode.upper() + ac = self.coeff.acs_rotor.get(actype) + if actype not in MULTICOPTER_TYPES or ac is None: + continue + spec = CONSTANTS.get(actype, {}) + self.twr[offset] = spec.get("twr", DEFAULT_TWR) + self.cds[offset] = spec.get("cds", DEFAULT_CDS) + wh = spec.get("battery_wh") + if wh is None: + wh = self._range_derived_wh(ac, self.cds[offset], self.twr[offset]) + self.capacity[offset] = wh * 3600.0 + self.soc[offset] = 1.0 + + @staticmethod + def _range_derived_wh(ac: dict, cds: float, twr: float) -> float: + """Derive the pack energy of an unlisted type from its range [Wh]. + + Energy to fly the ``d_range_max`` of the OpenAP rotor entry at + cruise speed (a fixed fraction of the envelope maximum), evaluated + with the same momentum-theory power model used at runtime. + + Args: + ac: OpenAP rotor ``aircraft.json`` entry for the typecode. + cds: Flat-plate parasite drag area [m2]. + twr: Thrust-to-weight ratio at maximum thrust [-]. + """ + envelop = ac["envelop"] + d_range = envelop.get("d_range_max", 0.0) * 1000.0 + v_max = envelop.get("v_max", 0.0) + if d_range <= 0.0 or v_max <= 0.0: + return 0.0 + mass = 0.5 * (ac["oew"] + ac["mtow"]) + p_max = int(ac["n_engines"]) * ac["engines"][0][1] * 1000.0 # kW -> W + v_cruise = CRUISE_SPEED_FRACTION * v_max + drag = 0.5 * aero.rho0 * v_cruise**2 * cds + thrust = float(np.hypot(mass * aero.g0, drag)) + power = p_max * min(thrust / (twr * mass * aero.g0), 1.0) ** 1.5 + return power * (d_range / v_cruise) / 3600.0 + + def required_thrust(self) -> np.ndarray: + """Return the thrust each aircraft would need as a multicopter [N]. + + The thrust vector supports the weight — including any vertical + acceleration, ``m * sqrt(g^2 + az^2)`` — while its horizontal + component overcomes the flat-plate parasite drag of translation, + ``0.5 * rho * v^2 * CdS``. Meaningful for multicopter rows (other + rows have a zero drag area). + """ + traf = self.traffic + rho = aero.vdensity(traf.alt) + drag = 0.5 * rho * traf.tas**2 * self.cds + lift = self.mass * np.hypot(aero.g0, traf.kinematics.az) + return np.hypot(lift, drag) def update(self, dt: float = 1) -> None: """Update performance, then the electric model for multicopter rows. After the base update, computes the thrust each multicopter needs to - hold its current acceleration and overcome parasite drag, derives the - electrical power from the momentum-theory scaling, and integrates the - battery state of charge. + support its weight and overcome parasite drag, derives the + electrical power from the momentum-theory scaling + ``P = P_max * (T / T_max) ** 1.5`` anchored to the installed power, + and integrates the battery state of charge as an ideal energy tank. Args: - dt: Update timestep [s]. + dt: Update timestep [s] (unused; the simulation timestep is read + from the owning runtime, like the base class does elsewhere). """ super().update(dt) - # TODO: required thrust -> P = Pmax * (T / Tmax) ** 1.5 -> - # self.thrust/self.power - # TODO: integrate self.soc (ideal energy tank: soc -= P * dt / capacity) + mc = get_multicopter(self.traffic) + if mc is None: + return + m = mc.ismulticopter & (self.capacity > 0.0) + if not m.any(): + return + + thrust = self.required_thrust()[m] + t_max = self.twr[m] * self.mass[m] * aero.g0 + p_max = self.engnum[m] * self.engpower[m] * 1000.0 # engpower is in kW + power = p_max * np.clip(thrust / t_max, 0.0, 1.0) ** 1.5 + + self.thrust[m] = thrust + self.power[m] = power + simdt = self.traffic._get_simulation().simdt + self.soc[m] = np.clip(self.soc[m] - power * simdt / self.capacity[m], 0.0, 1.0) def limits( self, @@ -93,9 +205,10 @@ def limits( ) -> OpenAP.PerformanceLimits: """Clip the intended state to the flight envelope. - Runs the base envelope, then tightens the speed and climb-rate limits - of multicopter rows below a state-of-charge threshold, so performance - degrades as the battery empties. + Runs the base envelope, then tightens the maximum speed and climb + rate of multicopter rows below the state-of-charge threshold, so + performance degrades as the battery empties. Descent stays + unrestricted — a low battery should not keep an aircraft airborne. Args: intent_v_tas: Intended true airspeed [m/s]. @@ -106,26 +219,42 @@ def limits( Returns: Allowed TAS [m/s], vertical speed [m/s] and altitude [m]. """ - limits = super().limits(intent_v_tas, intent_vs, intent_h, ax) - # TODO: shrink vmax/vsmax for multicopter rows at low state of charge - return limits + allowed = super().limits(intent_v_tas, intent_vs, intent_h, ax) + mc = get_multicopter(self.traffic) + if mc is None: + return allowed + low = mc.ismulticopter & (self.capacity > 0.0) & (self.soc < SOC_LOW) + if not low.any(): + return allowed - def required_thrust(self) -> np.ndarray: - """Return the thrust each multicopter needs right now [N]. - - Hover and climb need ``m * sqrt(g^2 + a^2)`` spread over the rotors; - translating additionally costs a flat-plate parasite term - ``0.5 * rho * v^2 * CdS``. - """ - # TODO - return np.zeros(len(self.mass)) + tas, vs, alt = allowed + tas[low] = np.minimum(tas[low], LOWBATT_SPD_FACTOR * self.vmax[low]) + vs[low] = np.minimum(vs[low], LOWBATT_VS_FACTOR * self.vsmax[low]) + return self.PerformanceLimits(tas, vs, alt) def batt(self, idx: int) -> tuple[bool, str]: - """Report battery state of charge, power draw and endurance estimate. + """Report battery state of charge, power draw and endurance. + + Backs the ``BATT`` stack command declared on the Multicopter entity, + which delegates here at call time so the command survives the + performance instance being swapped on reset. + + Args: + idx: Aircraft index. - Arguments: - - idx: Aircraft callsign + Returns: + tuple: (success flag, report message). """ - # TODO: report soc/power and a remaining-endurance estimate at the - # current draw - return False, "BATT: not implemented yet" + callsign = self.traffic.callsign[idx] + if self.capacity[idx] <= 0.0: + return False, f"BATT: no battery model for {callsign} ({self.actype[idx]})" + + soc = self.soc[idx] + power = self.power[idx] + if soc <= 0.0: + endurance = "battery empty" + elif power > 0.0: + endurance = f"endurance {soc * self.capacity[idx] / power / 60.0:.0f} min" + else: + endurance = "endurance --" + return True, f"BATT {callsign}: {soc:.0%}, drawing {power:.0f} W, {endurance}" diff --git a/packages/minisky-multicopter/tests/integration/conftest.py b/packages/minisky-multicopter/tests/integration/conftest.py new file mode 100644 index 0000000..f48facd --- /dev/null +++ b/packages/minisky-multicopter/tests/integration/conftest.py @@ -0,0 +1,64 @@ +"""Shared fixtures for the multicopter integration tests. + +The plugin tests run on their own runtime with the MULTICOPTER plugin +loaded — the plugin registers replaceable implementations on load and +selects them from its hooks (first step after load, and after every reset), +so the shared session runtime of the other integration tests keeps the core +implementations. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Iterator + +import pytest +from minisky import MiniSky +from minisky.core.config import MiniSkyConfig +from minisky.simulation import Simulation +from tests._types import RunCommand, StepUntil + + +@pytest.fixture(scope="module") +def mcruntime() -> Iterator[MiniSky]: + """Module-wide MiniSky runtime with the MULTICOPTER plugin loaded.""" + instance = MiniSky(MiniSkyConfig()) + result = asyncio.run(instance.plugins.load("MULTICOPTER")) + assert result.is_ok(), result.err() + yield instance + asyncio.run(instance.aclose()) + + +@pytest.fixture +def mcsim(mcruntime: MiniSky) -> Simulation: + """Fresh simulation state; the plugin reset hook re-selects the impls.""" + mcruntime.simulation.reset() + mcruntime.console.read_output_buffer() # drain "Simulation reset" echo + return mcruntime.simulation + + +@pytest.fixture +def run_mc(mcruntime: MiniSky, mcsim: Simulation) -> RunCommand: + """Queue a stack command, step the sim, and return the last echoed output.""" + + def _run(cmd: str, steps: int = 1) -> str: + mcruntime.commands.stack(cmd) + for _ in range(steps): + mcruntime.simulation.step() + return mcruntime.console.read_output_buffer() + + return _run + + +@pytest.fixture +def step_mc(mcruntime: MiniSky) -> StepUntil: + """Step the simulation until a predicate holds, failing after max_steps.""" + + def _step(pred: Callable[[], bool], max_steps: int = 600) -> int: + for i in range(max_steps): + mcruntime.simulation.step() + if pred(): + return i + pytest.fail(f"condition not met within {max_steps} simulation steps") + + return _step diff --git a/packages/minisky-multicopter/tests/integration/test_multicopter.py b/packages/minisky-multicopter/tests/integration/test_multicopter.py index c42a5f0..b5d3115 100644 --- a/packages/minisky-multicopter/tests/integration/test_multicopter.py +++ b/packages/minisky-multicopter/tests/integration/test_multicopter.py @@ -1,10 +1,8 @@ """Integration tests for the MULTICOPTER plugin (Phase 2). -Driven through the stack, like test_stack.py. The plugin registers -replaceable implementations on load and selects them from its hooks (first -step after load, and after every reset), so these tests run on their own -runtime instead of the shared session runtime — the other integration tests -keep the core implementations. +Driven through the stack, like test_stack.py. These tests run on the +plugin-loaded `mcruntime` from conftest.py instead of the shared session +runtime — the other integration tests keep the core implementations. The default simulation timestep is 1 s; yaw rates are lowered where a slew must be observable across steps. @@ -12,36 +10,13 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable, Iterator - -import pytest from minisky import MiniSky -from minisky.core.config import MiniSkyConfig from minisky.simulation import Simulation from minisky.tools.aero import ft from minisky_multicopter.autopilot import MulticopterAutopilot from tests._types import RunCommand, StepUntil -@pytest.fixture(scope="module") -def mcruntime() -> Iterator[MiniSky]: - """Module-wide MiniSky runtime with the MULTICOPTER plugin loaded.""" - instance = MiniSky(MiniSkyConfig()) - result = asyncio.run(instance.plugins.load("MULTICOPTER")) - assert result.is_ok(), result.err() - yield instance - asyncio.run(instance.aclose()) - - -@pytest.fixture -def mcsim(mcruntime: MiniSky) -> Simulation: - """Fresh simulation state; the plugin reset hook re-selects the impls.""" - mcruntime.simulation.reset() - mcruntime.console.read_output_buffer() # drain "Simulation reset" echo - return mcruntime.simulation - - class TestPluginDiscovery: def test_plugin_listed_from_entry_point(self, mcruntime: MiniSky) -> None: result = mcruntime.plugins.listing() @@ -49,33 +24,6 @@ def test_plugin_listed_from_entry_point(self, mcruntime: MiniSky) -> None: assert "MULTICOPTER" in result.unwrap() -@pytest.fixture -def run_mc(mcruntime: MiniSky, mcsim: Simulation) -> RunCommand: - """Queue a stack command, step the sim, and return the last echoed output.""" - - def _run(cmd: str, steps: int = 1) -> str: - mcruntime.commands.stack(cmd) - for _ in range(steps): - mcruntime.simulation.step() - return mcruntime.console.read_output_buffer() - - return _run - - -@pytest.fixture -def step_mc(mcruntime: MiniSky) -> StepUntil: - """Step the simulation until a predicate holds, failing after max_steps.""" - - def _step(pred: Callable[[], bool], max_steps: int = 600) -> int: - for i in range(max_steps): - mcruntime.simulation.step() - if pred(): - return i - pytest.fail(f"condition not met within {max_steps} simulation steps") - - return _step - - class TestPluginWiring: def test_implementations_selected_after_reset( self, mcruntime: MiniSky, mcsim: Simulation @@ -85,6 +33,7 @@ def test_implementations_selected_after_reset( assert type(mcruntime.traffic.aporasas).__name__ == "MulticopterAPorASAS" assert type(mcruntime.traffic.ap).__name__ == "MulticopterAutopilot" assert type(mcruntime.traffic.actwp).__name__ == "MulticopterActiveWaypoint" + assert type(mcruntime.traffic.perf).__name__ == "MulticopterPerf" def test_membership_from_typecode(self, mcruntime: MiniSky, run_mc: RunCommand) -> None: run_mc("CRE D1,MAVIC,52,4,90,100,20") diff --git a/packages/minisky-multicopter/tests/integration/test_perf.py b/packages/minisky-multicopter/tests/integration/test_perf.py new file mode 100644 index 0000000..075841a --- /dev/null +++ b/packages/minisky-multicopter/tests/integration/test_perf.py @@ -0,0 +1,174 @@ +"""Tests for the multicopter electric performance model (Phase 3). + +Power-model checks against hand-computed points, battery state-of-charge +integration and envelope feedback, plus the BATT stack command — on the +plugin-loaded `mcruntime` from conftest.py. + +Hand-computed anchor (MAVIC, hover): mass = (0.494 + 0.734) / 2 kg, +installed power P_max = 4 x 66.9 W, T_max = 2 * m * g, so hover power is +P_max * 0.5 ** 1.5 ~= 94.6 W from a 43.6 Wh pack ~= 27.7 min endurance. +""" + +from __future__ import annotations + +import itertools + +import numpy as np +import pytest +from minisky import MiniSky +from minisky.tools.aero import g0 +from minisky_multicopter.perf import ( + LOWBATT_SPD_FACTOR, + LOWBATT_VS_FACTOR, + SOC_LOW, + MulticopterPerf, +) +from tests._types import RunCommand, StepUntil + +#: Installed power of the MAVIC entry in the OpenAP rotor database [W]. +MAVIC_PMAX = 4 * 66.9 + +#: Effective mass of the MAVIC entry, mean of OEW and MTOW [kg]. +MAVIC_MASS = 0.5 * (0.494 + 0.734) + + +def hovering_mavic( + mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil +) -> MulticopterPerf: + """Create a MAVIC, bring it to a stationary hover, return the perf.""" + traf = mcruntime.traffic + run_mc("CRE D1,MAVIC,52,4,90,100,20") + run_mc("SPD D1 0") + kin = traf.kinematics + step_mc(lambda: traf.gs[0] == 0.0 and traf.vs[0] == 0.0 and kin.az[0] == 0.0, 30) + mcruntime.simulation.step() # one settled step so power reflects the hover + perf = traf.perf + assert isinstance(perf, MulticopterPerf) + return perf + + +class TestPowerModel: + def test_hover_power_matches_hand_computed_point( + self, mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil + ) -> None: + perf = hovering_mavic(mcruntime, run_mc, step_mc) + + # At hover: T = m * g (no drag, no vertical acceleration), and the + # momentum-theory scaling gives P = P_max * (1 / TWR) ** 1.5. + assert perf.thrust[0] == pytest.approx(MAVIC_MASS * g0, rel=1e-6) + assert perf.power[0] == pytest.approx(MAVIC_PMAX * 0.5**1.5, rel=1e-6) + + def test_translation_needs_more_thrust_than_hover( + self, mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil + ) -> None: + traf = mcruntime.traffic + run_mc("CRE D1,MAVIC,52,4,90,100,30") + step_mc(lambda: traf.gs[0] > 10.0, 20) + perf = traf.perf + assert isinstance(perf, MulticopterPerf) + + hover_thrust = MAVIC_MASS * g0 + assert perf.thrust[0] > hover_thrust + assert perf.power[0] > MAVIC_PMAX * 0.5**1.5 + + def test_soc_decreases_monotonically( + self, mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil + ) -> None: + perf = hovering_mavic(mcruntime, run_mc, step_mc) + + history = [float(perf.soc[0])] + for _ in range(20): + mcruntime.simulation.step() + history.append(float(perf.soc[0])) + assert all(a > b for a, b in itertools.pairwise(history)) + + def test_mavic_hover_endurance_within_sanity_bounds( + self, mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil + ) -> None: + perf = hovering_mavic(mcruntime, run_mc, step_mc) + + endurance_min = perf.soc[0] * perf.capacity[0] / perf.power[0] / 60.0 + assert 20.0 < endurance_min < 35.0 + + def test_fixed_wing_rows_have_no_electric_model( + self, mcruntime: MiniSky, run_mc: RunCommand + ) -> None: + run_mc("CRE KL001,A320,52,4,90,FL100,250", steps=5) + perf = mcruntime.traffic.perf + assert isinstance(perf, MulticopterPerf) + assert perf.capacity[0] == 0.0 + assert perf.power[0] == 0.0 + assert perf.fuelflow[0] > 0.0 + + def test_unlisted_types_get_range_derived_capacity( + self, mcruntime: MiniSky, run_mc: RunCommand + ) -> None: + # AMZN has no public pack spec: energy derives from d_range_max + run_mc("CRE D1,AMZN,52,4,90,100,20") + perf = mcruntime.traffic.perf + assert isinstance(perf, MulticopterPerf) + assert perf.capacity[0] > 0.0 + assert perf.soc[0] == 1.0 + + +class TestEnvelopeFeedback: + def test_envelope_tightens_below_soc_threshold( + self, mcruntime: MiniSky, run_mc: RunCommand + ) -> None: + # two steps: the first only processes CRE, the second runs perf.update + run_mc("CRE D1,MAVIC,52,4,90,100,20", steps=2) + perf = mcruntime.traffic.perf + assert isinstance(perf, MulticopterPerf) + vmax, vsmax = perf.vmax[0], perf.vsmax[0] + intent = (np.array([vmax]), np.array([vsmax]), np.array([100.0]), np.array([0.0])) + + perf.soc[0] = SOC_LOW + 0.1 + healthy = perf.limits(*intent) + assert healthy.tas[0] == pytest.approx(vmax) + assert healthy.vertical_speed[0] == pytest.approx(vsmax) + + perf.soc[0] = SOC_LOW - 0.1 + low = perf.limits(*intent) + assert low.tas[0] == pytest.approx(LOWBATT_SPD_FACTOR * vmax) + assert low.vertical_speed[0] == pytest.approx(LOWBATT_VS_FACTOR * vsmax) + + def test_low_battery_descent_stays_unrestricted( + self, mcruntime: MiniSky, run_mc: RunCommand + ) -> None: + run_mc("CRE D1,MAVIC,52,4,90,100,20", steps=2) + perf = mcruntime.traffic.perf + assert isinstance(perf, MulticopterPerf) + + perf.soc[0] = 0.0 + vsmin = perf.vsmin[0] + low = perf.limits( + np.array([1.0]), np.array([vsmin]), np.array([100.0]), np.array([0.0]) + ) + assert low.vertical_speed[0] == pytest.approx(vsmin) + + +class TestBattCommand: + def test_batt_reports_soc_power_and_endurance( + self, mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil + ) -> None: + hovering_mavic(mcruntime, run_mc, step_mc) + report = run_mc("BATT D1") + assert "BATT D1" in report + assert "%" in report + assert "W" in report + assert "min" in report + + def test_batt_rejects_non_multicopter( + self, mcruntime: MiniSky, run_mc: RunCommand + ) -> None: + run_mc("CRE KL001,A320,52,4,90,FL100,250") + assert "not a multicopter" in run_mc("BATT KL001") + + def test_batt_reports_no_model_for_custom_multicopter( + self, mcruntime: MiniSky, run_mc: RunCommand + ) -> None: + # MCOPT ON gives an A320 multicopter kinematics, but there is no + # rotor performance entry to build an electric model from. + run_mc("CRE KL001,A320,52,4,90,FL100,250") + run_mc("MCOPT KL001 ON") + assert "no battery model" in run_mc("BATT KL001") From bd0d0b7877b79e8aab301e97b60a760e9e6d807e Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:13:28 +0200 Subject: [PATCH 14/16] docs: add multicopter guide, example scenario, and replaceables architecture notes Completes phase 4 of the multicopter plan: new guides/multicopters.md (with nav entry), Kinematics entity and replaceable-components section in architecture.md, and the multicopter_delivery.scn example scenario in the plugin package. Marks the plan complete. --- docs/architecture.md | 38 +++++ docs/guides/multicopters.md | 147 ++++++++++++++++++ docs/multicopter-plan.md | 22 +-- .../scenarios/multicopter_delivery.scn | 47 ++++++ .../tests/integration/test_perf.py | 12 +- zensical.toml | 1 + 6 files changed, 249 insertions(+), 18 deletions(-) create mode 100644 docs/guides/multicopters.md create mode 100644 packages/minisky-multicopter/scenarios/multicopter_delivery.scn diff --git a/docs/architecture.md b/docs/architecture.md index 7fc7340..123e427 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,6 +116,13 @@ subsystems that act on it each timestep: - **Autopilot / FMS** ([`autopilot.py`](api/traffic.md)) — selected altitude/speed/heading, LNAV/VNAV logic following a [`Route`][minisky.traffic.route.Route] of waypoints. +- **Kinematics** ([`Kinematics`][minisky.traffic.kinematics.Kinematics], `runtime.traffic.kinematics`) — + the flight integration: accelerate towards the commanded airspeed within the + performance limits, turn towards the commanded heading at the bank-angle turn + rate, combine with wind into a ground-speed vector, and integrate position. + It lives in its own first-level entity (rather than on `Traffic` itself) so + that alternative flight models can be swapped in — see + [replaceable components](#replaceable-components) below. - **Conflict detection** (`traffic/asas/detection.py`) — pairwise state-based detection within a lookahead time against a protected zone (default 5 NM / 1000 ft, configurable in the [config file](guides/configuration.md)). Candidate pairs are pre-selected with a KD-tree on projected @@ -131,6 +138,37 @@ Units follow the BlueSky convention: internal state is SI (metres, m/s, seconds, while stack commands and scenario files use aviation units (FL/ft, knots, Mach) that the argument parsers convert on the way in. +## Replaceable components + +The traffic subsystems above are *replaceable*: a subclass can be selected in +place of the default implementation at runtime, and the live instance is swapped +immediately (its per-aircraft arrays re-seeded for the current fleet). The +runtime's [`ReplaceableManager`][minisky.core.trafficarrays.ReplaceableManager] +registers these base classes: + +| Name | Base class | Role | +| --- | --- | --- | +| `ACTIVEWAYPOINT` | [`ActiveWaypoint`][minisky.traffic.activewpdata.ActiveWaypoint] | Active-leg data and waypoint-capture criterion | +| `APORASAS` | [`APorASAS`][minisky.traffic.aporasas.APorASAS] | Pilot logic selecting between autopilot and resolution commands | +| `AUTOPILOT` | [`Autopilot`][minisky.traffic.autopilot.Autopilot] | FMS / LNAV / VNAV guidance | +| `CONFLICTDETECTION` | `ConflictDetection` | Pairwise conflict detection | +| `CONFLICTRESOLUTION` | `ConflictResolution` | Conflict resolution (the core registers `MVP`) | +| `KINEMATICS` | [`Kinematics`][minisky.traffic.kinematics.Kinematics] | Flight integration | +| `OPENAP` | [`OpenAP`][minisky.traffic.performance.perfoap.OpenAP] | Aircraft performance | + +Select an implementation with the `SELECTIMPL` stack command +(`SELECTIMPL AUTOPILOT MYAUTOPILOT`; without arguments it lists the +alternatives), or programmatically through +`runtime.traffic.select_implementation`. `RESET` reverts every replaceable to +its default implementation. + +Plugins provide implementations by decorating a subclass with +[`@plugin.replacement`][minisky.plugin.plugin_decorators.replacement] and +declaring it when the build finishes; such replacements are local to the +runtime that loaded the plugin. The +[multicopter plugin](guides/multicopters.md) is a worked example: it registers +subclasses of five of these bases and keeps them selected from its hooks. + ## I/O: how output gets back to you Simulation code reports through [`runtime.console`][minisky.simulation.console.ConsoleIO] (a diff --git a/docs/guides/multicopters.md b/docs/guides/multicopters.md new file mode 100644 index 0000000..307e3d6 --- /dev/null +++ b/docs/guides/multicopters.md @@ -0,0 +1,147 @@ +# Flying multicopters + +The `MULTICOPTER` plugin (in `packages/minisky-multicopter/`) makes MiniSky fly +small electric multirotors — DJI MAVIC/M600-class camera drones and +Amazon/Matternet-style delivery drones — with multicopter behaviour that the +fixed-wing core cannot express: + +- **Hover and yaw at zero speed.** A multicopter can stop (`SPD 0`), hold + position, and rotate its nose in place at a yaw-rate limit instead of the + bank-angle turn rate (which degenerates as speed approaches zero). +- **Velocity decoupled from heading.** The direction of travel follows the + *track* commanded by LNAV or conflict resolution, while the nose points + wherever `YAW` put it — the aircraft can strafe, and course changes at + waypoints are thrust redirections with no turn radius. +- **Electric performance.** Thrust-based power draw, a battery state of + charge, and an envelope that tightens when the battery runs low. + +Fixed-wing aircraft in the same simulation are untouched, and helicopters are +deliberately out of scope (the `EC35` keeps its default envelope-only +behaviour). + +## Loading the plugin + +Add an (empty) plugin table to your [config file](configuration.md): + +```toml +[plugins.multicopter] +``` + +or load it at runtime — from a scenario or the console with +`PLUGINS LOAD MULTICOPTER`, or from Python with +`await runtime.plugins.load("MULTICOPTER")`. + +On the first simulation step after loading, the plugin selects its +implementations of five [replaceable traffic components](../architecture.md#replaceable-components) +(kinematics, pilot logic, autopilot, waypoint capture, and performance), and +re-selects them after every `RESET`. A manual `SELECTIMPL` afterwards is +respected until the next reset. + +## Which aircraft count as multicopters + +Membership follows the aircraft type: creating any of + +``` +MAVIC PHAN4 M100 M200 M600 MNET AMZN HORSEFLY +``` + +produces a multicopter. Everything else — including the `EC35` helicopter — +keeps stock behaviour. Query or override per aircraft with `MCOPT`: + +``` +MCOPT DRONE1 -> MCOPT DRONE1: ON +MCOPT DRONE1 OFF -> back to fixed-wing kinematics +``` + +## Commands + +| Command | What it does | +| --- | --- | +| `MCOPT acid [ON/OFF]` | Query or set whether an aircraft is flown as a multicopter | +| `YAW acid hdg` | Point the nose at a body heading; the velocity vector is unaffected | +| `YAWRATE acid [rate]` | Query or set the maximum yaw rate (default 90 deg/s) | +| `HOVER acid [time] [alt]` | Hold position — optionally for `time` seconds, optionally moving vertically to `alt` | +| `BATT acid` | Report battery state of charge, power draw, and endurance | + +By default the nose follows the direction of travel, like any aircraft. The +first `YAW` decouples them: the nose stays where you put it while LNAV, +conflict resolution, and waypoint corners steer the velocity vector +underneath. For a multicopter, `HDG` is an alias of `YAW` — there is no +command that couples heading back to track short of `MCOPT acid OFF`. + +`HOVER` is composable rather than a scripted manoeuvre: + +- `HOVER DRONE1` brakes to a stop and holds position until `LNAV DRONE1 ON` + resumes the route. +- `HOVER DRONE1 30` holds for 30 seconds — counted while actually stopped at + the selected altitude — then restores the saved LNAV/VNAV/speed state. +- `HOVER DRONE1 30 200` also moves vertically to 200 ft while holding + position; the route resumes at the hover altitude. +- Re-issuing `HOVER` while hovering updates the hold time and altitude, and a + plain `ALT` changes the hover altitude too. + +A delivery profile is therefore just scenario vocabulary: fly a route, `HOVER` +over the drop point at a descent altitude, climb back with `ALT`, `LNAV ON` +to fly home. See `packages/minisky-multicopter/scenarios/multicopter_delivery.scn` +for a complete example: + +```bash +uv run minisky run --scenario ../minisky-multicopter/scenarios/multicopter_delivery.scn --speed 10 +``` + +(scenario paths resolve relative to the `packages/minisky/` package root). + +Multicopters fly point-to-point: their waypoints default to fly-over with a +fixed 10 m capture radius, and `SPD 0` is valid (the rotor envelopes have a +negative minimum speed). One thing to keep in mind: LNAV commands only the +track, so a multicopter created at rest also needs a speed source — a `SPD` +command or waypoint speed constraints — before it starts moving. + +## The battery model + +Multicopter rows replace fuel flow with an electric model, updated every +simulation step: + +- **Required thrust** supports the weight and overcomes flat-plate parasite + drag: `T = hypot(m * sqrt(g^2 + az^2), 0.5 * rho * v^2 * CdS)`. +- **Electrical power** follows a momentum-theory scaling anchored to the + installed power from the OpenAP rotor coefficients + (`P = P_max * (T / T_max)^1.5`, with `T_max = TWR * m * g` and a default + thrust-to-weight ratio of 2). +- **State of charge** integrates that power against a usable pack energy — + an ideal energy tank, with no terminal-voltage or current modelling. +- **Envelope feedback**: below 20% charge the maximum speed shrinks to 60% + and the maximum climb rate to 50%. Descent stays unrestricted — a low + battery should not keep an aircraft airborne. + +`BATT` reports the live state: + +``` +BATT DRONE1 -> BATT DRONE1: 50%, drawing 1022 W, endurance 18 min +``` + +The absolute forward-flight power is approximate (momentum-theory shape, not +measured propeller data), but hover figures and the qualitative trends are +sound — hover endurance for the MAVIC comes out around 28 minutes. A +measured-data upgrade path is sketched in the plan document +(`docs/multicopter-plan.md`). + +## Adding a new multicopter type + +Three places, all keyed on the ICAO-style typecode: + +1. **Performance data** — add a rotor entry to + `packages/minisky/minisky/data/performance/openap/rotor/aircraft.json` + with the masses (`oew`, `mtow`, in kg), `n_engines`, per-engine power + (`engines`, in kW), and the flight envelope (`v_min`/`v_max`, + `vs_min`/`vs_max`, `h_max`, `d_range_max`). Give `v_min` a negative value + so the aircraft may stop. +2. **Membership** — add the typecode to `MULTICOPTER_TYPES` in + `minisky_multicopter/entity.py`. +3. **Battery capacity** — add a `CONSTANTS` entry in + `minisky_multicopter/perf.py` with the usable pack energy `battery_wh` + (the one datum the rotor `aircraft.json` cannot carry — its `mfc` fuel + field is unused for electric types), plus optional `cds` (flat-plate drag + area, m²) and `twr` (thrust-to-weight ratio) overrides. Without an entry + the pack energy is derived from the envelope's `d_range_max` flown at + cruise speed, like the delivery-drone types that have no public pack spec. diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md index d31218e..cf93ec2 100644 --- a/docs/multicopter-plan.md +++ b/docs/multicopter-plan.md @@ -1,6 +1,6 @@ # Multicopter support plan -Status: **in progress** — Phases 1, 2 and 3 are implemented on this branch. +Status: **complete** — all four phases are implemented on this branch. ## Goal @@ -348,20 +348,24 @@ for the power model against a few hand-computed points. - New guide `docs/guides/multicopters.md`: creating multicopters, hover/yaw commands, battery model, how to add a new type (rotor `aircraft.json` entry + constants dict). - Update `docs/architecture.md` with the `Kinematics` entity and the replaceable list. -- Example scenario `scenarios/multicopter_delivery.scn`: create, fly a route, hover at a - delivery point, yaw for "camera", return; exercises everything above. +- Example scenario `packages/minisky-multicopter/scenarios/multicopter_delivery.scn`: create, + fly a route, hover at a delivery point, yaw for "camera", return; exercises everything + above. Lives in the plugin package because it depends entirely on the plugin. - Regenerate `docs/reference/commands.md` (`uv run minisky commands docs`) after adding the stack commands (`MCOPT`, `YAW`, `YAWRATE`, `HOVER`, `BATT`). - `ruff`, `pyright`, full test suite green at every phase boundary. ### Phase 4 checklist -- [ ] `docs/guides/multicopters.md` (usage, commands, battery model, adding a new type) -- [ ] Update `docs/architecture.md`: `Kinematics` entity + replaceable list -- [ ] `scenarios/multicopter_delivery.scn` exercising create → route → hover-delivery - (`HOVER` + `ALT` + `LNAV ON`) → return -- [ ] Regenerate `docs/reference/commands.md` (`uv run minisky commands docs`) -- [ ] Final sweep: `uv run pytest`, `uv run ruff check .`, `uv run pyright` +- [x] `docs/guides/multicopters.md` (usage, commands, battery model, adding a new type) +- [x] Update `docs/architecture.md`: `Kinematics` entity + replaceable list +- [x] `packages/minisky-multicopter/scenarios/multicopter_delivery.scn` exercising + create → route → hover-delivery (`HOVER` + `ALT` + `LNAV ON`) → return +- [x] Verify `docs/reference/commands.md` regeneration — the reference is now built by the + `command_docs()` macro at site build (`just docs-build`), so there is no separate gen + step; the table is core-only by design and the plugin commands are documented in + `docs/guides/multicopters.md` +- [x] Final sweep: `uv run pytest`, `uv run ruff check .`, `uv run pyright` ## Sequencing and effort diff --git a/packages/minisky-multicopter/scenarios/multicopter_delivery.scn b/packages/minisky-multicopter/scenarios/multicopter_delivery.scn new file mode 100644 index 0000000..209f2f2 --- /dev/null +++ b/packages/minisky-multicopter/scenarios/multicopter_delivery.scn @@ -0,0 +1,47 @@ +# Multicopter delivery mission: an M600 flies a package to a drop point, +# hovers down for the delivery, and returns home — exercising the +# MULTICOPTER plugin (packages/minisky-multicopter): decoupled yaw, the +# composable HOVER primitive, and the battery model. +00:00:00.00>PLUGINS LOAD MULTICOPTER + +# The drop point, 1.4 km east of home. +00:00:01.00>DEFWPT HOME 52.0000 4.0000 +00:00:01.00>DEFWPT DROP 52.0000 4.0200 + +# Create the drone at home. M600 is a multicopter typecode, so MCOPT +# reports ON without being set. +00:00:02.00>CRE PH1 M600 52.0 4.0 90 100 20 +00:00:03.00>MCOPT PH1 + +# Out and back. Multicopter waypoints are fly-over with a fixed capture +# radius: the course change at DROP is a thrust redirection, no turn arc. +00:00:03.00>ADDWPT PH1 DROP +00:00:03.00>ADDWPT PH1 HOME + +# The delivery, composed on the waypoint stack: when PH1 reaches DROP, +# hold position for 30 s while descending to 50 ft, and yaw the camera +# south — the nose stays there for the rest of the flight, so the return +# leg is flown strafing (track west, nose south). +00:00:03.00>AT PH1 DROP DO PH1 HOVER 30 50 +00:00:03.00>AT PH1 DROP DO PH1 YAW 180 +# When PH1 arrives back at HOME, stop and hover indefinitely. +00:00:03.00>AT PH1 HOME DO PH1 HOVER + +00:00:04.00>LNAV PH1 ON + +# Enroute to the drop point. +00:01:00.00>POS PH1 +00:01:00.00>BATT PH1 + +# Mid-delivery: stopped over DROP at 50 ft, nose south. +00:02:30.00>POS PH1 +00:02:30.00>BATT PH1 + +# The 30 s hold has expired and the route resumed at the hover altitude; +# climb back to cruise altitude for the return leg. +00:03:10.00>ALT PH1 100 + +# Hovering at home with the mission complete. +00:05:30.00>POS PH1 +00:05:30.00>BATT PH1 +00:05:40.00>QUIT diff --git a/packages/minisky-multicopter/tests/integration/test_perf.py b/packages/minisky-multicopter/tests/integration/test_perf.py index 075841a..983025e 100644 --- a/packages/minisky-multicopter/tests/integration/test_perf.py +++ b/packages/minisky-multicopter/tests/integration/test_perf.py @@ -32,9 +32,7 @@ MAVIC_MASS = 0.5 * (0.494 + 0.734) -def hovering_mavic( - mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil -) -> MulticopterPerf: +def hovering_mavic(mcruntime: MiniSky, run_mc: RunCommand, step_mc: StepUntil) -> MulticopterPerf: """Create a MAVIC, bring it to a stationary hover, return the perf.""" traf = mcruntime.traffic run_mc("CRE D1,MAVIC,52,4,90,100,20") @@ -141,9 +139,7 @@ def test_low_battery_descent_stays_unrestricted( perf.soc[0] = 0.0 vsmin = perf.vsmin[0] - low = perf.limits( - np.array([1.0]), np.array([vsmin]), np.array([100.0]), np.array([0.0]) - ) + low = perf.limits(np.array([1.0]), np.array([vsmin]), np.array([100.0]), np.array([0.0])) assert low.vertical_speed[0] == pytest.approx(vsmin) @@ -158,9 +154,7 @@ def test_batt_reports_soc_power_and_endurance( assert "W" in report assert "min" in report - def test_batt_rejects_non_multicopter( - self, mcruntime: MiniSky, run_mc: RunCommand - ) -> None: + def test_batt_rejects_non_multicopter(self, mcruntime: MiniSky, run_mc: RunCommand) -> None: run_mc("CRE KL001,A320,52,4,90,FL100,250") assert "not a multicopter" in run_mc("BATT KL001") diff --git a/zensical.toml b/zensical.toml index 4904aa6..77b0411 100644 --- a/zensical.toml +++ b/zensical.toml @@ -17,6 +17,7 @@ nav = [ { "Control console" = "guides/console.md" }, { "Python library" = "guides/python-api.md" }, { "Writing plugins" = "guides/plugins.md" }, + { "Flying multicopters" = "guides/multicopters.md" }, { "Streaming to tangram" = "guides/tangram.md" }, ] }, { "Reference" = [ From 1e0072cec966084cfa4e944344b84891d028bb0d Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:19:00 +0200 Subject: [PATCH 15/16] delete multicopter plan --- docs/multicopter-plan.md | 414 --------------------------------------- 1 file changed, 414 deletions(-) delete mode 100644 docs/multicopter-plan.md diff --git a/docs/multicopter-plan.md b/docs/multicopter-plan.md deleted file mode 100644 index cf93ec2..0000000 --- a/docs/multicopter-plan.md +++ /dev/null @@ -1,414 +0,0 @@ -# Multicopter support plan - -Status: **complete** — all four phases are implemented on this branch. - -## Goal - -Make MiniSky able to simulate small electric multirotors ("multicopters": DJI MAVIC/M600/PHAN4-class, -Amazon/Matternet-style delivery drones) with realistic behaviour: - -1. **Hover and yaw** — change heading at zero ground/airspeed, limited by a yaw rate instead of a - bank-angle turn rate. -2. **Decoupled track and heading** — change direction of travel without rotating the body. A - multicopter redirects thrust; its velocity vector (track) is independent of where the nose - points (heading). Course changes at waypoints are immediate, with no turn radius. -3. **Electric performance** — battery state of charge, power draw as a function of speed and - required thrust, and a flight envelope that degrades as the battery sags. - -Everything lands as a **plugin** plus one small, behaviour-preserving core refactor. This follows -the project direction: keep the core minimal, make behaviour hackable from outside. - -### Explicit non-goals - -- **Helicopters.** OpenAP's rotor list includes the EC35 (a crewed helicopter). It is deliberately - *not* covered: it keeps today's envelope-only performance and bank-to-turn kinematics. This is - why the feature is named *multicopter*, not *drone* (wrong axis: describes crew, not lift type) - and not *rotorcraft* (would promise helicopter support). -- **Aeroelastic / attitude-level dynamics.** We stay at the kinematic point-mass level of the rest - of the simulator; "heading" is the only attitude state. -- **PyThrust, for now.** The measured-prop-data pipeline is deferred entirely to future work - (see the last section). Phase 3 uses only the OpenAP rotor coefficients already shipped in - `data/performance/openap/rotor/aircraft.json` plus a few spec-sheet constants. If the pipeline - is ever built, it uses PyThrust's *data*, never its code at runtime, and it is not added to - `pyproject.toml`. - -## What the codebase already provides - -The exploration that produced this plan found MiniSky closer to multicopter-ready than expected: - -- **Rotor performance path exists.** `minisky/traffic/performance/perfoap.py` distinguishes - `LIFT_FIXWING` from `LIFT_ROTOR`. Creating an aircraft with a rotor typecode (`CRE D1 MAVIC ...`) - gets envelope-only performance: no drag polar, fixed `axmax = 3.5 m/s²`, static limits. - Shipped rotor typecodes: `EC35, M600, AMZN, MNET, PHAN4, M100, M200, MAVIC, HORSEFLY`. -- **Zero speed already passes the performance clamp.** Rotor envelopes have *negative* `vmin` - (e.g. M600: −18 m/s), and `OpenAP.limits()` clamps rotor TAS directly against `[vmin, vmax]`, - so `SPD D1 0` survives. Fixed-wing aircraft are clamped to stall speed and cannot do this. -- **The replaceable pattern.** `ReplaceableManager` (`minisky/core/trafficarrays.py`) owns a - curated set of replaceable bases per runtime (registered in `MiniSky.__init__`) and hot-swaps - the instance on `traf` via `SELECTIMPL`, carrying per-aircraft arrays over and dispatching - stack commands through the current instance. Plugins register implementations runtime-locally - with `@plugin.replacement` + `context.finish(replacements=...)`; - `packages/minisky-example-customautopilot` demonstrates the pattern. -- **Plugin machinery.** Plugins are packages exposing a `Plugin` declaration through the - `minisky.plugins` entry-point group. Timed `preupdate`/`update`/`reset` hooks via - `@plugin.hook`, `plugin.Entity` + `settrafarrays()` for per-aircraft state that grows/shrinks - with the fleet, and `@plugin.command` for new commands. - -## What blocks the two manoeuvring behaviours - -Both live in `Traffic` (`minisky/traffic/traffic.py`), in ~100 lines of kinematics: - -1. **`update_airspeed()`** derives turn rate from the bank-angle triangle, - `ω = g·tan(φ)/max(tas, eps)` with `eps = 0.01`. At TAS → 0 this *explodes* (≈26 000 °/s), so - heading snaps instantly — hover-yaw "works" by numerical accident, with no physical yaw-rate - limit. -2. **`update_groundspeed()`** hard-couples `trk = hdg` and points the velocity vector along the - heading. The aircraft must fly where its nose points. Upstream, `APorASAS.update()` - (`minisky/traffic/aporasas.py`) converts the desired *track* into a desired *heading* (with wind - correction), baking the same coupling into the command path. - -Everything downstream is already agnostic: conflict detection/resolution, the stream snapshot and -LNAV all work off `trk`/`gs`, which remain well-defined when decoupled from `hdg`. - -`Traffic` is technically registered as a replaceable (`SELECTIMPL TRAFFIC ...` lists it), but the -hot-swap helper only swaps instances found *on* `traf` — it cannot replace the root object itself, -and in CLI runs plugins load after `minisky.init()` has constructed `traf`. Hence Phase 1. - ---- - -## Phase 1 — core refactor: extract `Kinematics` as a replaceable entity - -**The only core change in this plan.** Behaviour-preserving. - -Move `update_airspeed()`, `update_groundspeed()`, `update_pos()` and the state they own -(`ax`, `az`, `swhdgsel`, `swaltsel`) out of `Traffic` into a new first-level class: - -```python -# minisky/traffic/kinematics.py -class Kinematics(TrafficArrays): - """Integrates airspeed, heading, vertical speed and position each step. - - Replaceable via SELECTIMPL KINEMATICS ; plugins may subclass to - change how (a subset of) aircraft fly. - """ - def update(self): - self.update_airspeed() - self.update_groundspeed() - self.update_pos() -``` - -- Instantiated as `self.kinematics = Kinematics()` inside `Traffic.__init__`'s - `settrafarrays()` block; `Traffic.update()` calls `self.kinematics.update()` in place of the - three method calls. -- Registered as a replaceable base in the runtime's `ReplaceableManager` (`minisky/runtime.py`, - together with `APorASAS` and `ActiveWaypoint`, which Phase 2 also swaps) — - `SELECTIMPL KINEMATICS MULTICOPTERKINEMATICS` then hot-swaps mid-simulation exactly like the - custom-autopilot example, with no further core support needed. -- Keep thin delegating properties on `Traffic` only if anything external reads `traf.ax` etc. - (grep first; `streaming.py` and `perfoap.py` read `traf.ax` — either keep `ax` on `Traffic` and - have `Kinematics` write it, or add a property. Decide during implementation; prefer keeping the - arrays registered on `Kinematics` and exposing properties on `Traffic`.) - -**Acceptance:** entire existing test suite passes unchanged; `SELECTIMPL KINEMATICS` lists the -base implementation; a trivial subclass registered from a test can be selected and reverts on -reset (mirror the existing `tests/integration/test_plugin.py` replaceable test). - -### Phase 1 checklist - -- [x] Create `minisky/traffic/kinematics.py`: `Kinematics(TrafficArrays)` with - `update_airspeed` / `update_groundspeed` / `update_pos` and the `ax`, `az`, `swhdgsel`, - `swaltsel` arrays moved over from `Traffic` (all four registered in `settrafarrays` and - seeded in `create()`; `az`/`swaltsel` were previously undeclared attributes materialised - by `update_airspeed`) -- [x] Instantiate as `self.kinematics = Kinematics()` inside `Traffic.__init__`'s - `settrafarrays()` block; `Traffic.update()` calls `self.kinematics.update()` -- [x] Grep external readers of the moved arrays: only `perfoap.py` reads `ax` - (`streaming.py` does not); pointed it at `traf.kinematics.ax` (no property needed) -- [x] Register `Kinematics` (plus `APorASAS` and `ActiveWaypoint` for Phase 2) as replaceable - bases in `MiniSky.__init__`; verify `SELECTIMPL KINEMATICS` lists the base implementation -- [x] Test: install a trivial subclass runtime-locally, select it, verify it takes effect and - reverts on reset (`tests/integration/test_kinematics.py`) -- [x] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green - -## Phase 2 — the `multicopter` plugin: membership + kinematics - -New workspace package `packages/minisky-multicopter/` (plugin ID `multicopter`), no core changes -beyond the Phase 1 base registration. One module per class, so each piece stays small and -readable: - -``` -packages/minisky-multicopter/ -├── pyproject.toml # workspace member; minisky.plugins entry point "multicopter" -└── src/minisky_multicopter/ - ├── __init__.py # Plugin declaration: build() mounts the entity, registers replacements - ├── entity.py # MULTICOPTER_TYPES + Multicopter Entity (ismulticopter, selhdg, yawrate), - │ # its stack commands (MCOPT, YAW, YAWRATE, HOVER) and the selection hooks - ├── kinematics.py # MulticopterKinematics(Kinematics) - ├── aporasas.py # MulticopterAPorASAS(APorASAS) - ├── autopilot.py # MulticopterAutopilot(Autopilot): hover primitive, fly-over defaults - ├── activewp.py # MulticopterActiveWaypoint(ActiveWaypoint): fixed capture radius - ├── perf.py # MulticopterPerf(OpenAP) + BATT (Phase 3) - └── data/ # generated perf maps + vendored PyThrust data (Phase 3) -``` - -Loader notes: the plugin manager discovers installed packages through the `minisky.plugins` -entry-point group without importing them; `__init__.py` exports the `Plugin` declaration and -imports the class modules. Replacements are registered runtime-locally when the plugin loads -(`@plugin.replacement` classes passed to `context.finish(replacements=...)`) and removed again -on shutdown. Selection is *not* automatic on load: the entity's `preupdate` hook selects the -four implementations on the first step after loading (via `traffic.select_implementation`), and -its `reset` hook re-selects them after every reset, which reverts all replaceables to their core -defaults. - -### Membership - -Selection must **not** be `traf.perf.lifttype == LIFT_ROTOR` — that would sweep in the EC35. - -- Module constant `MULTICOPTER_TYPES = {"MAVIC", "PHAN4", "M100", "M200", "M600", "MNET", "AMZN", - "HORSEFLY"}` (the OpenAP rotor list minus helicopters). -- A `plugin.Entity` subclass holding per-aircraft arrays registered via `settrafarrays()`: - - `ismulticopter` (bool) — set in `create()` from the typecode, manual override via a - `MCOPT acid ON/OFF` stack command for custom typecodes; - - `selhdg` (deg) — commanded body heading, decoupled from track; - - `yawrate` (deg/s) — default ≈ 90 °/s, settable per aircraft (`YAWRATE acid 120`). - -### `MulticopterKinematics(Kinematics)` - -Selected with `SELECTIMPL KINEMATICS MULTICOPTERKINEMATICS` (the plugin's hooks keep this -selected). Calls `super().update()` for the whole fleet, then re-integrates the multicopter -rows (mask `m`): - -```python -dt = minisky.sim.simdt -# 1. Yaw at a fixed rate — valid at tas = 0 (hover-yaw) -delhdg = (mc.selhdg[m] - traf.hdg[m] + 180) % 360 - 180 -traf.hdg[m] += np.clip(delhdg, -mc.yawrate[m] * dt, mc.yawrate[m] * dt) -traf.hdg[m] %= 360 -# 2. Velocity vector follows the commanded *track* (LNAV/ASAS), not the heading -trkcmd = np.radians(traf.aporasas.trk[m]) -traf.gsnorth[m] = traf.tas[m] * np.cos(trkcmd) + traf.windnorth[m] * airborne -traf.gseast[m] = traf.tas[m] * np.sin(trkcmd) + traf.windeast[m] * airborne -traf.gs[m] = np.hypot(traf.gsnorth[m], traf.gseast[m]) -traf.trk[m] = np.degrees(np.arctan2(traf.gseast[m], traf.gsnorth[m])) % 360 -# 3. Re-integrate lat/lon for these rows (base class integrated with the wrong velocity) -``` - -Implementation notes: - -- The base class integrates position before the override, so either re-integrate lat/lon for the - masked rows from the stored previous position, or (cleaner) restructure `Kinematics.update()` - into `update_airspeed / update_groundspeed / update_pos` calls so the subclass overrides the - first two and lets `update_pos()` run once, after. Prefer the latter — it is exactly what the - Phase 1 split enables. -- Heading no longer follows track for these rows, so also subclass or bypass the - `APorASAS` trk→hdg coupling: `MulticopterAPorASAS(APorASAS)` that, after `super().update()`, - overwrites `self.hdg[m]` with `mc.selhdg[m]`. (`SELECTIMPL APORASAS MULTICOPTERAPORASAS`.) -- `HDG` (stack) semantics for multicopters: route the existing `HDG` command value into - `mc.selhdg` (nose) and add `YAW acid 45` as an explicit alias; the FMS/LNAV track command - continues to steer the velocity vector. Default behaviour when no `selhdg` was ever set: - follow the track (nose-along-course), so routes look natural without extra commands. -- Turn-anticipation in the FMS assumes a turn radius; multicopters fly point-to-point. Keep it - simple first: the immediate-course-capture behaviour falls out of step 2 automatically because - `aporasas.trk` snaps to the new leg bearing at waypoint switch. - -### `MulticopterAutopilot(Autopilot)` — thin, mission-level - -A full autopilot rewrite is **not** needed: LNAV already outputs a *track* command -(`ap.trk = qdr2wp`), which is exactly what the decoupled kinematics consumes; fly-over waypoints -already exist (`ADDWPTMODE FLYOVER`); the vertical channel (`ALT`/`selvs`) is speed-independent, -so hover-climb/descend works with the plain `ALT` command; and turn-speed deceleration only -activates for `FLYTURN` waypoints, which multicopters won't use. - -A thin subclass (`SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT`) covers what the stock FMS cannot: - -- **A hover primitive** the FMS has no concept of — deliberately *composable*, not a scripted - manoeuvre (a "delivery" is written in the scenario from `HOVER` + `ALT` + `LNAV`): - - `HOVER acid [time] [alt]` — suspend LNAV/VNAV, hold position (commanded gs = 0), optionally - at a commanded altitude (moved to vertically, at a fixed position). With a `time` the route - auto-resumes once position and altitude have been held that long (the conditional-command - machinery cannot express "hold for 90 s"); without one the aircraft hovers until LNAV is - re-engaged. Repeating `HOVER` while hovering updates the hold; a plain `ALT` changes the - hover altitude too. -- **`HDG` semantics**: for multicopter rows `HDG` becomes an alias of `YAW` — it rotates the - nose only and leaves LNAV engaged. -- **Route defaults**: fly-over waypoints automatically for multicopter aircraft, so scenario - authors need no extra commands. -- **Low-speed capture (in `MulticopterActiveWaypoint`)**: `calcturn()` and the turn-distance - formulas are bank- and speed-based and degenerate at multicopter speeds; multicopter rows use - a fixed capture radius (10 m) instead. This must live in an `ActiveWaypoint` subclass, because - `ActiveWaypoint.reached()` recomputes `turndist` every step — clamping it from the autopilot - update would be overwritten before it is ever used. - -With this, the plugin registers and keeps selected four swaps — `KINEMATICS`, `APORASAS`, -`AUTOPILOT`, `ACTIVEWAYPOINT` — each subclass calling `super()` and adjusting only the masked -multicopter rows. - -**Acceptance (integration tests, driven through the stack like `test_stack.py`):** - -- `CRE D1 MAVIC ... ; SPD D1 0` → ground speed reaches 0 and stays; aircraft holds position. -- At `gs == 0`, `HDG D1 90` → heading slews at `yawrate`, position unchanged. -- In cruise, `YAW D1 0` while flying track 090 → `trk` stays 090, `hdg` goes to 0. -- Waypoint passage: course changes leg-to-leg with no overshoot arc. -- `HOVER D1 90` mid-route → position frozen for 90 s of sim time, then the route resumes. -- `HOVER D1 30 100` mid-route → vertical descent to 100 ft at a fixed position, 30 s hold, - route resumes at the hover altitude; a delivery profile composes from `HOVER`, `ALT` and - `LNAV ON` with lat/lon unchanged throughout. -- A fixed-wing aircraft in the same simulation behaves byte-identically to `main` (regression - guard for the fleet-wide hooks). - -### Phase 2 checklist - -- [x] `packages/minisky-multicopter/` workspace package with a `minisky.plugins` entry point - (`multicopter = "minisky_multicopter:plugin"`) and the `Plugin` declaration in - `__init__.py` -- [x] `entity.py`: `MULTICOPTER_TYPES` set + `Entity` with `ismulticopter`, `selhdg`, - `yawrate` arrays, auto-set from typecode in `create()`; stack commands `MCOPT`, - `YAW`, `YAWRATE`, `HOVER` (declared with `@plugin.command`) -- [x] `kinematics.py`: `MulticopterKinematics(Kinematics)` — yaw-rate-limited heading, - track-driven velocity vector, single `update_pos()` pass -- [x] `aporasas.py`: `MulticopterAPorASAS(APorASAS)` — skip trk→hdg coupling for - multicopter rows -- [x] `autopilot.py`: `MulticopterAutopilot(Autopilot)` — composable `HOVER [time] [alt]` - (the planned `DELIVER` was dropped as too use-case specific), `HDG`-yaws-the-nose, - fly-over route defaults; `activewp.py`: `MulticopterActiveWaypoint` fixed capture - radius -- [x] Plugin registers the four replacements on load; the entity's `preupdate` hook selects - them on the first step, and its `reset` hook re-selects after every reset (which - reverts all replaceables to the core defaults) -- [x] Integration tests: hover-hold, yaw at gs = 0, strafe (fixed nose, moving track), - leg-to-leg course capture, `HOVER` (timed, at altitude, composed with `ALT`/`LNAV`), - fixed-wing regression guard -- [x] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green - -## Phase 3 — `MulticopterPerf`: analytic electric model from the OpenAP rotor data - -`class MulticopterPerf(OpenAP)`, selected with `SELECTIMPL OPENAP MULTICOPTERPERF`. Fixed-wing -rows keep `super()` behaviour untouched; multicopter rows get an electric model. This fills the -long-standing `# TODO: implement thrust computation for rotor aircraft` in `perfoap.py`. - -### Data: what ships already, and the one gap - -`data/performance/openap/rotor/aircraft.json` (already loaded by `OpenAP.create()`) provides, -per rotor typecode: mass (`oew`/`mtow` → `traf.perf.mass`), `n_engines` (`engnum`), per-engine -max power in kW (`engpower`), and the flight envelope. That is enough for an analytic power -model with **no new data pipeline and no PyThrust anything**: - -- **Installed power** `P_max = engnum · engpower` is the model's anchor. -- **Battery capacity is the one thing the json lacks** (`mfc` is 0 for every rotor type). A - small hand-written per-typecode dict in `perf.py` supplies spec-sheet watt-hours (e.g. MAVIC - 43.6 Wh, PHAN4 81.3 Wh, M600 6×99.9 Wh), with a fallback that derives energy from - `d_range_max` at cruise speed for unlisted types. The same dict optionally carries `CdS` - (flat-plate area) and a thrust-to-weight ratio where the default is wrong. - -### Runtime model (multicopter rows) - -- **Required thrust:** `T = m·√(g² + a_z²)`, plus a flat-plate parasite term `½ρv²·CdS` in - translation (small default `CdS`). -- **Power:** momentum-theory scaling referenced to installed power, - `P = P_max · (T / T_max)^1.5`, with `T_max = TWR · m·g` and a default thrust-to-weight ratio - of 2 (typical for camera/delivery multirotors). Sanity anchor: MAVIC installed power is - 4 × 66.9 W ≈ 268 W, giving ≈ 130 W in hover — matching published figures. Write `self.thrust` - and expose `battery_power` as the electric analogue of `fuelflow`. -- **Battery:** per-aircraft `soc` array, ideal-energy-tank integration - `soc -= P·dt / E_batt`. No terminal-voltage/current modelling — that needs the electrical - data (motor kv/resistance, OCV/R curves) deferred with PyThrust. -- **Envelope feedback:** below an SoC threshold, tighten `vmax`/`vsmax` in `limits()` — - keyed on SoC directly rather than physical voltage sag. -- **Stack commands:** `BATT acid` (report SoC/power/endurance estimate), optional auto-RTH/land - threshold via the conditional-command machinery later. - -**Fidelity caveat (documented in the plugin):** the power curve is momentum-theory shape, not -measured prop data — absolute forward-flight power is approximate, and the model deliberately -ignores the induced-power *drop* in fast translation (power is monotone in required thrust -here). Hover figures and the qualitative trends (power vs thrust, endurance, envelope shrink at -low battery) are sound — the right level for a traffic simulator, upgradeable later without API -changes (see the future-work section). - -**Acceptance:** hover endurance for a MAVIC-class config lands within sanity bounds (~20–35 min); -`BATT` reports monotonically decreasing SoC; envelope shrinks below a SoC threshold; unit tests -for the power model against a few hand-computed points. - -### Phase 3 checklist - -- [x] Per-typecode constants dict in `perf.py`: `{battery_wh, cds?, twr?}` from public spec - sheets (MAVIC, PHAN4, M100, M200, M600), `d_range_max`-derived fallback for unlisted - rotor types (MNET, AMZN, HORSEFLY) -- [x] `perf.py`: `MulticopterPerf(OpenAP)` — required-thrust model, momentum-theory power from - `engnum · engpower` (stored in kW — converted at the model boundary), per-aircraft SoC - integration, envelope feedback in `limits()` (descent deliberately unrestricted); the - `BATT` command lives on the Multicopter entity and delegates at call time, like `HOVER`, - so it survives the reset double-swap; fifth entry in the plugin's `IMPLEMENTATIONS` -- [x] Unit tests: power model vs hand-computed points; SoC monotonically decreasing; - envelope tightens below SoC threshold (`tests/integration/test_perf.py`) -- [x] Sanity: MAVIC-class hover endurance in the 20–35 min range (≈27.7 min analytic) -- [x] `uv run pytest`, `uv run ruff check .`, `uv run pyright` all green - -## Phase 4 — docs, scenarios, cleanup - -- New guide `docs/guides/multicopters.md`: creating multicopters, hover/yaw commands, battery - model, how to add a new type (rotor `aircraft.json` entry + constants dict). -- Update `docs/architecture.md` with the `Kinematics` entity and the replaceable list. -- Example scenario `packages/minisky-multicopter/scenarios/multicopter_delivery.scn`: create, - fly a route, hover at a delivery point, yaw for "camera", return; exercises everything - above. Lives in the plugin package because it depends entirely on the plugin. -- Regenerate `docs/reference/commands.md` (`uv run minisky commands docs`) after adding the - stack commands (`MCOPT`, `YAW`, `YAWRATE`, `HOVER`, `BATT`). -- `ruff`, `pyright`, full test suite green at every phase boundary. - -### Phase 4 checklist - -- [x] `docs/guides/multicopters.md` (usage, commands, battery model, adding a new type) -- [x] Update `docs/architecture.md`: `Kinematics` entity + replaceable list -- [x] `packages/minisky-multicopter/scenarios/multicopter_delivery.scn` exercising - create → route → hover-delivery (`HOVER` + `ALT` + `LNAV ON`) → return -- [x] Verify `docs/reference/commands.md` regeneration — the reference is now built by the - `command_docs()` macro at site build (`just docs-build`), so there is no separate gen - step; the table is core-only by design and the plugin commands are documented in - `docs/guides/multicopters.md` -- [x] Final sweep: `uv run pytest`, `uv run ruff check .`, `uv run pyright` - -## Sequencing and effort - -| Phase | Scope | Risk | Depends on | -|---|---|---|---| -| 1 | Extract `Kinematics` (core, behaviour-preserving) | Low — mechanical move guarded by existing tests | — | -| 2 | Plugin: membership + kinematics + commands | Medium — command semantics for HDG/YAW need care | 1 | -| 3 | Perf: analytic `MulticopterPerf` + battery from shipped OpenAP data | Low–medium — model calibration/sanity | 2 (usable after 1) | -| 4 | Docs, scenario, polish | Low | 2, 3 | - -Implementation lands on this branch phase by phase, checking off the checklists above as items -complete; phase 1 is intentionally the only one touching `minisky/`. - -## Decision log - -| Decision | Choice | Why | -|---|---|---| -| Name | **multicopter** (not drone/rotorcraft) | Names the lift/control type actually modelled; scope excludes helicopters (EC35) | -| Where behaviour lives | Plugin + replaceable subclasses | Matches "minimal core, hack from outside"; hot-swappable via `SELECTIMPL`; reverts on reset | -| Kinematics override mechanism | New first-level `Kinematics` entity (Phase 1) | `Traffic` itself can't be hot-swapped (root object); post-hoc plugin-hook correction would double-integrate state | -| Custom autopilot | Thin `MulticopterAutopilot` for the hover primitive, HDG semantics and fly-over defaults only | LNAV's track output already suits decoupled kinematics; no guidance rewrite needed | -| Mission primitive | One composable `HOVER acid [time] [alt]`; no `DELIVER` | Delivery choreography belongs in scenarios (`HOVER` + `ALT` + `LNAV ON`); keeps the primitive abstract | -| Capture radius | `MulticopterActiveWaypoint` subclass (fourth swap) | `ActiveWaypoint.reached()` recomputes `turndist` every step, so clamping it from the autopilot is overwritten before use | -| Membership predicate | Plugin-owned typecode set + `ismulticopter` array | `LIFT_ROTOR` includes helicopters | -| PyThrust | **Deferred entirely to future work** (2026-08-02; was: vendor its data + gen script) | The shipped OpenAP rotor json (mass, engine power, envelope) supports an analytic model; only battery Wh needs a small constants dict. Avoids ~1 MB vendored data and a gen script until the fidelity is actually needed | -| Perf evaluation | Analytic momentum-theory scaling, pure numpy | Keeps the numpy discipline; fleet-size independent; no artifacts to regenerate | - -## Future work — PyThrust-data fidelity upgrade (deferred) - -If measured-data fidelity is ever needed, the original Phase 3 design still applies and slots in -behind the same `MulticopterPerf` API (only the power/current evaluation changes): - -[PyThrust](https://github.com/Setuav/PyThrust) (Apache 2.0) ships everything needed as plain -data — APC propeller performance grids (`rpm, speed_mps, thrust_n, power_w, ...`; thrust *and* -shaft power tabulated, so "required thrust at this airspeed → power" is pure interpolation), -motor specs (`kv`, `resistance`, `io` — shaft-to-electrical is textbook motor algebra), and -battery OCV/internal-resistance-vs-DoD curves. The pipeline: vendor the handful of needed -CSV/JSONs (~1 MB) under `packages/minisky-multicopter/src/minisky_multicopter/data/pythrust/` -with PyThrust's LICENSE and an attribution note; a **self-contained** numpy-only -`scripts/gen_multicopter_perf.py` (regen convention like navdb parquet) emits one ~30 KB -`(airspeed, thrust) → (power_w, current_a, feasible)` grid per typecode plus battery curves, -checked in next to the plugin; runtime evaluates with vectorised interpolation. Never a runtime -PyThrust dependency. This upgrade adds what the analytic model cannot do: current draw, -terminal-voltage sag, and envelope infeasibility driven by physical voltage collapse rather -than an SoC threshold. Fidelity caveat regardless: APC coefficients are axial-flow, so -forward-flight power in edgewise translation stays approximate. From 25c1a8ac318a278fbb770a42f1836853a2edd2d8 Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:23:09 +0200 Subject: [PATCH 16/16] Update scenario to have a vertical take off --- .../scenarios/multicopter_delivery.scn | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/packages/minisky-multicopter/scenarios/multicopter_delivery.scn b/packages/minisky-multicopter/scenarios/multicopter_delivery.scn index 209f2f2..493e64d 100644 --- a/packages/minisky-multicopter/scenarios/multicopter_delivery.scn +++ b/packages/minisky-multicopter/scenarios/multicopter_delivery.scn @@ -1,16 +1,17 @@ -# Multicopter delivery mission: an M600 flies a package to a drop point, -# hovers down for the delivery, and returns home — exercising the -# MULTICOPTER plugin (packages/minisky-multicopter): decoupled yaw, the -# composable HOVER primitive, and the battery model. +# Multicopter delivery mission: an M600 takes off vertically, flies a package +# to a drop point, hovers down for the delivery, climbs back up vertically and +# returns home — exercising the MULTICOPTER plugin +# (packages/minisky-multicopter): decoupled yaw, the composable HOVER +# primitive, and the battery model. 00:00:00.00>PLUGINS LOAD MULTICOPTER # The drop point, 1.4 km east of home. 00:00:01.00>DEFWPT HOME 52.0000 4.0000 00:00:01.00>DEFWPT DROP 52.0000 4.0200 -# Create the drone at home. M600 is a multicopter typecode, so MCOPT -# reports ON without being set. -00:00:02.00>CRE PH1 M600 52.0 4.0 90 100 20 +# Create the drone on the ground at home, stationary. M600 is a multicopter +# typecode, so MCOPT reports ON without being set. +00:00:02.00>CRE PH1 M600 52.0 4.0 90 0 0 00:00:03.00>MCOPT PH1 # Out and back. Multicopter waypoints are fly-over with a fixed capture @@ -19,29 +20,39 @@ 00:00:03.00>ADDWPT PH1 HOME # The delivery, composed on the waypoint stack: when PH1 reaches DROP, -# hold position for 30 s while descending to 50 ft, and yaw the camera -# south — the nose stays there for the rest of the flight, so the return -# leg is flown strafing (track west, nose south). -00:00:03.00>AT PH1 DROP DO PH1 HOVER 30 50 +# hold position at 50 ft, and yaw the camera south — the nose stays there +# for the rest of the flight, so the return leg is flown strafing (track +# west, nose south). The 45 s is a guard that never expires: the timed +# climb-out below re-issues the hover after 30 s of hold. +00:00:03.00>AT PH1 DROP DO PH1 HOVER 45 50 00:00:03.00>AT PH1 DROP DO PH1 YAW 180 # When PH1 arrives back at HOME, stop and hover indefinitely. 00:00:03.00>AT PH1 HOME DO PH1 HOVER -00:00:04.00>LNAV PH1 ON +# Vertical takeoff: climb straight up to 100 ft and hold 5 s. A hover that +# started with the route disengaged does not engage it on expiry, so the +# departure below is a separate timed command. +00:00:04.00>HOVER PH1 5 100 + +# Depart eastbound once the takeoff hover has expired. +00:00:20.00>SPD PH1 20 +00:00:20.00>LNAV PH1 ON # Enroute to the drop point. 00:01:00.00>POS PH1 00:01:00.00>BATT PH1 # Mid-delivery: stopped over DROP at 50 ft, nose south. -00:02:30.00>POS PH1 -00:02:30.00>BATT PH1 +00:03:00.00>POS PH1 +00:03:00.00>BATT PH1 -# The 30 s hold has expired and the route resumed at the hover altitude; -# climb back to cruise altitude for the return leg. -00:03:10.00>ALT PH1 100 +# Climb out of the delivery: re-issuing the hover while it is still held +# keeps the position pinned and sets a new target altitude — a pure +# vertical climb back to cruise altitude, a 10 s hold at the top, then the +# route resumes on its own for the return leg. +00:03:10.00>HOVER PH1 10 100 # Hovering at home with the mission complete. -00:05:30.00>POS PH1 -00:05:30.00>BATT PH1 -00:05:40.00>QUIT +00:06:20.00>POS PH1 +00:06:20.00>BATT PH1 +00:06:30.00>QUIT