Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
147 changes: 147 additions & 0 deletions docs/guides/multicopters.md
Original file line number Diff line number Diff line change
@@ -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)`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use KaTeX (I will enable the arithmatex plugin soon)

@amorfinv amorfinv Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see #45

- **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`).
Comment thread
amorfinv marked this conversation as resolved.

## 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`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make plugins truly self contained we should allow users to configure their own multicopter parameters in their cache directory (the core already has platformdirs.user_cache_dir())

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`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code should be closed for modification, so we should not encourage users to modify the source code just to add a new multicopter entry. This is especially true when we decide to publish things on PyPI.

Can you make the plugin read a TOML configuration file (which stores all of the multicopter performance data) from the user cache dir, defining Pydantic shapes, and load/validate that on plugin startup?

(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.
21 changes: 21 additions & 0 deletions packages/minisky-multicopter/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
58 changes: 58 additions & 0 deletions packages/minisky-multicopter/scenarios/multicopter_delivery.scn
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions packages/minisky-multicopter/src/minisky_multicopter/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
Loading