drones-sim 0.2.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.
- drones_sim/__init__.py +15 -0
- drones_sim/control/__init__.py +15 -0
- drones_sim/control/allocation.py +76 -0
- drones_sim/control/cascaded.py +204 -0
- drones_sim/control/geometric.py +170 -0
- drones_sim/control/lqr.py +174 -0
- drones_sim/control/pid.py +50 -0
- drones_sim/dynamics/__init__.py +13 -0
- drones_sim/dynamics/config.py +117 -0
- drones_sim/dynamics/disturbances.py +262 -0
- drones_sim/dynamics/quadcopter.py +317 -0
- drones_sim/estimation/__init__.py +2 -0
- drones_sim/estimation/ahrs.py +79 -0
- drones_sim/estimation/ekf.py +594 -0
- drones_sim/logging/__init__.py +13 -0
- drones_sim/logging/csv_logger.py +76 -0
- drones_sim/logging/json_logger.py +53 -0
- drones_sim/math_utils.py +119 -0
- drones_sim/models/__init__.py +21 -0
- drones_sim/models/quadcopter.urdf +296 -0
- drones_sim/models/urdf_loader.py +444 -0
- drones_sim/rl/__init__.py +16 -0
- drones_sim/rl/actions.py +219 -0
- drones_sim/rl/env.py +195 -0
- drones_sim/rl/observations.py +40 -0
- drones_sim/rl/reward.py +69 -0
- drones_sim/rl/tasks.py +73 -0
- drones_sim/sensors/__init__.py +3 -0
- drones_sim/sensors/gps.py +158 -0
- drones_sim/sensors/imu.py +196 -0
- drones_sim/sensors/models.py +113 -0
- drones_sim/simulation.py +283 -0
- drones_sim/state.py +133 -0
- drones_sim/trajectory.py +391 -0
- drones_sim/visualization/__init__.py +23 -0
- drones_sim/visualization/api.py +50 -0
- drones_sim/visualization/dashboard.py +74 -0
- drones_sim/visualization/plots.py +183 -0
- drones_sim/visualization/rerun_viewer.py +411 -0
- drones_sim/visualization/viewer.py +452 -0
- drones_sim-0.2.0.dist-info/METADATA +323 -0
- drones_sim-0.2.0.dist-info/RECORD +45 -0
- drones_sim-0.2.0.dist-info/WHEEL +5 -0
- drones_sim-0.2.0.dist-info/licenses/LICENSE +21 -0
- drones_sim-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Generic PID controller with anti-windup and output saturation.
|
|
2
|
+
|
|
3
|
+
Consolidated from quadcopter_simulation.py.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PIDController:
|
|
12
|
+
"""Scalar PID with integral anti-windup and output clamping."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
kp: float,
|
|
17
|
+
ki: float,
|
|
18
|
+
kd: float,
|
|
19
|
+
output_limits: tuple[float, float] | None = None,
|
|
20
|
+
windup_limits: tuple[float, float] | None = None,
|
|
21
|
+
):
|
|
22
|
+
self.kp = kp
|
|
23
|
+
self.ki = ki
|
|
24
|
+
self.kd = kd
|
|
25
|
+
self.output_limits = output_limits
|
|
26
|
+
self.windup_limits = windup_limits
|
|
27
|
+
|
|
28
|
+
self._prev_error = 0.0
|
|
29
|
+
self._integral = 0.0
|
|
30
|
+
|
|
31
|
+
def reset(self) -> None:
|
|
32
|
+
self._prev_error = 0.0
|
|
33
|
+
self._integral = 0.0
|
|
34
|
+
|
|
35
|
+
def update(self, setpoint: float, measurement: float, dt: float) -> float:
|
|
36
|
+
error = setpoint - measurement
|
|
37
|
+
dt = max(dt, 1e-6)
|
|
38
|
+
|
|
39
|
+
self._integral += error * dt
|
|
40
|
+
if self.windup_limits is not None:
|
|
41
|
+
self._integral = np.clip(self._integral, *self.windup_limits)
|
|
42
|
+
|
|
43
|
+
derivative = (error - self._prev_error) / dt
|
|
44
|
+
self._prev_error = error
|
|
45
|
+
|
|
46
|
+
output = self.kp * error + self.ki * self._integral + self.kd * derivative
|
|
47
|
+
if self.output_limits is not None:
|
|
48
|
+
output = np.clip(output, *self.output_limits)
|
|
49
|
+
|
|
50
|
+
return float(output)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .config import QuadcopterConfig
|
|
2
|
+
from .disturbances import ( # noqa: F401
|
|
3
|
+
ConstantWind,
|
|
4
|
+
Disturbance,
|
|
5
|
+
DrydenGust,
|
|
6
|
+
GroundEffect,
|
|
7
|
+
MotorFailure,
|
|
8
|
+
PayloadDrop,
|
|
9
|
+
StepWind,
|
|
10
|
+
)
|
|
11
|
+
from .quadcopter import QuadcopterDynamics
|
|
12
|
+
|
|
13
|
+
__all__ = ["QuadcopterConfig", "QuadcopterDynamics"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Physical configuration for the quadcopter plant."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class QuadcopterConfig:
|
|
13
|
+
"""Validated physical and actuator parameters.
|
|
14
|
+
|
|
15
|
+
The default rotor positions form a ``+`` layout compatible with the
|
|
16
|
+
historical model: front, right, rear, left. Positive rotor directions
|
|
17
|
+
produce positive yaw moments.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
mass: float = 1.0
|
|
21
|
+
inertia: NDArray = field(
|
|
22
|
+
default_factory=lambda: np.diag([0.01, 0.01, 0.018])
|
|
23
|
+
)
|
|
24
|
+
arm_length: float = 0.2
|
|
25
|
+
thrust_coefficient: float = 1.0e-6
|
|
26
|
+
moment_coefficient: float = 1.0e-7
|
|
27
|
+
linear_drag: NDArray = field(default_factory=lambda: np.full(3, 0.1))
|
|
28
|
+
quadratic_drag: NDArray = field(default_factory=lambda: np.zeros(3))
|
|
29
|
+
gravity: float = 9.81
|
|
30
|
+
motor_time_constant: float = 0.04
|
|
31
|
+
min_motor_speed: float = 0.0
|
|
32
|
+
max_motor_speed: float = 4000.0
|
|
33
|
+
rotor_directions: NDArray = field(
|
|
34
|
+
default_factory=lambda: np.array([1.0, -1.0, 1.0, -1.0])
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
self.mass = float(self.mass)
|
|
39
|
+
self.arm_length = float(self.arm_length)
|
|
40
|
+
self.thrust_coefficient = float(self.thrust_coefficient)
|
|
41
|
+
self.moment_coefficient = float(self.moment_coefficient)
|
|
42
|
+
self.gravity = float(self.gravity)
|
|
43
|
+
self.motor_time_constant = float(self.motor_time_constant)
|
|
44
|
+
self.min_motor_speed = float(self.min_motor_speed)
|
|
45
|
+
self.max_motor_speed = float(self.max_motor_speed)
|
|
46
|
+
self.inertia = np.asarray(self.inertia, dtype=float).copy()
|
|
47
|
+
if self.inertia.shape == (3,):
|
|
48
|
+
self.inertia = np.diag(self.inertia)
|
|
49
|
+
self.linear_drag = self._axis_value(self.linear_drag, "linear_drag")
|
|
50
|
+
self.quadratic_drag = self._axis_value(
|
|
51
|
+
self.quadratic_drag, "quadratic_drag"
|
|
52
|
+
)
|
|
53
|
+
self.rotor_directions = np.asarray(
|
|
54
|
+
self.rotor_directions, dtype=float
|
|
55
|
+
).copy()
|
|
56
|
+
self.validate()
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _axis_value(value: NDArray | float, name: str) -> NDArray:
|
|
60
|
+
array = np.asarray(value, dtype=float)
|
|
61
|
+
if array.ndim == 0:
|
|
62
|
+
array = np.full(3, float(array))
|
|
63
|
+
if array.shape != (3,):
|
|
64
|
+
raise ValueError(f"{name} must be scalar or shape (3,), got {array.shape}")
|
|
65
|
+
return array.copy()
|
|
66
|
+
|
|
67
|
+
def validate(self) -> None:
|
|
68
|
+
if self.mass <= 0.0:
|
|
69
|
+
raise ValueError("mass must be positive")
|
|
70
|
+
if self.arm_length <= 0.0:
|
|
71
|
+
raise ValueError("arm_length must be positive")
|
|
72
|
+
if self.thrust_coefficient <= 0.0 or self.moment_coefficient <= 0.0:
|
|
73
|
+
raise ValueError("rotor coefficients must be positive")
|
|
74
|
+
if self.inertia.shape != (3, 3):
|
|
75
|
+
raise ValueError("inertia must have shape (3, 3)")
|
|
76
|
+
if not np.allclose(self.inertia, self.inertia.T):
|
|
77
|
+
raise ValueError("inertia must be symmetric")
|
|
78
|
+
if np.min(np.linalg.eigvalsh(self.inertia)) <= 0.0:
|
|
79
|
+
raise ValueError("inertia must be positive definite")
|
|
80
|
+
if np.any(self.linear_drag < 0.0) or np.any(self.quadratic_drag < 0.0):
|
|
81
|
+
raise ValueError("drag coefficients must be non-negative")
|
|
82
|
+
if self.gravity <= 0.0:
|
|
83
|
+
raise ValueError("gravity must be positive")
|
|
84
|
+
if self.motor_time_constant < 0.0:
|
|
85
|
+
raise ValueError("motor_time_constant cannot be negative")
|
|
86
|
+
if not 0.0 <= self.min_motor_speed < self.max_motor_speed:
|
|
87
|
+
raise ValueError("motor speed limits are invalid")
|
|
88
|
+
if self.rotor_directions.shape != (4,):
|
|
89
|
+
raise ValueError("rotor_directions must have shape (4,)")
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def rotor_positions(self) -> NDArray:
|
|
93
|
+
length = self.arm_length
|
|
94
|
+
return np.array(
|
|
95
|
+
[[length, 0.0, 0.0], [0.0, length, 0.0],
|
|
96
|
+
[-length, 0.0, 0.0], [0.0, -length, 0.0]]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def allocation_matrix(self, efficiencies: NDArray | None = None) -> NDArray:
|
|
100
|
+
"""Map squared rotor speeds to ``[thrust, tau_x, tau_y, tau_z]``."""
|
|
101
|
+
efficiency = (
|
|
102
|
+
np.ones(4)
|
|
103
|
+
if efficiencies is None
|
|
104
|
+
else np.asarray(efficiencies, dtype=float)
|
|
105
|
+
)
|
|
106
|
+
if efficiency.shape != (4,):
|
|
107
|
+
raise ValueError("efficiencies must have shape (4,)")
|
|
108
|
+
force_gain = self.thrust_coefficient * efficiency
|
|
109
|
+
positions = self.rotor_positions
|
|
110
|
+
return np.vstack(
|
|
111
|
+
[
|
|
112
|
+
force_gain,
|
|
113
|
+
positions[:, 1] * force_gain,
|
|
114
|
+
-positions[:, 0] * force_gain,
|
|
115
|
+
self.rotor_directions * self.moment_coefficient * efficiency,
|
|
116
|
+
]
|
|
117
|
+
)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Disturbance models for quadcopter dynamics.
|
|
2
|
+
|
|
3
|
+
Each disturbance can inject an external force (world frame), an external torque
|
|
4
|
+
(body frame), or modify the physical parameters of the quadcopter (mass, inertia,
|
|
5
|
+
rotor coefficients) at a given simulation time.
|
|
6
|
+
|
|
7
|
+
All disturbances derive from the abstract ``Disturbance`` base and are wired into
|
|
8
|
+
``QuadcopterDynamics._derivatives`` via the ``disturbances=`` list parameter in the
|
|
9
|
+
constructor (see ``quadcopter.py``).
|
|
10
|
+
|
|
11
|
+
Reference
|
|
12
|
+
---------
|
|
13
|
+
- Dryden gust model: MIL-F-8785C / U.S. Military Specification on Flying Qualities
|
|
14
|
+
of Piloted Airplanes (1980).
|
|
15
|
+
- Ground effect: Cheeseman & Bennett (1955), "The Effect of the Ground on a
|
|
16
|
+
Helicopter Rotor in Forward Flight", ARC R&M 3021.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from abc import ABC
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
from numpy.typing import NDArray
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Disturbance(ABC):
|
|
28
|
+
"""Abstract base for any external disturbance acting on the quadcopter.
|
|
29
|
+
|
|
30
|
+
Subclasses override one or more of the three hooks. The default
|
|
31
|
+
implementations are no-ops.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def reset(self) -> None:
|
|
35
|
+
"""Re-seed any internal state (called at the start of a new episode)."""
|
|
36
|
+
|
|
37
|
+
def advance(self, t: float, dt: float, state: NDArray) -> None:
|
|
38
|
+
"""Advance stochastic state once per integration step.
|
|
39
|
+
|
|
40
|
+
This is separate from force evaluation because RK4 evaluates the force
|
|
41
|
+
model four times per integration step.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def external_force(self, t: float, dt: float, state: NDArray) -> NDArray:
|
|
45
|
+
"""External force in the world frame [N].
|
|
46
|
+
|
|
47
|
+
Called inside ``_derivatives``; result is added directly to the total
|
|
48
|
+
force vector before dividing by mass.
|
|
49
|
+
"""
|
|
50
|
+
return np.zeros(3)
|
|
51
|
+
|
|
52
|
+
def external_torque(self, t: float, dt: float, state: NDArray) -> NDArray:
|
|
53
|
+
"""External torque in the body frame [N·m]."""
|
|
54
|
+
return np.zeros(3)
|
|
55
|
+
|
|
56
|
+
def modify_dynamics(self, quad: object, t: float) -> None:
|
|
57
|
+
"""Mutate quadcopter physical attributes in-place (mass, inertia, etc.).
|
|
58
|
+
|
|
59
|
+
Called once per ``update()`` call, before ``_derivatives``.
|
|
60
|
+
The ``quad`` argument is the ``QuadcopterDynamics`` instance.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Wind models
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
class ConstantWind(Disturbance):
|
|
69
|
+
"""Steady world-frame wind force.
|
|
70
|
+
|
|
71
|
+
The wind pushes the drone with a force proportional to the wind velocity:
|
|
72
|
+
|
|
73
|
+
F = k_d * v_wind
|
|
74
|
+
|
|
75
|
+
where *k_d* is matched to the quadcopter's drag coefficient so the
|
|
76
|
+
simulation remains dimensionally consistent.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, velocity: NDArray, k_d: float = 0.1) -> None:
|
|
80
|
+
self.velocity = np.asarray(velocity, dtype=float)
|
|
81
|
+
self.k_d = k_d
|
|
82
|
+
|
|
83
|
+
def external_force(self, t: float, dt: float, state: NDArray) -> NDArray:
|
|
84
|
+
# Retained for direct/legacy callers. The plant calls ``advance`` once
|
|
85
|
+
# and obtains the resulting wind through ``wind_velocity``.
|
|
86
|
+
self.advance(t, dt, state)
|
|
87
|
+
return np.zeros(3)
|
|
88
|
+
|
|
89
|
+
def wind_velocity(self, t: float = 0.0) -> NDArray:
|
|
90
|
+
return self.velocity.copy()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class StepWind(Disturbance):
|
|
94
|
+
"""Wind that switches on at time *t_on* with a given world-frame velocity."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, velocity: NDArray, t_on: float = 0.0) -> None:
|
|
97
|
+
self.velocity = np.asarray(velocity, dtype=float)
|
|
98
|
+
self.t_on = t_on
|
|
99
|
+
|
|
100
|
+
def external_force(self, t: float, dt: float, state: NDArray) -> NDArray:
|
|
101
|
+
return np.zeros(3)
|
|
102
|
+
|
|
103
|
+
def wind_velocity(self, t: float = 0.0) -> NDArray:
|
|
104
|
+
return self.velocity.copy() if t >= self.t_on else np.zeros(3)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class DrydenGust(Disturbance):
|
|
108
|
+
"""Dryden turbulence model — continuous random gust in the world frame.
|
|
109
|
+
|
|
110
|
+
The Dryden spectrum is approximated in the time domain as a first-order
|
|
111
|
+
Gauss-Markov process with correlation length *L* (m) and intensity *sigma*
|
|
112
|
+
(m/s). The filter is:
|
|
113
|
+
|
|
114
|
+
d(wind)/dt = -(V / L) * wind + sigma * sqrt(2*V / (pi*L)) * eta(t)
|
|
115
|
+
|
|
116
|
+
where *V* is the reference speed (default: hover-induced downwash proxy) and
|
|
117
|
+
*eta* ~ N(0,1/dt) per step.
|
|
118
|
+
|
|
119
|
+
Reference
|
|
120
|
+
---------
|
|
121
|
+
MIL-F-8785C, §3.7.2 "Discrete Gust and Continuous Turbulence Models"
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
intensity: float = 2.0, # sigma [m/s]
|
|
127
|
+
length_scale: float = 50.0, # L [m]
|
|
128
|
+
reference_speed: float = 5.0, # V [m/s]
|
|
129
|
+
seed: int | None = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
self.intensity = intensity
|
|
132
|
+
self.length_scale = length_scale
|
|
133
|
+
self.reference_speed = reference_speed
|
|
134
|
+
|
|
135
|
+
self._seed = seed
|
|
136
|
+
self._rng = np.random.default_rng(seed)
|
|
137
|
+
self._wind = np.zeros(3)
|
|
138
|
+
self._alpha_cache: float | None = None # exp(-V/L * dt), cached per dt
|
|
139
|
+
|
|
140
|
+
def reset(self) -> None:
|
|
141
|
+
self._rng = np.random.default_rng(self._seed)
|
|
142
|
+
self._wind = np.zeros(3)
|
|
143
|
+
self._alpha_cache = None
|
|
144
|
+
|
|
145
|
+
def advance(self, t: float, dt: float, state: NDArray) -> None:
|
|
146
|
+
if dt <= 0.0:
|
|
147
|
+
return
|
|
148
|
+
# Build the Dryden filter coefficients for this dt (cached).
|
|
149
|
+
if self._alpha_cache is None or self._alpha_cache != dt:
|
|
150
|
+
V, L = self.reference_speed, self.length_scale
|
|
151
|
+
self._alpha_cache = float(dt)
|
|
152
|
+
self._alpha = float(np.exp(-V / L * dt))
|
|
153
|
+
self._beta = self.intensity * float(np.sqrt(2.0 * V / (np.pi * L)))
|
|
154
|
+
|
|
155
|
+
# Discrete Gauss-Markov step
|
|
156
|
+
drive = self._rng.normal(0.0, 1.0, 3) / np.sqrt(dt + 1e-12)
|
|
157
|
+
self._wind = self._alpha * self._wind + self._beta * drive
|
|
158
|
+
|
|
159
|
+
def external_force(self, t: float, dt: float, state: NDArray) -> NDArray:
|
|
160
|
+
self.advance(t, dt, state)
|
|
161
|
+
return np.zeros(3)
|
|
162
|
+
|
|
163
|
+
def wind_velocity(self, t: float = 0.0) -> NDArray:
|
|
164
|
+
return self._wind.copy()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Mechanical / failure disturbances
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
class MotorFailure(Disturbance):
|
|
172
|
+
"""Degrade one or more rotors' thrust coefficient *k_f* at time *t_fail*.
|
|
173
|
+
|
|
174
|
+
The affected motor(s) produce ``efficiency * k_f`` after failure, simulating
|
|
175
|
+
a partial loss-of-thrust event (propeller damage, ESC brownout).
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
motor_indices: list[int],
|
|
181
|
+
efficiency: float = 0.3,
|
|
182
|
+
t_fail: float = 1.0,
|
|
183
|
+
) -> None:
|
|
184
|
+
self.motor_indices = motor_indices
|
|
185
|
+
self.efficiency = efficiency
|
|
186
|
+
self.t_fail = t_fail
|
|
187
|
+
self._failed = False
|
|
188
|
+
|
|
189
|
+
def reset(self) -> None:
|
|
190
|
+
self._failed = False
|
|
191
|
+
|
|
192
|
+
def modify_dynamics(self, quad: object, t: float) -> None:
|
|
193
|
+
if not self._failed and t >= self.t_fail:
|
|
194
|
+
self._failed = True
|
|
195
|
+
|
|
196
|
+
def rotor_efficiencies(self, t: float) -> NDArray:
|
|
197
|
+
efficiency = np.ones(4)
|
|
198
|
+
if t >= self.t_fail:
|
|
199
|
+
efficiency[self.motor_indices] = self.efficiency
|
|
200
|
+
return efficiency
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class PayloadDrop(Disturbance):
|
|
204
|
+
"""Instantaneous mass change at time *t_drop* (simulates payload release).
|
|
205
|
+
|
|
206
|
+
At ``t >= t_drop`` the drone's mass is set to the post-drop value.
|
|
207
|
+
On ``reset()`` the mass is restored to the original constructor value.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(self, post_drop_mass: float, t_drop: float = 2.0) -> None:
|
|
211
|
+
self.post_drop_mass = post_drop_mass
|
|
212
|
+
self.t_drop = t_drop
|
|
213
|
+
self._active = False
|
|
214
|
+
self._original_mass: float | None = None
|
|
215
|
+
|
|
216
|
+
def reset(self) -> None:
|
|
217
|
+
self._active = False
|
|
218
|
+
|
|
219
|
+
def modify_dynamics(self, quad: object, t: float) -> None:
|
|
220
|
+
# Capture the drone's mass on the very first call (before any mutation).
|
|
221
|
+
if self._original_mass is None:
|
|
222
|
+
self._original_mass = float(quad.mass)
|
|
223
|
+
if not self._active and t >= self.t_drop:
|
|
224
|
+
self._active = True
|
|
225
|
+
if self._active:
|
|
226
|
+
quad.mass = self.post_drop_mass
|
|
227
|
+
else:
|
|
228
|
+
quad.mass = self._original_mass
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# Environmental disturbances
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
class GroundEffect(Disturbance):
|
|
236
|
+
"""Thrust augmentation near the ground.
|
|
237
|
+
|
|
238
|
+
The thrust multiplier follows an exponential decay with altitude:
|
|
239
|
+
|
|
240
|
+
T/T∞ = 1 + 0.3 * exp(-h / r)
|
|
241
|
+
|
|
242
|
+
where *h* is altitude above ground [m] and *r* is the rotor radius [m].
|
|
243
|
+
This gives:
|
|
244
|
+
- h = 0: T/T∞ ≈ 1.3 (maximum augmentation)
|
|
245
|
+
- h = r: T/T∞ ≈ 1.11
|
|
246
|
+
- h = 2r: T/T∞ ≈ 1.04
|
|
247
|
+
- h → ∞: T/T∞ → 1.0
|
|
248
|
+
|
|
249
|
+
The multiplier is applied inside ``_derivatives`` by calling
|
|
250
|
+
``ground_effect.thrust_multiplier(altitude)`` and scaling the total thrust.
|
|
251
|
+
|
|
252
|
+
Attributes
|
|
253
|
+
----------
|
|
254
|
+
radius: Rotor radius [m] (default 0.13 m for a 10" propeller).
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
def __init__(self, radius: float = 0.13) -> None:
|
|
258
|
+
self.radius = radius
|
|
259
|
+
|
|
260
|
+
def thrust_multiplier(self, alt: float) -> float:
|
|
261
|
+
alt_safe = max(alt, 0.0)
|
|
262
|
+
return float(1.0 + 0.3 * np.exp(-alt_safe / self.radius))
|