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" = [