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 +58 -0
- aerial_kit/__init__.py +5 -0
- aerial_kit/airframes/__init__.py +9 -0
- aerial_kit/airframes/base.py +25 -0
- aerial_kit/airframes/fixed_wing.py +104 -0
- aerial_kit/airframes/multirotor.py +81 -0
- aerial_kit/controllers/__init__.py +18 -0
- aerial_kit/controllers/basic.py +79 -0
- aerial_kit/controllers/fixed_wing.py +147 -0
- aerial_kit/controllers/position.py +79 -0
- aerial_kit/dynamics/__init__.py +17 -0
- aerial_kit/dynamics/fixed_wing.py +248 -0
- aerial_kit/dynamics/multirotor.py +157 -0
- aerial_kit/dynamics/pointmass.py +38 -0
- aerial_kit/guidance/__init__.py +1 -0
- aerial_kit/guidance/l1.py +56 -0
- aerial_kit/guidance/tecs.py +53 -0
- aerial_kit/interfaces.py +73 -0
- aerial_kit/py.typed +0 -0
- aerial_kit/registry.py +112 -0
- aerial_kit/types.py +89 -0
- aerial_kit-0.1.0.dist-info/METADATA +82 -0
- aerial_kit-0.1.0.dist-info/RECORD +26 -0
- aerial_kit-0.1.0.dist-info/WHEEL +5 -0
- aerial_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
- aerial_kit-0.1.0.dist-info/top_level.txt +1 -0
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,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
|
+
]
|