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/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/scenarios/multicopter_delivery.scn b/packages/minisky-multicopter/scenarios/multicopter_delivery.scn
new file mode 100644
index 0000000..493e64d
--- /dev/null
+++ b/packages/minisky-multicopter/scenarios/multicopter_delivery.scn
@@ -0,0 +1,58 @@
+# 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 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
+# 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 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
+
+# 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:03:00.00>POS PH1
+00:03:00.00>BATT PH1
+
+# 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:06:20.00>POS PH1
+00:06:20.00>BATT PH1
+00:06:30.00>QUIT
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..b4a499b
--- /dev/null
+++ b/packages/minisky-multicopter/src/minisky_multicopter/__init__.py
@@ -0,0 +1,73 @@
+"""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).
+- ``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, BATT.
+"""
+
+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
+from minisky_multicopter.perf import MulticopterPerf
+
+__all__ = (
+ "Multicopter",
+ "MulticopterAPorASAS",
+ "MulticopterActiveWaypoint",
+ "MulticopterAutopilot",
+ "MulticopterKinematics",
+ "MulticopterPerf",
+ "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,
+ MulticopterPerf,
+ )
+ )
+
+
+plugin = plugin_api.Plugin(build=build)
diff --git a/packages/minisky-multicopter/src/minisky_multicopter/activewp.py b/packages/minisky-multicopter/src/minisky_multicopter/activewp.py
new file mode 100644
index 0000000..4021ab5
--- /dev/null
+++ b/packages/minisky-multicopter/src/minisky_multicopter/activewp.py
@@ -0,0 +1,71 @@
+"""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 import plugin as plugin_api
+from minisky.traffic.activewpdata import ActiveWaypoint
+
+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."""
+
+ 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/packages/minisky-multicopter/src/minisky_multicopter/aporasas.py b/packages/minisky-multicopter/src/minisky_multicopter/aporasas.py
new file mode 100644
index 0000000..bbfd940
--- /dev/null
+++ b/packages/minisky-multicopter/src/minisky_multicopter/aporasas.py
@@ -0,0 +1,42 @@
+"""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
+
+import numpy as np
+from minisky import plugin as plugin_api
+from minisky.traffic.aporasas import APorASAS
+
+from minisky_multicopter.entity import get_multicopter
+
+
+@plugin_api.replacement
+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. 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()
+ 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/packages/minisky-multicopter/src/minisky_multicopter/autopilot.py b/packages/minisky-multicopter/src/minisky_multicopter/autopilot.py
new file mode 100644
index 0000000..1d9ce71
--- /dev/null
+++ b/packages/minisky-multicopter/src/minisky_multicopter/autopilot.py
@@ -0,0 +1,195 @@
+"""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`` primitive, rerouted ``HDG`` semantics
+(nose only), and fly-over route defaults. The fixed waypoint capture radius
+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
+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 typing import TYPE_CHECKING
+
+import numpy as np
+from minisky import plugin as plugin_api
+from minisky.result import Err, Ok, Result
+from minisky.stack.argparser import Hdg
+from minisky.traffic.autopilot import Autopilot
+
+from minisky_multicopter.entity import MULTICOPTER_TYPES, get_multicopter
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from minisky.simulation import Simulation
+ from minisky.traffic import Traffic
+
+#: Ground speed below which a multicopter counts as stopped [m/s].
+GS_HOVER = 0.1
+
+#: Altitude tolerance for holding the selected hover altitude [m].
+ALT_CAPTURE = 0.5
+
+
+@plugin_api.replacement
+class MulticopterAutopilot(Autopilot):
+ """Autopilot with a multicopter hover primitive.
+
+ Attributes:
+ 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.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 hover state of n newly created aircraft.
+
+ 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)
+ 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 advance any active hovers (vectorized).
+
+ 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()
+ 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) -> Result[str, 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.
+
+ Args:
+ idx: Aircraft index.
+ hdg: Selected heading [deg].
+ """
+ mc = get_multicopter(self.traffic)
+ if mc is not None and mc.ismulticopter[idx]:
+ ok, message = mc.yaw(idx, float(hdg))
+ return Ok(message) if ok else Err(message)
+ 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.
+
+ 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)
+ 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/packages/minisky-multicopter/src/minisky_multicopter/entity.py b/packages/minisky-multicopter/src/minisky_multicopter/entity.py
new file mode 100644
index 0000000..f00541f
--- /dev/null
+++ b/packages/minisky-multicopter/src/minisky_multicopter/entity.py
@@ -0,0 +1,237 @@
+"""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``,
+``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).
+
+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 as plugin_api
+
+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
+
+#: 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"),
+ ("OPENAP", "MULTICOPTERPERF"),
+)
+
+
+def get_multicopter(traffic: Traffic) -> Multicopter | None:
+ """Return the Multicopter entity attached to a traffic tree, if any.
+
+ 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.
+ """
+ return next(
+ (child for child in traffic._children if isinstance(child, Multicopter)),
+ None,
+ )
+
+
+class Multicopter(plugin_api.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) -> None:
+ super().__init__()
+ self._selected = False
+ 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)
+ 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."""
+ 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:
+ result = self.traffic.select_implementation(basename, implname)
+ if result.is_err():
+ raise RuntimeError(f"MULTICOPTER: {result.err()}")
+ 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).
+
+ Arguments:
+ - idx: Aircraft callsign
+ - flag: ON to fly it as a multicopter, OFF for normal fixed-wing
+ kinematics (optional, omit to query)
+ """
+ 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'}"
+
+ @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.
+
+ 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]
+ """
+ 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"
+
+ @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.
+
+ Arguments:
+ - idx: Aircraft callsign
+ - yawrate: Maximum yaw rate [deg/s] (optional, omit to query)
+ """
+ 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"
+
+ @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)
+
+ @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/kinematics.py b/packages/minisky-multicopter/src/minisky_multicopter/kinematics.py
new file mode 100644
index 0000000..3cf0a2f
--- /dev/null
+++ b/packages/minisky-multicopter/src/minisky_multicopter/kinematics.py
@@ -0,0 +1,96 @@
+"""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
+
+import numpy as np
+from minisky import plugin as plugin_api
+from minisky.tools.aero import ft
+from minisky.traffic.kinematics import Kinematics
+
+from minisky_multicopter.entity import get_multicopter
+
+
+@plugin_api.replacement
+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).
+ """
+ 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()
+
+ # 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.
+
+ 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: thrust is redirected without rotating the body, and
+ course changes have no turn radius.
+ """
+ traf = self.traffic
+ super().update_groundspeed()
+ 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/packages/minisky-multicopter/src/minisky_multicopter/perf.py b/packages/minisky-multicopter/src/minisky_multicopter/perf.py
new file mode 100644
index 0000000..a93ec56
--- /dev/null
+++ b/packages/minisky-multicopter/src/minisky_multicopter/perf.py
@@ -0,0 +1,260 @@
+"""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, 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`` (the plugin's hooks keep this
+selected, like the other multicopter implementations).
+
+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 — 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
+
+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]; 0 = no battery model.
+ power (ndarray): Current electrical power draw [W] — the electric
+ analogue of ``fuelflow``.
+ twr (ndarray): Thrust-to-weight ratio at maximum thrust [-].
+ cds (ndarray): Flat-plate parasite drag area [m2].
+ """
+
+ def __init__(self, traffic: Traffic) -> None:
+ super().__init__(traffic)
+ with self.settrafarrays():
+ self.soc = np.array([])
+ self.capacity = np.array([])
+ self.power = 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 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)
+ 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
+ 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] (unused; the simulation timestep is read
+ from the owning runtime, like the base class does elsewhere).
+ """
+ super().update(dt)
+ 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,
+ intent_v_tas: np.ndarray,
+ intent_vs: np.ndarray,
+ intent_h: np.ndarray,
+ ax: np.ndarray,
+ ) -> OpenAP.PerformanceLimits:
+ """Clip the intended state to the flight envelope.
+
+ 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].
+ 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].
+ """
+ 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
+
+ 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.
+
+ 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.
+
+ Returns:
+ tuple: (success flag, report message).
+ """
+ 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/conftest.py b/packages/minisky-multicopter/tests/conftest.py
new file mode 100644
index 0000000..4569653
--- /dev/null
+++ b/packages/minisky-multicopter/tests/conftest.py
@@ -0,0 +1,29 @@
+"""Runtime fixtures for the multicopter plugin tests.
+
+The plugin tests build their own runtime with the MULTICOPTER plugin loaded
+(see `mcruntime` in the test module); the plain `runtime`/`sim` fixtures here
+provide a reference runtime with the core implementations for regression
+comparisons.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+from minisky import MiniSky, MiniSkyConfig
+from minisky.simulation import Simulation
+
+
+@pytest.fixture(scope="session")
+def runtime() -> Iterator[MiniSky]:
+ instance = MiniSky(MiniSkyConfig())
+ yield instance
+ instance.close()
+
+
+@pytest.fixture
+def sim(runtime: MiniSky) -> Simulation:
+ runtime.simulation.reset()
+ runtime.console.read_output_buffer() # drain "Simulation reset" echo
+ return runtime.simulation
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
new file mode 100644
index 0000000..b5d3115
--- /dev/null
+++ b/packages/minisky-multicopter/tests/integration/test_multicopter.py
@@ -0,0 +1,249 @@
+"""Integration tests for the MULTICOPTER plugin (Phase 2).
+
+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.
+"""
+
+from __future__ import annotations
+
+from minisky import MiniSky
+from minisky.simulation import Simulation
+from minisky.tools.aero import ft
+from minisky_multicopter.autopilot import MulticopterAutopilot
+from tests._types import RunCommand, StepUntil
+
+
+class TestPluginDiscovery:
+ def test_plugin_listed_from_entry_point(self, mcruntime: MiniSky) -> None:
+ result = mcruntime.plugins.listing()
+ assert result.is_ok(), result.err()
+ assert "MULTICOPTER" in result.unwrap()
+
+
+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"
+ 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")
+ 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
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..983025e
--- /dev/null
+++ b/packages/minisky-multicopter/tests/integration/test_perf.py
@@ -0,0 +1,168 @@
+"""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")
diff --git a/packages/minisky/minisky/runtime.py b/packages/minisky/minisky/runtime.py
index 856a8e0..e1b9005 100644
--- a/packages/minisky/minisky/runtime.py
+++ b/packages/minisky/minisky/runtime.py
@@ -17,8 +17,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
@@ -62,7 +65,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/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 d0276b5..5fa54f3 100644
--- a/packages/minisky/minisky/traffic/performance/perfoap.py
+++ b/packages/minisky/minisky/traffic/performance/perfoap.py
@@ -316,7 +316,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 7b9882d..408d388 100644
--- a/packages/minisky/minisky/traffic/traffic.py
+++ b/packages/minisky/minisky/traffic/traffic.py
@@ -26,19 +26,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
@@ -48,6 +43,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
@@ -97,7 +93,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].
@@ -110,7 +105,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].
@@ -126,6 +120,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.
@@ -191,9 +187,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]
@@ -224,13 +217,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
@@ -661,13 +652,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()
@@ -684,130 +673,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/packages/minisky/tests/integration/test_kinematics.py b/packages/minisky/tests/integration/test_kinematics.py
new file mode 100644
index 0000000..91e76b2
--- /dev/null
+++ b/packages/minisky/tests/integration/test_kinematics.py
@@ -0,0 +1,69 @@
+"""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.
+
+ 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:
+ 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:
+ result = runtime.replaceables.select("KINEMATICS")
+ assert result.is_ok(), result.err()
+ assert "KINEMATICS" in result.unwrap().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)
+
+ prepared = runtime.replaceables.prepare(TaggedKinematics)
+ runtime.replaceables.validate((prepared,))
+ runtime.replaceables.install((prepared,))
+ try:
+ result = runtime.replaceables.select("KINEMATICS", "TAGGEDKINEMATICS")
+ assert result.is_ok(), result.err()
+ 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
+ finally:
+ runtime.replaceables.remove((prepared,))
diff --git a/pyproject.toml b/pyproject.toml
index 54803a9..b416571 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,6 +16,7 @@ testpaths = [
"packages/minisky/tests",
"packages/minisky-example/tests",
"packages/minisky-example-customautopilot/tests",
+ "packages/minisky-multicopter/tests",
"packages/minisky-tangram/tests",
]
pythonpath = [".", "packages/minisky"]
@@ -40,6 +41,7 @@ extend-select = [
include = [
"packages/minisky/minisky",
"packages/minisky-example*/src",
+ "packages/minisky-multicopter/src",
"packages/minisky-tangram/src",
"packages/tangram-minisky/src",
"packages/*/tests",
@@ -59,6 +61,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/uv.lock b/uv.lock
index 031c6e0..c5d4ad8 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"
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" = [