Skip to content

minisky_multicopter

quantities

Physical and dimensionless quantities specific to multicopter performance.

FlatPlateDragAreaM2 module-attribute

FlatPlateDragAreaM2: TypeAlias = q.AreaM2[_T]

Equivalent flat-plate parasite-drag area, \(C_D S\), where \(C_D\) is the drag coefficient and \(S\) is the reference area.

ThrustToWeightRatio module-attribute

ThrustToWeightRatio: TypeAlias = Annotated[_T, aerospace.THRUST_LOADING]

Dimensionless maximum thrust divided by aircraft weight, \(T_{\max}/(mg)\)

CruiseSpeedFraction module-attribute

CruiseSpeedFraction: TypeAlias = Annotated[_T, isqx.Dimensionless('cruise_speed_fraction')]

Cruise speed as a fraction of the airframe's maximum speed.

\[ V_{\mathrm{cruise}} = f\,V_{\max}. \]

where \(0 < f \le 1\).

Used when deriving battery energy from the configured maximum range for an aircraft whose usable battery energy is not supplied directly.

MulticopterConfig

Bases: BaseModel

Validated [plugins.multicopter] configuration.

model_config class-attribute instance-attribute

model_config = ConfigDict(extra='forbid', frozen=True)

capture_radius class-attribute instance-attribute

capture_radius: DistanceM[IsFinite[Gt0[float]]] = 10.0

performance_path class-attribute instance-attribute

performance_path: Path | None = None

soc_low class-attribute instance-attribute

soc_low: Annotated[IsFinite[Ge0[float]], Lt(1)] = 0.2

lowbatt_spd_factor class-attribute instance-attribute

lowbatt_spd_factor: Annotated[IsFinite[Gt0[float]], Le(1)] = 0.6

lowbatt_vs_factor class-attribute instance-attribute

lowbatt_vs_factor: Annotated[IsFinite[Gt0[float]], Le(1)] = 0.5

gs_hover class-attribute instance-attribute

gs_hover: GroundSpeedMps[IsFinite[Gt0[float]]] = 0.1

alt_capture class-attribute instance-attribute

alt_capture: VerticalDistanceM[IsFinite[Gt0[float]]] = 0.5

cruise_speed_fraction class-attribute instance-attribute

cruise_speed_fraction: CruiseSpeedFraction[Annotated[IsFinite[Gt0[float]], Le(1)]] = 0.8

MulticopterTypeTable

Bases: BaseModel

model_config class-attribute instance-attribute

model_config = ConfigDict(extra='forbid', frozen=True)

types instance-attribute

types: Annotated[dict[TypeCode, MulticopterTypeSpec], MinLen(1)]

MulticopterTypeSpec

Bases: BaseModel

Electric model and complete airframe for a multicopter type.

model_config class-attribute instance-attribute

model_config = ConfigDict(extra='forbid', frozen=True)

battery_energy class-attribute instance-attribute

battery_energy: EnergyWh[IsFinite[Gt0[float]]] | None = None

cds class-attribute instance-attribute

cds: FlatPlateDragAreaM2[IsFinite[Gt0[float]]] = 0.01

twr class-attribute instance-attribute

twr: ThrustToWeightRatio[IsFinite[Gt0[float]]] = 2.0

airframe instance-attribute

RotorAirframeSpec

Bases: BaseModel

Complete rotor-airframe data in MiniSky's internal SI units.

model_config class-attribute instance-attribute

model_config = ConfigDict(extra='forbid', frozen=True)

oew instance-attribute

oew: OewKg[IsFinite[Gt0[float]]]

mtow instance-attribute

mtow: MtowKg[IsFinite[Gt0[float]]]

n_engines instance-attribute

n_engines: Annotated[int, Ge(1)]

engine_power instance-attribute

engine_power: PowerW[IsFinite[Gt0[float]]]

v_min instance-attribute

v_min: Annotated[VelocityMps[IsFinite[float]], Le(0)]

v_max instance-attribute

v_max: TrueAirspeedMps[IsFinite[Gt0[float]]]

vs_min instance-attribute

vs_min: Annotated[VerticalRateMps[IsFinite[float]], Lt(0)]

vs_max instance-attribute

vs_max: VerticalRateMps[IsFinite[Gt0[float]]]

h_max instance-attribute

h_max: PressureAltitudeM[IsFinite[Gt0[float]]]

range_max instance-attribute

range_max: DistanceM[IsFinite[Gt0[float]]]

Multicopter

Multicopter(typespecs: Mapping[str, MulticopterTypeSpec] | None = None, config: MulticopterConfig | None = None)

Bases: Entity

Per-aircraft multicopter state.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def __init__(
    self,
    typespecs: Mapping[str, MulticopterTypeSpec] | None = None,
    config: MulticopterConfig | None = None,
) -> None:
    super().__init__()
    self.typespecs = dict(typespecs) if typespecs is not None else load_type_table()
    self.config = config if config is not None else MulticopterConfig()
    self._selected = False
    with self.settrafarrays():
        self.ismulticopter = np.array([], dtype=bool)
        """Whether each aircraft uses multicopter kinematics."""
        self.selhdg: q.TrueHeadingDegrees[np.ndarray] = np.array([])  # pyright: ignore[reportGeneralTypeIssues]
        self.swselhdg = np.array([], dtype=bool)
        """Whether body heading is explicitly selected rather than following track."""
        self.yawrate: q.YawRateDegPerS[np.ndarray] = np.array([])  # pyright: ignore[reportGeneralTypeIssues]

typespecs instance-attribute

typespecs = dict(typespecs) if typespecs is not None else load_type_table()

config instance-attribute

config = config if config is not None else MulticopterConfig()

ismulticopter instance-attribute

ismulticopter = np.array([], dtype=bool)

Whether each aircraft uses multicopter kinematics.

selhdg instance-attribute

swselhdg instance-attribute

swselhdg = np.array([], dtype=bool)

Whether body heading is explicitly selected rather than following track.

yawrate instance-attribute

yawrate: YawRateDegPerS[ndarray] = np.array([])

create

create(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.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
103
104
105
106
107
108
109
110
111
112
113
114
115
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.
    """
    super().create(n)
    self.ismulticopter[-n:] = [
        typecode.upper() in self.typespecs for typecode in self.traffic.typecode[-n:]
    ]
    self.selhdg[-n:] = self.traffic.hdg[-n:]
    self.swselhdg[-n:] = False
    self.yawrate[-n:] = DEFAULT_YAWRATE

mask

mask() -> ndarray

Return the boolean row mask of aircraft flown as multicopters.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
117
118
119
def mask(self) -> np.ndarray:
    """Return the boolean row mask of aircraft flown as multicopters."""
    return self.ismulticopter

select_implementations

select_implementations() -> None

Swap the multicopter implementations onto the owning traffic.

Equivalent to issuing SELECTIMPL <BASE> <IMPL> for each entry of IMPLEMENTATIONS; replaces the live instance immediately.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def select_implementations(self) -> None:
    """Swap the multicopter implementations onto the owning traffic.

    Equivalent to issuing `SELECTIMPL <BASE> <IMPL>` for each entry of
    `IMPLEMENTATIONS`; replaces the live instance immediately.
    """
    # NOTE(abraham): plugin loading should eventually register behaviour,
    # not mutate which concrete implementation owns every aircraft.
    # importing multicopter should not have any side effects!
    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

ensure_implementations

ensure_implementations() -> 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.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
136
137
138
139
140
141
142
143
144
145
146
@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()

reselect_implementations

reselect_implementations() -> 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.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
148
149
150
151
152
153
154
155
@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()

mcopt_status

mcopt_status(idx: AcId) -> Result[str, str]

See stack command: MCOPT

Report whether multicopter behavior is enabled for an aircraft.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
157
158
159
160
161
@command(name="MCOPT")
def mcopt_status(self, idx: AcId) -> Result[str, str]:
    """Report whether multicopter behavior is enabled for an aircraft."""
    callsign = self.traffic.callsign[idx]
    return Ok(f"MCOPT {callsign}: {'ON' if self.ismulticopter[idx] else 'OFF'}")

set_mcopt

set_mcopt(idx: AcId, flag: OnOff) -> Result[str, str]

See stack command: MCOPT

Enable or disable multicopter behavior for an aircraft.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
@command(name="MCOPT")
def set_mcopt(self, idx: AcId, flag: OnOff) -> Result[str, str]:
    """Enable or disable multicopter behavior for an aircraft."""
    callsign = self.traffic.callsign[idx]
    if flag and self.traffic.typecode[idx].upper() not in self.typespecs:
        return Err(f"MCOPT: {callsign} type is not configured as a multicopter")

    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 Ok(f"MCOPT {callsign}: {'ON' if flag else 'OFF'}")

yaw

yaw(idx: AcId, hdg: TrueHeadingDeg[IsFinite[float]] | MagneticHeadingDeg[IsFinite[float]]) -> Result[str, str]

See stack command: YAW

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.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
@command
def yaw(
    self, idx: AcId, hdg: TrueHeadingDeg[IsFinite[float]] | MagneticHeadingDeg[IsFinite[float]]
) -> Result[str, 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.
    """
    if not self.ismulticopter[idx]:
        callsign = self.traffic.callsign[idx]
        return Err(f"YAW: {callsign} is not a multicopter")

    resolved_hdg = hdg.value
    if isinstance(hdg, MagneticHeadingDeg):
        resolved_hdg += geo.magdec(float(self.traffic.lat[idx]), float(self.traffic.lon[idx]))
    resolved_hdg %= 360.0
    self.selhdg[idx] = resolved_hdg
    self.swselhdg[idx] = True
    return Ok(f"YAW {self.traffic.callsign[idx]}: nose to {resolved_hdg:.0f} deg")

yawrate_status

yawrate_status(idx: AcId) -> Result[str, str]

See stack command: YAWRATE

Report the maximum yaw rate of a multicopter.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
201
202
203
204
205
@command(name="YAWRATE")
def yawrate_status(self, idx: AcId) -> Result[str, str]:
    """Report the maximum yaw rate of a multicopter."""
    callsign = self.traffic.callsign[idx]
    return Ok(f"YAWRATE {callsign}: {self.yawrate[idx]:.0f} deg/s")

set_yawrate

set_yawrate(idx: AcId, yawrate: YawRateDegPerS[IsFinite[Gt0[float]]]) -> Result[str, str]

See stack command: YAWRATE

Set the maximum yaw rate of a multicopter.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
207
208
209
210
211
212
213
214
215
216
@command(name="YAWRATE")
def set_yawrate(
    self,
    idx: AcId,
    yawrate: q.YawRateDegPerS[IsFinite[Gt0[float]]],
) -> Result[str, str]:
    """Set the maximum yaw rate of a multicopter."""
    callsign = self.traffic.callsign[idx]
    self.yawrate[idx] = yawrate
    return Ok(f"YAWRATE {callsign}: {yawrate:.0f} deg/s")

hover

hover(idx: AcId, duration: TimeS | None = None, alt: StdPressureAltM[IsFinite[float]] | None = None) -> Result[str, str]

See stack command: HOVER

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.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
@command
def hover(
    self,
    idx: AcId,
    duration: TimeS | None = None,
    alt: StdPressureAltM[IsFinite[float]] | None = None,
) -> Result[str, 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.
    """
    # Deferred import: the autopilot module imports this one.
    from minisky_multicopter.autopilot import MulticopterAutopilot

    ap = self.traffic.ap
    if not isinstance(ap, MulticopterAutopilot):
        return Err("HOVER: SELECTIMPL AUTOPILOT MULTICOPTERAUTOPILOT first")
    return ap.hover(idx, duration, alt)

batt

batt(idx: AcId) -> Result[str, str]

See stack command: BATT

Report the battery state of charge, power draw and endurance.

Source code in packages/minisky-multicopter/src/minisky_multicopter/entity.py
244
245
246
247
248
249
250
251
252
253
254
255
256
@command
def batt(self, idx: AcId) -> Result[str, str]:
    """Report the battery state of charge, power draw and endurance."""
    # 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 Err(f"BATT: {callsign} is not a multicopter")
    perf = self.traffic.perf
    if not isinstance(perf, MulticopterPerf):
        return Err("BATT: SELECTIMPL OPENAP MULTICOPTERPERF first")
    return perf.batt(idx)

MulticopterAutopilot

MulticopterAutopilot(traffic: Traffic, get_simulation: Callable[[], Simulation])

Bases: Autopilot

Autopilot with a multicopter hover primitive.

Source code in packages/minisky-multicopter/src/minisky_multicopter/autopilot.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) -> None:
    super().__init__(traffic, get_simulation)
    with self.settrafarrays():
        self.swhover = np.array([], dtype=bool)
        """Whether each aircraft is in a commanded hover."""
        self.hovertimer: OptionalArray[q.DurationS[np.ndarray]] = OptionalArray(  # pyright: ignore[reportGeneralTypeIssues]
            np.array([]), np.array([], dtype=bool)
        )
        """Remaining hold time of an active hover."""
        self.resume_airspeed: VariantArray[np.ndarray] = VariantArray(
            np.array([]), np.array([], dtype=np.uint8)
        )
        """[CAS in m/s][minisky.types.CasMps] or [Mach][minisky.types.Mach] selection restored
        when hover ends."""
        self.resume_lnav = np.array([], dtype=bool)
        self.resume_vnav = np.array([], dtype=bool)
        self.resume_vnav_airspeed = np.array([], dtype=bool)

swhover instance-attribute

swhover = np.array([], dtype=bool)

Whether each aircraft is in a commanded hover.

hovertimer instance-attribute

hovertimer: OptionalArray[DurationS[ndarray]] = OptionalArray(np.array([]), np.array([], dtype=bool))

Remaining hold time of an active hover.

resume_airspeed instance-attribute

resume_airspeed: VariantArray[ndarray] = VariantArray(np.array([]), np.array([], dtype=np.uint8))

CAS in m/s or Mach selection restored when hover ends.

resume_lnav instance-attribute

resume_lnav = np.array([], dtype=bool)

resume_vnav instance-attribute

resume_vnav = np.array([], dtype=bool)

resume_vnav_airspeed instance-attribute

resume_vnav_airspeed = np.array([], dtype=bool)

create

create(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).

Source code in packages/minisky-multicopter/src/minisky_multicopter/autopilot.py
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
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).
    """
    super().create(n)
    self.swhover[-n:] = False
    self.resume_airspeed.values[-n:] = 0.0
    self.resume_airspeed.kind[-n:] = AirspeedKind.CAS
    self.resume_lnav[-n:] = False
    self.resume_vnav[-n:] = False
    self.resume_vnav_airspeed[-n:] = False

    # Membership by typecode from the performance table: the Multicopter
    # entity may be created after this autopilot in the traffic tree, so
    # its arrays cannot be relied upon here.
    mc = get_multicopter(self.traffic)
    if mc is None:
        return
    for offset, typecode in enumerate(self.traffic.typecode[-n:], start=-n):
        if typecode.upper() in mc.typespecs:
            self.route[offset].swflyby = False

update

update() -> 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).

Source code in packages/minisky-multicopter/src/minisky_multicopter/autopilot.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
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()
    mc = get_multicopter(self.traffic)
    if not self.swhover.any() or mc 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.
    timed = self.swhover & (self.hovertimer.present)
    holding = (
        timed
        & ~traf.swlnav
        & (traf.gs < mc.config.gs_hover)
        & (np.abs(traf.alt - traf.selalt) < mc.config.alt_capture)
    )
    self.hovertimer.values[holding] -= self.simulation.simdt
    expired = holding & (self.hovertimer.values <= 0.0)

    # Restore the saved route state; expiry also re-engages LNAV/VNAV.
    resume = cancel | expired
    traf.selected_airspeed.values[:] = np.where(
        resume, self.resume_airspeed.values, traf.selected_airspeed.values
    )
    traf.selected_airspeed.kind[:] = np.where(
        resume, self.resume_airspeed.kind, traf.selected_airspeed.kind
    ).astype(np.uint8)
    traf.swvnavairspeed = np.where(resume, self.resume_vnav_airspeed, traf.swvnavairspeed)
    traf.swvnav = np.where(cancel, self.resume_vnav, traf.swvnav)
    traf.swlnav = np.where(expired, self.resume_lnav, traf.swlnav)
    traf.swvnav = np.where(expired, self.resume_vnav & self.resume_lnav, traf.swvnav)
    self.swhover = self.swhover & ~resume
    self.hovertimer.clear(resume)

selhdgcmd

Select the autopilot heading; for multicopters, yaw the nose only.

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.

Source code in packages/minisky-multicopter/src/minisky_multicopter/autopilot.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def selhdgcmd(
    self, idx: AcIdSelection, hdg: TrueHeadingDeg | MagneticHeadingDeg
) -> 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`:
    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.
    """
    mc = get_multicopter(self.traffic)
    if mc is None:
        return super().selhdgcmd(idx, hdg)

    is_multicopter = mc.ismulticopter[idx]
    if not is_multicopter.any():
        return super().selhdgcmd(idx, hdg)

    message = "heading set"
    for acidx in idx[is_multicopter]:
        result = mc.yaw(int(acidx), hdg)
        if isinstance(result, Err):
            return result
        message = result.ok()

    fixed_wing = idx[~is_multicopter]
    if fixed_wing.size:
        return super().selhdgcmd(fixed_wing, hdg)
    return Ok(message)

hover

hover(idx: AircraftIndex, duration: DurationS[float] | None = None, alt: StdPressureAltM | None = None) -> 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.

Parameters:

Name Type Description Default
duration DurationS[float] | None

Hold time; None holds indefinitely.

None
alt StdPressureAltM | None

Hover altitude; None holds the current altitude.

None
Source code in packages/minisky-multicopter/src/minisky_multicopter/autopilot.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def hover(
    self,
    idx: AircraftIndex,
    duration: q.DurationS[float] | None = None,
    alt: StdPressureAltM | None = None,
) -> 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.

    Args:
        duration: Hold time; `None` holds indefinitely.
        alt: Hover altitude; `None` holds the current altitude.
    """
    callsign = self.traffic.callsign[idx]
    mc = get_multicopter(self.traffic)
    if mc is None or not mc.ismulticopter[idx]:
        return Err(f"HOVER: {callsign} is not a multicopter")

    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:
        result = self.selaltcmd(np.asarray([idx], dtype=int), alt)
        if isinstance(result, Err):
            return result
    if duration is None:
        self.hovertimer.clear(idx)
    else:
        self.hovertimer.set(idx, duration)

    if duration is None:
        return Ok(f"HOVER {callsign}: holding position (resume with LNAV {callsign} ON)")
    return Ok(f"HOVER {callsign}: holding position for {duration:.0f} s")

MulticopterPerf

MulticopterPerf(traffic: Traffic)

Bases: OpenAP

OpenAP performance with an electric model for multicopter rows.

Source code in packages/minisky-multicopter/src/minisky_multicopter/perf.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def __init__(self, traffic: Traffic) -> None:
    # NOTE(abraham): miniSky currently has one globally selected performance impl,
    # in the future we should have independent performance backends operating on
    # aircraft subsets with openap moved out of core
    super().__init__(traffic)
    self._install_types()
    with self.settrafarrays():
        self.soc = np.array([])
        """Battery state of charge as a fraction of usable capacity."""
        self.capacity: q.EnergyJ[np.ndarray] = np.array([])  # pyright: ignore[reportGeneralTypeIssues]
        """Usable pack energy; zero disables the battery model."""
        self.power: q.PowerW[np.ndarray] = np.array([])  # pyright: ignore[reportGeneralTypeIssues]
        self.twr: mq.ThrustToWeightRatio[np.ndarray] = np.array([])  # pyright: ignore[reportGeneralTypeIssues]
        """Maximum-thrust-to-weight ratio."""
        self.cds: mq.FlatPlateDragAreaM2[np.ndarray] = np.array([])  # pyright: ignore[reportGeneralTypeIssues]

soc instance-attribute

soc = np.array([])

Battery state of charge as a fraction of usable capacity.

capacity instance-attribute

capacity: EnergyJ[ndarray] = np.array([])

Usable pack energy; zero disables the battery model.

power instance-attribute

power: PowerW[ndarray] = np.array([])

twr instance-attribute

Maximum-thrust-to-weight ratio.

cds instance-attribute

create

create(n: int = 1) -> None

Seed the electric state of n newly created aircraft.

Multicopters start on a full battery with their typecode's pack energy, drag area and thrust-to-weight ratio; other rows keep zeros (no battery model). Seeded per row rather than per batch, so a swap onto a mixed fleet stays correct. Membership comes from the performance table because the Multicopter entity may sit after this object in the traffic tree.

Source code in packages/minisky-multicopter/src/minisky_multicopter/perf.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def create(self, n: int = 1) -> None:
    """Seed the electric state of n newly created aircraft.

    Multicopters start on a full battery with their typecode's pack
    energy, drag area and thrust-to-weight ratio; other rows keep zeros
    (no battery model). Seeded per row rather than per batch, so a swap
    onto a mixed fleet stays correct. Membership comes from the
    performance table because the Multicopter entity may sit after this
    object in the traffic tree.
    """
    super().create(n)
    mc = get_multicopter(self.traffic)
    if mc is None:
        return
    for offset, typecode in enumerate(self.traffic.typecode[-n:], start=-n):
        actype = typecode.upper()
        spec = mc.typespecs.get(actype)
        if spec is None:
            continue
        self.twr[offset] = spec.twr
        self.cds[offset] = spec.cds
        if (energy := spec.battery_energy) is None:
            energy = self._range_derived_wh(
                spec.airframe, spec.cds, spec.twr, mc.config.cruise_speed_fraction
            )
        self.capacity[offset] = q.wh_to_j(energy)
        self.soc[offset] = 1.0

required_thrust

required_thrust() -> ForceN[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 + a_z^2}\) — while its horizontal component overcomes the flat-plate parasite drag of translation, \(\tfrac{1}{2} \rho v^2 C_D S\). Meaningful for multicopter rows (other rows have a zero drag area).

Source code in packages/minisky-multicopter/src/minisky_multicopter/perf.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def required_thrust(self) -> q.ForceN[np.ndarray]:
    r"""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 + a_z^2}$ — while its horizontal
    component overcomes the flat-plate parasite drag of translation,
    $\tfrac{1}{2} \rho v^2 C_D S$. 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)

update

update() -> None

Update performance, then the electric model for multicopter rows.

After the base update, computes each multicopter's required thrust, derives the electrical power from the momentum-theory scaling \(P = P_\text{max} (T / T_\text{max})^{1.5}\), and integrates the battery state of charge as an ideal energy tank.

Source code in packages/minisky-multicopter/src/minisky_multicopter/perf.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def update(self) -> None:
    r"""Update performance, then the electric model for multicopter rows.

    After the base update, computes each multicopter's required thrust,
    derives the electrical power from the momentum-theory scaling
    $P = P_\text{max} (T / T_\text{max})^{1.5}$, and integrates the
    battery state of charge as an ideal energy tank.
    """
    # NOTE(abraham): OpenAP updates the shared performance arrays for the
    # entire fleet first, after which this subclass overwrites multicopter
    # rows!
    super().update()
    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]
    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)

limits

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 (the soc_low / lowbatt_*_factor plugin settings). Descent stays unrestricted — a low battery should not keep an aircraft airborne.

Source code in packages/minisky-multicopter/src/minisky_multicopter/perf.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
def limits(
    self,
    intent_v_tas: q.TrueAirspeedMps[np.ndarray],
    intent_vs: q.VerticalRateMps[np.ndarray],
    intent_h: q.PressureAltitudeM[np.ndarray],
    ax: q.AccelerationMps2[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 (the
    `soc_low` / `lowbatt_*_factor` plugin settings). Descent stays
    unrestricted — a low battery should not keep an aircraft airborne.
    """
    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 < mc.config.soc_low)
    if not low.any():
        return allowed

    tas, vs, alt = allowed
    tas[low] = np.minimum(tas[low], mc.config.lowbatt_spd_factor * self.vmax[low])
    vs[low] = np.minimum(vs[low], mc.config.lowbatt_vs_factor * self.vsmax[low])
    return self.PerformanceLimits(tas, vs, alt)

batt

batt(idx: AircraftIndex) -> Result[str, 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.

Source code in packages/minisky-multicopter/src/minisky_multicopter/perf.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def batt(self, idx: AircraftIndex) -> Result[str, 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.
    """
    callsign = self.traffic.callsign[idx]
    soc = self.soc[idx]
    power = self.power[idx]
    if soc <= 0.0:
        endurance = "battery empty"
    elif power > 0.0:
        endurance = f"endurance {q.s_to_min(soc * self.capacity[idx] / power):.0f} min"
    else:
        endurance = "endurance --"
    return Ok(f"BATT {callsign}: {soc:.0%}, drawing {power:.0f} W, {endurance}")