aerial-kit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aerial_kit/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # aerial_kit
2
+
3
+ A shared control-stack package for aerial robots: airframe capabilities, actuator
4
+ allocation/trim, 6-DOF and point-mass dynamics models, controllers (PID/LQR/MPC, and an
5
+ L1 + TECS fixed-wing autopilot), and lateral/longitudinal guidance laws -- all pure
6
+ numpy/scipy, no ROS and no matplotlib dependency.
7
+
8
+ It grew out of [UAV-Controller](https://github.com/RawFish69/UAV-Controller), a
9
+ quadcopter-to-general-aerial-robotics control stack, where it's the shared layer imported
10
+ by both a standalone Python simulator and ROS 2 flight nodes so they don't duplicate
11
+ control code.
12
+
13
+ ## What's in it
14
+
15
+ - `aerial_kit.types` -- `SimState`, `ControlTarget`, `Capabilities`, `Wrench`,
16
+ `CommandKind`, `Waypoint`
17
+ - `aerial_kit.interfaces` -- `Controller`, `DynamicsBackend`, `Planner` ABCs
18
+ - `aerial_kit.registry` -- a pluggable component registry
19
+ (`register_airframe`/`register_controller`/etc., `create_*` factories)
20
+ - `aerial_kit.airframes` -- `Airframe` ABC, `MultirotorAirframe` (mixer-driven quad/hex/
21
+ octo), `TwinWingAirframe` (elevon + differential-thrust allocation, trim)
22
+ - `aerial_kit.dynamics` -- 6-DOF multirotor dynamics, point-mass dynamics, and a
23
+ hand-rolled 6-DOF fixed-wing model with a flat-plate-blended lift curve, drag polar,
24
+ and moment derivatives
25
+ - `aerial_kit.controllers` -- PID/LQR/MPC position controllers, and
26
+ `FixedWingL1TECSController` (L1 lateral guidance + TECS-lite longitudinal control +
27
+ coordinated-turn attitude PID)
28
+ - `aerial_kit.guidance` -- `l1_bank_command`, `tecs_command` as standalone functions
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ from aerial_kit.registry import register_builtin_components, create_airframe, create_controller
34
+
35
+ register_builtin_components()
36
+ airframe = create_airframe("quad")
37
+ controller = create_controller("pid")
38
+ print(airframe.capabilities)
39
+ ```
40
+
41
+ `register_builtin_components()` here registers only what lives inside `aerial_kit`
42
+ itself (airframes, controllers) -- it has no ROS or matplotlib dependency and does not
43
+ know about any host application's own dynamics backends or planners. A host application
44
+ (like `sim_py` in the parent repo) registers its own backends/planners into the same
45
+ registry alongside this.
46
+
47
+ ## Status
48
+
49
+ Early, actively developed alongside the parent repo's twin-motor-wing simulation work.
50
+ The multirotor path (quad/hex/octo airframes, PID/LQR/MPC) is stable and used by a flying
51
+ quadcopter's ground-station simulator. The fixed-wing path (`TwinWingAirframe`,
52
+ `FixedWingL1TECSController`, guidance) is simulation-only so far -- not flown, and its aero
53
+ model uses plausible placeholder coefficients rather than a fitted model of any specific
54
+ real airframe.
55
+
56
+ ## License
57
+
58
+ MIT
aerial_kit/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """aerial_kit: shared control-stack package for aerial robots.
2
+
3
+ No ROS and no matplotlib dependency, so it can be imported by both the
4
+ standalone ``sim_py`` simulator and the ROS 2 nodes in ``ros2_ws/src/``.
5
+ """
@@ -0,0 +1,9 @@
1
+ """Airframe profiles: capabilities, allocation, and trim per vehicle family."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import Airframe
6
+ from .fixed_wing import TwinWingAirframe
7
+ from .multirotor import MultirotorAirframe
8
+
9
+ __all__ = ["Airframe", "MultirotorAirframe", "TwinWingAirframe"]
@@ -0,0 +1,25 @@
1
+ """Abstract airframe interface: capabilities, allocation, trim."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ import numpy as np
8
+
9
+ from ..types import Capabilities, SimState, Wrench
10
+
11
+
12
+ class Airframe(ABC):
13
+ """A vehicle profile: what it can do, and how a wrench becomes actuator commands."""
14
+
15
+ name: str
16
+ capabilities: Capabilities
17
+
18
+ @abstractmethod
19
+ def allocate(self, wrench: Wrench, state: SimState) -> np.ndarray:
20
+ """Body thrust+moment -> per-actuator commands."""
21
+
22
+ @abstractmethod
23
+ def trim(self, state: SimState) -> np.ndarray:
24
+ """Actuator vector holding steady flight (hover for multirotor, level
25
+ cruise for a wing)."""
@@ -0,0 +1,104 @@
1
+ """Twin-motor flying wing: two motors, two elevons, no rudder.
2
+
3
+ See plans/04-twin-wing-control/PLAN.md for the design. Yaw is differential
4
+ thrust only -- authority is proportional to throttle and collapses at idle
5
+ or in a glide, so a controller must coordinate turns with bank rather than
6
+ relying on yaw. Aero coefficients for a specific airframe are unknown at
7
+ this stage (no FixedWingBackend/aero model exists yet), so allocate() below
8
+ is a deliberately simple, documented placeholder: moment_body is
9
+ interpreted directly in actuator-native units (elevon radians for
10
+ pitch/roll) rather than through a dynamic-pressure-scaled aero model. It
11
+ will be superseded once a real aero model can convert aerodynamic moments
12
+ into these commands.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+
19
+ from ..types import Capabilities, CommandKind, SimState, Wrench
20
+ from .base import Airframe
21
+
22
+
23
+ class TwinWingAirframe(Airframe):
24
+ """Flying wing: throttle_L/R + elevon_L/R actuators, AIRSPEED_NAV command kind."""
25
+
26
+ def __init__(
27
+ self,
28
+ mass_kg: float = 1.5,
29
+ cruise_airspeed_mps: float = 15.0,
30
+ min_airspeed_mps: float = 8.0,
31
+ max_airspeed_mps: float = 25.0,
32
+ max_climb_rate_mps: float = 3.0,
33
+ max_bank_deg: float = 45.0,
34
+ max_thrust_n: float = 10.0,
35
+ max_elevon_rad: float = np.radians(25.0),
36
+ motor_separation_m: float = 0.6,
37
+ trim_throttle_n: float = 3.0,
38
+ trim_elevon_rad: float = np.radians(2.0),
39
+ gravity_mps2: float = 9.81,
40
+ ) -> None:
41
+ if mass_kg <= 0.0:
42
+ raise ValueError(f"mass_kg must be positive, got {mass_kg}")
43
+ if motor_separation_m <= 0.0:
44
+ raise ValueError(f"motor_separation_m must be positive, got {motor_separation_m}")
45
+ if max_thrust_n <= 0.0:
46
+ raise ValueError(f"max_thrust_n must be positive, got {max_thrust_n}")
47
+ if not (0.0 < max_bank_deg < 90.0):
48
+ raise ValueError(f"max_bank_deg must be in (0, 90), got {max_bank_deg}")
49
+
50
+ self.name = "twin_wing"
51
+ self.mass_kg = float(mass_kg)
52
+ self.motor_separation_m = float(motor_separation_m)
53
+ self.max_thrust_n = float(max_thrust_n)
54
+ self.max_elevon_rad = float(max_elevon_rad)
55
+ self.trim_throttle_n = float(trim_throttle_n)
56
+ self.trim_elevon_rad = float(trim_elevon_rad)
57
+ self.gravity_mps2 = float(gravity_mps2)
58
+
59
+ phi_max_rad = np.radians(max_bank_deg)
60
+ min_turn_radius_m = (cruise_airspeed_mps**2) / (gravity_mps2 * np.tan(phi_max_rad))
61
+
62
+ self.capabilities = Capabilities(
63
+ can_hover=False,
64
+ min_airspeed_mps=float(min_airspeed_mps),
65
+ max_airspeed_mps=float(max_airspeed_mps),
66
+ max_climb_rate_mps=float(max_climb_rate_mps),
67
+ max_bank_deg=float(max_bank_deg),
68
+ min_turn_radius_m=float(min_turn_radius_m),
69
+ n_actuators=4,
70
+ command_kind=CommandKind.AIRSPEED_NAV,
71
+ )
72
+
73
+ def allocate(self, wrench: Wrench, state: SimState) -> np.ndarray:
74
+ """[throttle_L, throttle_R, elevon_L, elevon_R], saturating by priority.
75
+
76
+ Interprets ``wrench.force_body[0]`` as collective thrust demand (T_c) and
77
+ ``wrench.moment_body`` as [roll, pitch, yaw] demand. Pitch is preserved
78
+ first (it is what keeps the wing out of a stall), roll second, and yaw
79
+ (via differential thrust) last -- matching the plan's saturation-by-
80
+ priority rule rather than naive clipping.
81
+ """
82
+ t_c = float(np.asarray(wrench.force_body, dtype=float)[0])
83
+ roll_m, pitch_m, yaw_m = np.asarray(wrench.moment_body, dtype=float)
84
+
85
+ delta_pitch = float(np.clip(pitch_m, -self.max_elevon_rad, self.max_elevon_rad))
86
+ roll_headroom = self.max_elevon_rad - abs(delta_pitch)
87
+ delta_roll = float(np.clip(roll_m, -roll_headroom, roll_headroom))
88
+ elevon_l = delta_pitch + delta_roll
89
+ elevon_r = delta_pitch - delta_roll
90
+
91
+ t_c = float(np.clip(t_c, 0.0, self.max_thrust_n))
92
+ q_c_demand = yaw_m / self.motor_separation_m
93
+ yaw_headroom = min(self.max_thrust_n - t_c, t_c)
94
+ q_c = float(np.clip(q_c_demand, -yaw_headroom, yaw_headroom))
95
+ throttle_l = float(np.clip(t_c + q_c, 0.0, self.max_thrust_n))
96
+ throttle_r = float(np.clip(t_c - q_c, 0.0, self.max_thrust_n))
97
+
98
+ return np.array([throttle_l, throttle_r, elevon_l, elevon_r], dtype=float)
99
+
100
+ def trim(self, state: SimState) -> np.ndarray:
101
+ return np.array(
102
+ [self.trim_throttle_n, self.trim_throttle_n, self.trim_elevon_rad, self.trim_elevon_rad],
103
+ dtype=float,
104
+ )
@@ -0,0 +1,81 @@
1
+ """Multirotor airframe: quad/hex/octo differ only in mixer geometry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from ..types import Capabilities, CommandKind, SimState, Wrench
8
+ from .base import Airframe
9
+
10
+
11
+ class MultirotorAirframe(Airframe):
12
+ """An omnidirectional multirotor: quad, hex, or octo, X or + layout.
13
+
14
+ The allocation matrix maps [total_thrust, Mx, My, Mz] to per-rotor thrust
15
+ via its pseudo-inverse. Rotors are placed on a regular polygon and spin
16
+ direction alternates so that all-equal thrust yields zero net yaw torque
17
+ at trim.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ arms: int = 4,
23
+ layout: str = "x",
24
+ arm_length_m: float = 0.2,
25
+ mass_kg: float = 1.0,
26
+ yaw_torque_coeff: float = 0.02,
27
+ gravity_mps2: float = 9.81,
28
+ max_airspeed_mps: float = 20.0,
29
+ max_climb_rate_mps: float = 5.0,
30
+ max_bank_deg: float = 45.0,
31
+ ) -> None:
32
+ if arms < 3:
33
+ raise ValueError(f"arms must be >= 3, got {arms}")
34
+ if layout not in {"x", "+"}:
35
+ raise ValueError(f"layout must be 'x' or '+', got {layout!r}")
36
+
37
+ self.name = f"multirotor_{arms}_{layout}"
38
+ self.arms = arms
39
+ self.layout = layout
40
+ self.arm_length_m = float(arm_length_m)
41
+ self.mass_kg = float(mass_kg)
42
+ self.yaw_torque_coeff = float(yaw_torque_coeff)
43
+ self.gravity_mps2 = float(gravity_mps2)
44
+
45
+ self.capabilities = Capabilities(
46
+ can_hover=True,
47
+ min_airspeed_mps=None,
48
+ max_airspeed_mps=float(max_airspeed_mps),
49
+ max_climb_rate_mps=float(max_climb_rate_mps),
50
+ max_bank_deg=float(max_bank_deg),
51
+ min_turn_radius_m=None,
52
+ n_actuators=arms,
53
+ command_kind=CommandKind.ACCEL,
54
+ )
55
+
56
+ self._mixer = self._build_mixer()
57
+ self._mixer_pinv = np.linalg.pinv(self._mixer)
58
+
59
+ def _build_mixer(self) -> np.ndarray:
60
+ offset = np.pi / self.arms if self.layout == "x" else 0.0
61
+ mixer = np.zeros((4, self.arms), dtype=float)
62
+ for i in range(self.arms):
63
+ theta = 2.0 * np.pi * i / self.arms + offset
64
+ x_i = self.arm_length_m * np.cos(theta)
65
+ y_i = self.arm_length_m * np.sin(theta)
66
+ spin_dir = 1.0 if i % 2 == 0 else -1.0
67
+ mixer[0, i] = 1.0
68
+ mixer[1, i] = y_i
69
+ mixer[2, i] = -x_i
70
+ mixer[3, i] = spin_dir * self.yaw_torque_coeff
71
+ return mixer
72
+
73
+ def allocate(self, wrench: Wrench, state: SimState) -> np.ndarray:
74
+ thrust_total = float(np.asarray(wrench.force_body, dtype=float)[2])
75
+ mx, my, mz = np.asarray(wrench.moment_body, dtype=float)
76
+ demand = np.array([thrust_total, mx, my, mz], dtype=float)
77
+ return self._mixer_pinv @ demand
78
+
79
+ def trim(self, state: SimState) -> np.ndarray:
80
+ demand = np.array([self.mass_kg * self.gravity_mps2, 0.0, 0.0, 0.0], dtype=float)
81
+ return self._mixer_pinv @ demand
@@ -0,0 +1,18 @@
1
+ """Controller implementations (numpy/scipy-only, no ROS/matplotlib)."""
2
+
3
+ from .basic import LQRController, MPCController, PIDController
4
+ from .fixed_wing import AttitudeGains, FixedWingL1TECSController, body_axis_pitch_bank
5
+ from .position import lqr_gain_double_integrator, lqr_position_control, mpc_position_control, pid_position_control
6
+
7
+ __all__ = [
8
+ "PIDController",
9
+ "LQRController",
10
+ "MPCController",
11
+ "FixedWingL1TECSController",
12
+ "AttitudeGains",
13
+ "body_axis_pitch_bank",
14
+ "pid_position_control",
15
+ "lqr_gain_double_integrator",
16
+ "lqr_position_control",
17
+ "mpc_position_control",
18
+ ]
@@ -0,0 +1,79 @@
1
+ """Built-in controller implementations for the simulation framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ import numpy as np
8
+
9
+ from ..interfaces import Controller
10
+ from ..types import ControlTarget, SimState, Waypoint
11
+ from .position import lqr_position_control, mpc_position_control, pid_position_control
12
+
13
+
14
+ class PIDController(Controller):
15
+ """PD position controller returning acceleration commands."""
16
+
17
+ def compute(
18
+ self,
19
+ state: SimState,
20
+ target_waypoint: Waypoint,
21
+ cfg: Mapping[str, Any],
22
+ ) -> ControlTarget:
23
+ ctrl_cfg = dict(cfg.get("controller", {}) or {})
24
+ pid_cfg = dict(ctrl_cfg.get("pid", {}) or {})
25
+ kp = float(pid_cfg.get("kp", 0.8))
26
+ kd = float(pid_cfg.get("kd", 1.2))
27
+
28
+ acc_cmd = pid_position_control(
29
+ state.position,
30
+ state.velocity,
31
+ target_waypoint.position,
32
+ kp=kp,
33
+ kd=kd,
34
+ )
35
+ return ControlTarget(accel_cmd=np.asarray(acc_cmd, dtype=float), metadata={"controller": "pid"})
36
+
37
+
38
+ class LQRController(Controller):
39
+ """LQR position controller returning acceleration commands."""
40
+
41
+ def compute(
42
+ self,
43
+ state: SimState,
44
+ target_waypoint: Waypoint,
45
+ cfg: Mapping[str, Any],
46
+ ) -> ControlTarget:
47
+ ctrl_cfg = dict(cfg.get("controller", {}) or {})
48
+ lqr_cfg = dict(ctrl_cfg.get("lqr", {}) or {})
49
+ acc_cmd = lqr_position_control(
50
+ state.position,
51
+ state.velocity,
52
+ target_waypoint.position,
53
+ q_pos=float(lqr_cfg.get("q_pos", 10.0)),
54
+ q_vel=float(lqr_cfg.get("q_vel", 2.0)),
55
+ r_acc=float(lqr_cfg.get("r_acc", 1.0)),
56
+ )
57
+ return ControlTarget(accel_cmd=np.asarray(acc_cmd, dtype=float), metadata={"controller": "lqr"})
58
+
59
+
60
+ class MPCController(Controller):
61
+ """MPC-like position controller returning acceleration commands."""
62
+
63
+ def compute(
64
+ self,
65
+ state: SimState,
66
+ target_waypoint: Waypoint,
67
+ cfg: Mapping[str, Any],
68
+ ) -> ControlTarget:
69
+ ctrl_cfg = dict(cfg.get("controller", {}) or {})
70
+ mpc_cfg = dict(ctrl_cfg.get("mpc", {}) or {})
71
+ acc_cmd = mpc_position_control(
72
+ state.position,
73
+ state.velocity,
74
+ target_waypoint.position,
75
+ q_pos=float(mpc_cfg.get("q_pos", 8.0)),
76
+ q_vel=float(mpc_cfg.get("q_vel", 2.0)),
77
+ r_acc=float(mpc_cfg.get("r_acc", 10.0)),
78
+ )
79
+ return ControlTarget(accel_cmd=np.asarray(acc_cmd, dtype=float), metadata={"controller": "mpc"})
@@ -0,0 +1,147 @@
1
+ """AIRSPEED_NAV controller for the twin wing: L1 lateral + TECS-lite + attitude PID.
2
+
3
+ Produces a body ``Wrench`` (in ``ControlTarget.metadata["wrench"]``, since the
4
+ shared ``ControlTarget.accel_cmd`` field is an ACCEL-kind concept this
5
+ controller doesn't use) for ``TwinWingAirframe.allocate()`` to turn into
6
+ actuator commands. Three loops, outer to inner, per plan 04:
7
+
8
+ 1. L1 picks a bank command from the vehicle's position/velocity toward the
9
+ target waypoint (see ``aerial_kit.guidance.l1`` for why this is the
10
+ point-target variant, not full path-segment L1).
11
+ 2. TECS-lite picks throttle + pitch command from airspeed/altitude error (see
12
+ ``aerial_kit.guidance.tecs``).
13
+ 3. An attitude PID converts bank/pitch *commands* into roll/pitch *moment*
14
+ demands using the vehicle's actual attitude, closing the loop.
15
+
16
+ Attitude feedback avoids extracting classical Euler angles from
17
+ ``attitude_quat`` (its body-FRD/world-ENU convention is not the one those
18
+ formulas assume -- see ``aerial_kit/dynamics/fixed_wing.py``'s docstring). Instead
19
+ it uses body axes projected onto the world frame directly: pitch is the
20
+ elevation of the body-forward axis above the horizon, bank is (approximately,
21
+ for small pitch) the depression of the body-right axis below the horizon.
22
+ This sidesteps Euler-order/sign ambiguity entirely and is exact at zero pitch,
23
+ which covers the cruise-flight regime this whole hand-rolled model targets.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+ from typing import Any, Mapping
30
+
31
+ import numpy as np
32
+
33
+ from ..dynamics.fixed_wing import quat_to_rotmat
34
+ from ..guidance.l1 import l1_bank_command
35
+ from ..guidance.tecs import TecsGains, tecs_command
36
+ from ..interfaces import Controller
37
+ from ..types import CommandKind, ControlTarget, SimState, Waypoint, Wrench
38
+
39
+
40
+ @dataclass
41
+ class AttitudeGains:
42
+ kp_roll: float = 0.6
43
+ kd_roll: float = 0.15
44
+ kp_pitch: float = 0.8
45
+ kd_pitch: float = 0.2
46
+ kp_yaw_rate: float = 2.0
47
+
48
+
49
+ def body_axis_pitch_bank(attitude_quat: np.ndarray) -> tuple[float, float]:
50
+ """(pitch_rad, bank_rad) from body axes projected onto the world frame."""
51
+ rot = quat_to_rotmat(attitude_quat)
52
+ body_x_world = rot @ np.array([1.0, 0.0, 0.0])
53
+ body_y_world = rot @ np.array([0.0, 1.0, 0.0])
54
+ pitch = float(np.arcsin(np.clip(body_x_world[2], -1.0, 1.0)))
55
+ bank = float(-np.arcsin(np.clip(body_y_world[2], -1.0, 1.0)))
56
+ return pitch, bank
57
+
58
+
59
+ class FixedWingL1TECSController(Controller):
60
+ """L1 + TECS-lite + attitude PID, emitting a Wrench for TwinWingAirframe."""
61
+
62
+ command_kind = CommandKind.AIRSPEED_NAV
63
+
64
+ def compute(
65
+ self,
66
+ state: SimState,
67
+ target_waypoint: Waypoint,
68
+ cfg: Mapping[str, Any],
69
+ ) -> ControlTarget:
70
+ ctrl_cfg = dict(cfg.get("controller", {}) or {})
71
+ fw_cfg = dict(ctrl_cfg.get("l1_tecs", {}) or {})
72
+
73
+ cruise_airspeed_mps = float(fw_cfg.get("cruise_airspeed_mps", 15.0))
74
+ l1_distance_m = float(fw_cfg.get("l1_distance_m", 40.0))
75
+ max_bank_rad = float(fw_cfg.get("max_bank_rad", np.radians(45.0)))
76
+ gravity_mps2 = float(fw_cfg.get("gravity_mps2", 9.81))
77
+
78
+ tecs_gains = TecsGains(
79
+ kp_throttle=float(fw_cfg.get("tecs_kp_throttle", 0.15)),
80
+ kp_pitch=float(fw_cfg.get("tecs_kp_pitch", 0.08)),
81
+ trim_throttle_n=float(fw_cfg.get("trim_throttle_n", 3.0)),
82
+ max_thrust_n=float(fw_cfg.get("max_thrust_n", 10.0)),
83
+ max_pitch_rad=float(fw_cfg.get("max_pitch_rad", 0.35)),
84
+ )
85
+ att_gains = AttitudeGains(
86
+ kp_roll=float(fw_cfg.get("kp_roll", 0.6)),
87
+ kd_roll=float(fw_cfg.get("kd_roll", 0.15)),
88
+ kp_pitch=float(fw_cfg.get("kp_pitch", 0.8)),
89
+ kd_pitch=float(fw_cfg.get("kd_pitch", 0.2)),
90
+ kp_yaw_rate=float(fw_cfg.get("kp_yaw_rate", 2.0)),
91
+ )
92
+
93
+ position = np.asarray(state.position, dtype=float)
94
+ velocity = np.asarray(state.velocity, dtype=float)
95
+ airspeed = float(np.linalg.norm(velocity[:2]))
96
+
97
+ bank_cmd = l1_bank_command(
98
+ position_xy=position[:2],
99
+ velocity_xy=velocity[:2],
100
+ target_xy=np.asarray(target_waypoint.position, dtype=float)[:2],
101
+ airspeed_mps=max(airspeed, 1.0),
102
+ l1_distance_m=l1_distance_m,
103
+ max_bank_rad=max_bank_rad,
104
+ gravity_mps2=gravity_mps2,
105
+ )
106
+ throttle_cmd_n, pitch_cmd = tecs_command(
107
+ airspeed_mps=airspeed,
108
+ airspeed_cmd_mps=cruise_airspeed_mps,
109
+ altitude_m=float(position[2]),
110
+ altitude_cmd_m=float(target_waypoint.position[2]),
111
+ gains=tecs_gains,
112
+ gravity_mps2=gravity_mps2,
113
+ )
114
+
115
+ if state.attitude_quat is None or state.body_rates is None:
116
+ pitch, bank = 0.0, 0.0
117
+ p_rate = q_rate = r_rate = 0.0
118
+ else:
119
+ pitch, bank = body_axis_pitch_bank(np.asarray(state.attitude_quat, dtype=float))
120
+ p_rate, q_rate, r_rate = np.asarray(state.body_rates, dtype=float)
121
+
122
+ roll_m = att_gains.kp_roll * (bank_cmd - bank) - att_gains.kd_roll * p_rate
123
+ pitch_m = att_gains.kp_pitch * (pitch_cmd - pitch) - att_gains.kd_pitch * q_rate
124
+
125
+ # Coordinated turn: command the yaw rate a level turn at this bank and
126
+ # airspeed actually needs (r_cmd = g*tan(bank)/V), rather than only
127
+ # damping yaw rate toward zero -- banking with no yaw-rate command
128
+ # produces sideslip, not a clean turn, since this model has no rudder
129
+ # and only weak weathervaning aero to close that gap on its own.
130
+ turn_airspeed = max(airspeed, 1.0)
131
+ r_cmd = gravity_mps2 * np.tan(bank) / turn_airspeed
132
+ yaw_m = att_gains.kp_yaw_rate * (r_cmd - r_rate)
133
+
134
+ wrench = Wrench(
135
+ force_body=np.array([throttle_cmd_n, 0.0, 0.0], dtype=float),
136
+ moment_body=np.array([roll_m, pitch_m, yaw_m], dtype=float),
137
+ )
138
+ return ControlTarget(
139
+ accel_cmd=np.zeros(3, dtype=float),
140
+ metadata={
141
+ "controller": "l1_tecs",
142
+ "wrench": wrench,
143
+ "bank_cmd": bank_cmd,
144
+ "pitch_cmd": pitch_cmd,
145
+ "throttle_cmd_n": throttle_cmd_n,
146
+ },
147
+ )
@@ -0,0 +1,79 @@
1
+ """Position-level controllers for the standalone simulator.
2
+
3
+ These operate on a simple point-mass model:
4
+ x_dot = v, v_dot = a_cmd
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from scipy.linalg import solve_continuous_are
11
+
12
+
13
+ def pid_position_control(
14
+ pos: np.ndarray,
15
+ vel: np.ndarray,
16
+ target: np.ndarray,
17
+ kp: float,
18
+ kd: float,
19
+ ) -> np.ndarray:
20
+ """Compute acceleration command using simple PD on position."""
21
+ pos = np.asarray(pos, dtype=float)
22
+ vel = np.asarray(vel, dtype=float)
23
+ target = np.asarray(target, dtype=float)
24
+
25
+ pos_error = target - pos
26
+ vel_error = -vel
27
+ acc_cmd = kp * pos_error + kd * vel_error
28
+ return acc_cmd
29
+
30
+
31
+ def lqr_gain_double_integrator(q_pos: float, q_vel: float, r_acc: float) -> np.ndarray:
32
+ """Compute continuous-time LQR gain for a 1D double integrator."""
33
+ A = np.array([[0.0, 1.0], [0.0, 0.0]])
34
+ B = np.array([[0.0], [1.0]])
35
+ Q = np.diag([q_pos, q_vel])
36
+ R = np.array([[r_acc]])
37
+
38
+ P = solve_continuous_are(A, B, Q, R)
39
+ K = np.linalg.inv(R) @ B.T @ P
40
+ return K
41
+
42
+
43
+ def lqr_position_control(
44
+ pos: np.ndarray,
45
+ vel: np.ndarray,
46
+ target: np.ndarray,
47
+ q_pos: float = 10.0,
48
+ q_vel: float = 2.0,
49
+ r_acc: float = 1.0,
50
+ ) -> np.ndarray:
51
+ """LQR state-feedback for each axis of a double integrator."""
52
+ pos = np.asarray(pos, dtype=float)
53
+ vel = np.asarray(vel, dtype=float)
54
+ target = np.asarray(target, dtype=float)
55
+
56
+ x_err = np.stack([pos - target, vel], axis=-1)
57
+ K = lqr_gain_double_integrator(q_pos, q_vel, r_acc)
58
+ acc_cmd = -np.einsum("ij,bj->bi", K, x_err).squeeze(-1)
59
+ return acc_cmd
60
+
61
+
62
+ def mpc_position_control(
63
+ pos: np.ndarray,
64
+ vel: np.ndarray,
65
+ target: np.ndarray,
66
+ q_pos: float,
67
+ q_vel: float,
68
+ r_acc: float,
69
+ ) -> np.ndarray:
70
+ """Very lightweight MPC-like controller."""
71
+ return lqr_position_control(pos, vel, target, q_pos=q_pos, q_vel=q_vel, r_acc=r_acc)
72
+
73
+
74
+ __all__ = [
75
+ "pid_position_control",
76
+ "lqr_gain_double_integrator",
77
+ "lqr_position_control",
78
+ "mpc_position_control",
79
+ ]
@@ -0,0 +1,17 @@
1
+ """6-DOF and point-mass dynamics models (numpy-only, no ROS/matplotlib)."""
2
+
3
+ from .fixed_wing import FixedWingDynamics, FixedWingParams, level_attitude_quat, lift_coefficient, quat_to_rotmat
4
+ from .multirotor import DynamicsParams, UAVDynamics
5
+ from .pointmass import PointMassDynamics, PointMassParams
6
+
7
+ __all__ = [
8
+ "FixedWingDynamics",
9
+ "FixedWingParams",
10
+ "level_attitude_quat",
11
+ "lift_coefficient",
12
+ "quat_to_rotmat",
13
+ "DynamicsParams",
14
+ "UAVDynamics",
15
+ "PointMassDynamics",
16
+ "PointMassParams",
17
+ ]