diff --git a/docs/assets/js/katex.js b/docs/assets/js/katex.js new file mode 100644 index 0000000..0946ce0 --- /dev/null +++ b/docs/assets/js/katex.js @@ -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 }, + ], + }); +}); diff --git a/docs/guides/multicopters.md b/docs/guides/multicopters.md index 307e3d6..3177620 100644 --- a/docs/guides/multicopters.md +++ b/docs/guides/multicopters.md @@ -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% @@ -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 diff --git a/packages/minisky-multicopter/src/minisky_multicopter/__init__.py b/packages/minisky-multicopter/src/minisky_multicopter/__init__.py index b4a499b..ee64572 100644 --- a/packages/minisky-multicopter/src/minisky_multicopter/__init__.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/__init__.py @@ -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. """ diff --git a/packages/minisky-multicopter/src/minisky_multicopter/activewp.py b/packages/minisky-multicopter/src/minisky_multicopter/activewp.py index f6bd04a..1050cdb 100644 --- a/packages/minisky-multicopter/src/minisky_multicopter/activewp.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/activewp.py @@ -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 diff --git a/packages/minisky-multicopter/src/minisky_multicopter/aporasas.py b/packages/minisky-multicopter/src/minisky_multicopter/aporasas.py index bbfd940..dafb747 100644 --- a/packages/minisky-multicopter/src/minisky_multicopter/aporasas.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/aporasas.py @@ -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 @@ -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. diff --git a/packages/minisky-multicopter/src/minisky_multicopter/autopilot.py b/packages/minisky-multicopter/src/minisky_multicopter/autopilot.py index daaeeab..68d63bf 100644 --- a/packages/minisky-multicopter/src/minisky_multicopter/autopilot.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/autopilot.py @@ -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 @@ -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. @@ -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) diff --git a/packages/minisky-multicopter/src/minisky_multicopter/entity.py b/packages/minisky-multicopter/src/minisky_multicopter/entity.py index 095fdee..98857dd 100644 --- a/packages/minisky-multicopter/src/minisky_multicopter/entity.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/entity.py @@ -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. """ @@ -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. @@ -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 `` for each entry of - :data:`IMPLEMENTATIONS`; replaces the live instance immediately. + Equivalent to issuing `SELECTIMPL ` for each entry of + `IMPLEMENTATIONS`; replaces the live instance immediately. """ for basename, implname in IMPLEMENTATIONS: result = self.traffic.select_implementation(basename, implname) @@ -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: diff --git a/packages/minisky-multicopter/src/minisky_multicopter/kinematics.py b/packages/minisky-multicopter/src/minisky_multicopter/kinematics.py index 3cf0a2f..813e4d0 100644 --- a/packages/minisky-multicopter/src/minisky_multicopter/kinematics.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/kinematics.py @@ -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. """ @@ -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: @@ -64,9 +64,11 @@ 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() @@ -74,10 +76,8 @@ def update_groundspeed(self) -> None: 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] @@ -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)) + ) diff --git a/packages/minisky-multicopter/src/minisky_multicopter/perf.py b/packages/minisky-multicopter/src/minisky_multicopter/perf.py index 93fe98c..e0b638f 100644 --- a/packages/minisky-multicopter/src/minisky_multicopter/perf.py +++ b/packages/minisky-multicopter/src/minisky_multicopter/perf.py @@ -1,24 +1,25 @@ -"""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 +r"""Electric performance for multicopters. + +Adds the thrust computation the core +[`OpenAP`][minisky.traffic.performance.perfoap.OpenAP] model lacks for rotor +aircraft: 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). +energy derived from their `d_range_max` at cruise speed. No PyThrust +anywhere: a measured-prop-data upgrade is future work. Fidelity caveat: the power curve is momentum-theory shape -(``P = P_max * (T / T_max) ** 1.5``), not measured prop data, so absolute +($P = P_\text{max} (T / T_\text{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, @@ -82,7 +83,7 @@ class MulticopterPerf(OpenAP): 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``. + analogue of `fuelflow`. twr (ndarray): Thrust-to-weight ratio at maximum thrust [-]. cds (ndarray): Flat-plate parasite drag area [m2]. """ @@ -129,12 +130,12 @@ def create(self, n: int = 1) -> None: 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 + 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. + ac: OpenAP rotor `aircraft.json` entry for the typecode. cds: Flat-plate parasite drag area [m2]. twr: Thrust-to-weight ratio at maximum thrust [-]. """ @@ -152,13 +153,13 @@ def _range_derived_wh(ac: dict, cds: float, twr: float) -> float: 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]. + 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 + az^2)`` — while its horizontal + acceleration, $m \sqrt{g^2 + a_z^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). + $\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) @@ -167,13 +168,14 @@ def required_thrust(self) -> np.ndarray: return np.hypot(lift, drag) def update(self, dt: float = 1) -> None: - """Update performance, then the electric model for multicopter rows. + r"""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. + $P = P_\text{max} (T / T_\text{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 @@ -236,15 +238,12 @@ def limits( def batt(self, idx: int) -> Result[str, str]: """Report battery state of charge, power draw and endurance. - Backs the ``BATT`` stack command declared on the Multicopter entity, + 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: - Result containing the report message or an error. """ callsign = self.traffic.callsign[idx] if self.capacity[idx] <= 0.0: diff --git a/packages/minisky/minisky/traffic/performance/perfoap.py b/packages/minisky/minisky/traffic/performance/perfoap.py index 1f2bf7e..64891b4 100644 --- a/packages/minisky/minisky/traffic/performance/perfoap.py +++ b/packages/minisky/minisky/traffic/performance/perfoap.py @@ -348,10 +348,6 @@ def update(self, dt: float = 1) -> None: # ----- update max acceleration ---- self.axmax = self.calc_axmax() - # TODO: implement thrust computation for rotor aircraft - # idx_rotor = np.where(self.lifttype==coeff.LiftType.ROTORCRAFT)[0] - # self.thrust[idx_rotor] = 0 - # update bank angle, due to phase change self.bank = np.where((self.phase == FlightPhase.GROUND), 15, self.bank) self.bank = np.where( diff --git a/zensical.toml b/zensical.toml index 961f81b..876afe9 100644 --- a/zensical.toml +++ b/zensical.toml @@ -35,6 +35,15 @@ nav = [ ] }, ] }, ] +extra_javascript = [ + "assets/js/katex.js", + "https://cdn.jsdelivr.net/npm/katex@0.17.0/dist/katex.min.js", + "https://cdn.jsdelivr.net/npm/katex@0.17.0/dist/contrib/auto-render.min.js", + "https://cdn.jsdelivr.net/npm/katex@0.17.0/dist/contrib/copy-tex.min.js", +] +extra_css = [ + "https://cdn.jsdelivr.net/npm/katex@0.17.0/dist/katex.min.css", +] [project.theme] features = [ @@ -102,6 +111,7 @@ on_undefined = "strict" [project.markdown_extensions] admonition = {} attr_list = {} +"pymdownx.arithmatex" = { generic = true } "pymdownx.details" = {} "pymdownx.superfences" = {} "pymdownx.highlight" = {}