Skip to content
Draft
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
10 changes: 10 additions & 0 deletions docs/assets/js/katex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
document$.subscribe(({ body }) => {
renderMathInElement(body, {
delimiters: [
{ left: "$$", right: "$$", display: true },
{ left: "$", right: "$", display: false },
{ left: "\\(", right: "\\)", display: false },
{ left: "\\[", right: "\\]", display: true },
],
});
});
20 changes: 13 additions & 7 deletions docs/guides/multicopters.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,19 @@ 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)`.
drag:

$$T = \sqrt{L^2 + D^2}, \qquad
L = m\sqrt{g^2 + a_z^2}, \qquad
D = \tfrac{1}{2}\rho v^2 C_D S$$

- **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).
installed power from the OpenAP rotor coefficients, with
$T_\text{max} = \mathrm{TWR} \cdot m g$ and a default thrust-to-weight
ratio of 2:

$$P = P_\text{max} \left(\frac{T}{T_\text{max}}\right)^{1.5}$$

- **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%
Expand All @@ -122,9 +130,7 @@ 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`).
sound — hover endurance for the MAVIC comes out around 28 minutes.

## Adding a new multicopter type

Expand Down
14 changes: 7 additions & 7 deletions packages/minisky-multicopter/src/minisky_multicopter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@
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
- `KINEMATICS` -> `MulticopterKinematics` — yaw-rate-limited
heading, track-driven velocity vector.
- ``APORASAS`` -> :class:`MulticopterAPorASAS` — no track-to-heading
- `APORASAS` -> `MulticopterAPorASAS` — no track-to-heading
coupling for multicopter rows.
- ``AUTOPILOT`` -> :class:`MulticopterAutopilot` — HOVER primitive,
- `AUTOPILOT` -> `MulticopterAutopilot` — HOVER primitive,
HDG-yaws-the-nose semantics, fly-over route defaults.
- ``ACTIVEWAYPOINT`` -> :class:`MulticopterActiveWaypoint` — fixed waypoint
- `ACTIVEWAYPOINT` -> `MulticopterActiveWaypoint` — fixed waypoint
capture radius (the bank-angle turn distance degenerates at hover speeds).
- ``OPENAP`` -> :class:`MulticopterPerf` — electric performance:
- `OPENAP` -> `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``.
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.
"""
Expand Down
11 changes: 6 additions & 5 deletions packages/minisky-multicopter/src/minisky_multicopter/activewp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
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.
This must live in an
[`ActiveWaypoint`][minisky.traffic.activewpdata.ActiveWaypoint] subclass
(selected with `SELECTIMPL ACTIVEWAYPOINT MULTICOPTERACTIVEWAYPOINT`)
because [`ActiveWaypoint.reached`][minisky.traffic.activewpdata.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
Expand Down
20 changes: 10 additions & 10 deletions packages/minisky-multicopter/src/minisky_multicopter/aporasas.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""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``.
The core [`APorASAS`][minisky.traffic.aporasas.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
Expand All @@ -27,8 +27,8 @@ 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)
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.
Expand Down
26 changes: 12 additions & 14 deletions packages/minisky-multicopter/src/minisky_multicopter/autopilot.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
"""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
A thin subclass of the core
[`Autopilot`][minisky.traffic.autopilot.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
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`.
lives in `MulticopterActiveWaypoint` (see `minisky_multicopter.activewp`).

``HOVER`` is deliberately composable rather than a scripted manoeuvre: it
`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.
`HOVER`, `ALT` and `LNAV` commands.

Selected with ``SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT``.
Selected with `SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT`.
"""

from __future__ import annotations
Expand Down Expand Up @@ -132,7 +133,7 @@ def update(self) -> None:
def selhdgcmd(self, idx: AcIdSelection, hdg: HeadingDeg) -> Result[str, str]:
"""Select the autopilot heading; for multicopters, yaw the nose only.

For multicopter rows the HDG stack command is an alias of ``YAW``:
For multicopter rows 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.
Expand Down Expand Up @@ -166,17 +167,14 @@ def hover(
) -> Result[str, 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.
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:
Result containing the confirmation message or an error.
"""
callsign = self.traffic.callsign[idx]
mc = get_multicopter(self.traffic)
Expand Down
18 changes: 9 additions & 9 deletions packages/minisky-multicopter/src/minisky_multicopter/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

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).
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
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.
"""
Expand Down Expand Up @@ -56,7 +56,7 @@
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 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.
Expand Down Expand Up @@ -112,8 +112,8 @@ def mask(self) -> np.ndarray:
def select_implementations(self) -> None:
"""Swap the multicopter implementations onto the owning traffic.

Equivalent to issuing ``SELECTIMPL <BASE> <IMPL>`` for each entry of
:data:`IMPLEMENTATIONS`; replaces the live instance immediately.
Equivalent to issuing `SELECTIMPL <BASE> <IMPL>` for each entry of
`IMPLEMENTATIONS`; replaces the live instance immediately.
"""
for basename, implname in IMPLEMENTATIONS:
result = self.traffic.select_implementation(basename, implname)
Expand All @@ -127,7 +127,7 @@ def ensure_implementations(self) -> None:

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
happens here. A manual `SELECTIMPL` afterwards is respected until
the next reset.
"""
if not self._selected:
Expand Down
43 changes: 27 additions & 16 deletions packages/minisky-multicopter/src/minisky_multicopter/kinematics.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""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.
Replaces the bank-to-turn kinematics of the core
[`Kinematics`][minisky.traffic.kinematics.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
Selected with `SELECTIMPL KINEMATICS MULTICOPTERKINEMATICS`; fixed-wing
rows keep the base-class behaviour untouched.
"""

Expand All @@ -24,10 +24,10 @@
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.
Only `update_airspeed` and `update_groundspeed` are overridden; the
inherited [`Kinematics.update`][minisky.traffic.kinematics.Kinematics.update]
still runs a single `update_pos` pass afterwards, so position is
integrated exactly once from the corrected velocity.
"""

def update_airspeed(self) -> None:
Expand Down Expand Up @@ -64,20 +64,20 @@ def update_groundspeed(self) -> None:

Runs the base implementation for the whole fleet, then rebuilds the
ground-speed components of multicopter rows from the *commanded
track* (``traf.aporasas.trk``) plus wind, and derives ``gs``/``trk``
from them: thrust is redirected without rotating the body, and
course changes have no turn radius.
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. The work accumulated by the base class
along its heading-driven ground speed is corrected to the rebuilt
velocity.
"""
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
gsbase = traf.gs[m]
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]
Expand All @@ -94,3 +94,14 @@ def update_groundspeed(self) -> None:
traf.aporasas.trk % 360.0,
)
traf.trk = np.where(m, trk, traf.trk)

# The base class accumulated traf.work along its heading-driven
# ground speed; replace that increment with one along the rebuilt
# velocity for multicopter rows.
simdt = self._get_simulation().simdt
vs2 = traf.vs[m] ** 2
traf.work[m] += (
traf.perf.thrust[m]
* simdt
* (np.sqrt(traf.gs[m] ** 2 + vs2) - np.sqrt(gsbase**2 + vs2))
)
Loading