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,196 @@
|
|
|
1
|
+
"""IMU sensor simulator with realistic noise, bias, scale factor, and optional temperature effects.
|
|
2
|
+
|
|
3
|
+
Consolidated from imu_ekf_simulation.py, imu_ekf_fusion_enhanced.py,
|
|
4
|
+
imu_ekf_fusion_final.py, and imu_ekf_fusion_simplified.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
from scipy.spatial.transform import Rotation as R
|
|
14
|
+
|
|
15
|
+
from ..state import VehicleState
|
|
16
|
+
from ..trajectory import TrajectoryData
|
|
17
|
+
from .models import SensorNoiseModel, TemperatureModel
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class IMUConfig:
|
|
22
|
+
"""Configuration for IMU sensor characteristics."""
|
|
23
|
+
accel_noise_std: float = 0.05 # m/s^2
|
|
24
|
+
accel_bias_range: float = 0.1 # m/s^2
|
|
25
|
+
accel_scale: tuple[float, float] = (0.98, 1.02)
|
|
26
|
+
|
|
27
|
+
gyro_noise_std: float = 0.01 # rad/s
|
|
28
|
+
gyro_bias_range: float = 0.005 # rad/s
|
|
29
|
+
gyro_scale: tuple[float, float] = (0.99, 1.01)
|
|
30
|
+
|
|
31
|
+
mag_noise_std: float = 0.5 # uT
|
|
32
|
+
mag_bias_range: float = 1.0 # uT
|
|
33
|
+
mag_scale: tuple[float, float] = (0.97, 1.03)
|
|
34
|
+
|
|
35
|
+
# Earth references
|
|
36
|
+
gravity: NDArray = field(default_factory=lambda: np.array([0.0, 0.0, 9.81]))
|
|
37
|
+
mag_field_ref: NDArray = field(default_factory=lambda: np.array([25.0, 5.0, -40.0]))
|
|
38
|
+
|
|
39
|
+
enable_temperature: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class IMUData:
|
|
44
|
+
"""Container for simulated IMU readings."""
|
|
45
|
+
t: NDArray
|
|
46
|
+
accel: NDArray # (N, 3)
|
|
47
|
+
gyro: NDArray # (N, 3)
|
|
48
|
+
mag: NDArray # (N, 3)
|
|
49
|
+
temperature: NDArray | None = None # (N,) if temperature model enabled
|
|
50
|
+
|
|
51
|
+
# Ground-truth biases for validation
|
|
52
|
+
accel_bias: NDArray = field(default_factory=lambda: np.zeros(3))
|
|
53
|
+
gyro_bias: NDArray = field(default_factory=lambda: np.zeros(3))
|
|
54
|
+
mag_bias: NDArray = field(default_factory=lambda: np.zeros(3))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class IMUMeasurement:
|
|
59
|
+
"""One timestamped 9-axis IMU measurement."""
|
|
60
|
+
|
|
61
|
+
acceleration: NDArray
|
|
62
|
+
angular_velocity: NDArray
|
|
63
|
+
magnetic_field: NDArray
|
|
64
|
+
temperature: float | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class IMUSimulator:
|
|
68
|
+
"""Simulate 9-axis IMU readings from a known trajectory.
|
|
69
|
+
|
|
70
|
+
Supports:
|
|
71
|
+
- Additive white Gaussian noise
|
|
72
|
+
- Constant bias per axis
|
|
73
|
+
- Scale-factor errors
|
|
74
|
+
- Optional temperature-dependent drift
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, config: IMUConfig | None = None, seed: int | None = 42):
|
|
78
|
+
self.cfg = config or IMUConfig()
|
|
79
|
+
self._seed = seed
|
|
80
|
+
seed_sequence = np.random.SeedSequence(seed)
|
|
81
|
+
accel_rng, gyro_rng, mag_rng, temp_rng = [
|
|
82
|
+
np.random.default_rng(child) for child in seed_sequence.spawn(4)
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
self.accel_model = SensorNoiseModel(
|
|
86
|
+
noise_std=self.cfg.accel_noise_std,
|
|
87
|
+
bias_range=self.cfg.accel_bias_range,
|
|
88
|
+
scale_factor_range=self.cfg.accel_scale,
|
|
89
|
+
rng=accel_rng,
|
|
90
|
+
)
|
|
91
|
+
self.gyro_model = SensorNoiseModel(
|
|
92
|
+
noise_std=self.cfg.gyro_noise_std,
|
|
93
|
+
bias_range=self.cfg.gyro_bias_range,
|
|
94
|
+
scale_factor_range=self.cfg.gyro_scale,
|
|
95
|
+
rng=gyro_rng,
|
|
96
|
+
)
|
|
97
|
+
self.mag_model = SensorNoiseModel(
|
|
98
|
+
noise_std=self.cfg.mag_noise_std,
|
|
99
|
+
bias_range=self.cfg.mag_bias_range,
|
|
100
|
+
scale_factor_range=self.cfg.mag_scale,
|
|
101
|
+
rng=mag_rng,
|
|
102
|
+
)
|
|
103
|
+
self.temp_model = (
|
|
104
|
+
TemperatureModel(rng=temp_rng) if self.cfg.enable_temperature else None
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def reset(self, seed: int | None = None) -> None:
|
|
108
|
+
"""Reset biases and local random streams for a new episode."""
|
|
109
|
+
self.__init__(self.cfg, self._seed if seed is None else seed)
|
|
110
|
+
|
|
111
|
+
def step(
|
|
112
|
+
self,
|
|
113
|
+
state: VehicleState,
|
|
114
|
+
linear_acceleration_world: NDArray,
|
|
115
|
+
dt: float,
|
|
116
|
+
*,
|
|
117
|
+
duration: float = 1.0,
|
|
118
|
+
) -> IMUMeasurement:
|
|
119
|
+
"""Generate one measurement from a physical vehicle state.
|
|
120
|
+
|
|
121
|
+
The accelerometer convention is specific force
|
|
122
|
+
``R.T @ (linear_acceleration + gravity_up)``.
|
|
123
|
+
"""
|
|
124
|
+
acceleration = np.asarray(linear_acceleration_world, dtype=float)
|
|
125
|
+
if acceleration.shape != (3,):
|
|
126
|
+
raise ValueError("linear_acceleration_world must have shape (3,)")
|
|
127
|
+
rotation = state.rotation_matrix
|
|
128
|
+
true_accel = rotation.T @ (acceleration + self.cfg.gravity)
|
|
129
|
+
true_gyro = state.body_rates
|
|
130
|
+
true_mag = rotation.T @ self.cfg.mag_field_ref
|
|
131
|
+
temp_factor = 1.0
|
|
132
|
+
temperature = None
|
|
133
|
+
if self.temp_model is not None:
|
|
134
|
+
temperature = self.temp_model.temperature_at(state.time, duration)
|
|
135
|
+
temp_factor = self.temp_model.noise_scale(temperature)
|
|
136
|
+
true_accel = true_accel + self.temp_model.accel_offset(temperature)
|
|
137
|
+
true_gyro = true_gyro + self.temp_model.gyro_offset(temperature)
|
|
138
|
+
true_mag = true_mag + self.temp_model.mag_offset(temperature)
|
|
139
|
+
return IMUMeasurement(
|
|
140
|
+
acceleration=self.accel_model.apply(true_accel, temp_factor, dt),
|
|
141
|
+
angular_velocity=self.gyro_model.apply(true_gyro, temp_factor, dt),
|
|
142
|
+
magnetic_field=self.mag_model.apply(true_mag, temp_factor, dt),
|
|
143
|
+
temperature=temperature,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def simulate(self, traj: TrajectoryData) -> IMUData:
|
|
147
|
+
"""Generate sensor readings from a trajectory."""
|
|
148
|
+
n = len(traj.t)
|
|
149
|
+
dt = traj.t[1] - traj.t[0] if n > 1 else 0.01
|
|
150
|
+
duration = traj.t[-1] - traj.t[0]
|
|
151
|
+
|
|
152
|
+
accel_out = np.zeros((n, 3))
|
|
153
|
+
gyro_out = np.zeros((n, 3))
|
|
154
|
+
mag_out = np.zeros((n, 3))
|
|
155
|
+
temperature = np.zeros(n) if self.temp_model else None
|
|
156
|
+
|
|
157
|
+
for i in range(n):
|
|
158
|
+
# Rotation matrix from world to body
|
|
159
|
+
q_xyzw = np.roll(traj.orientation_quat[i], -1) # wxyz -> xyzw
|
|
160
|
+
rot = R.from_quat(q_xyzw).as_matrix()
|
|
161
|
+
|
|
162
|
+
# --- Accelerometer: gravity in body + linear accel reaction ---
|
|
163
|
+
gravity_body = rot.T @ self.cfg.gravity
|
|
164
|
+
lin_accel_body = rot.T @ traj.acceleration[i]
|
|
165
|
+
true_accel = gravity_body + lin_accel_body
|
|
166
|
+
|
|
167
|
+
# --- Gyroscope: angular velocity in body frame ---
|
|
168
|
+
true_gyro = traj.angular_velocity[i]
|
|
169
|
+
|
|
170
|
+
# --- Magnetometer: Earth field in body frame ---
|
|
171
|
+
true_mag = rot.T @ self.cfg.mag_field_ref
|
|
172
|
+
|
|
173
|
+
# Apply temperature effects if enabled
|
|
174
|
+
temp_factor = 1.0
|
|
175
|
+
if self.temp_model is not None:
|
|
176
|
+
temp = self.temp_model.temperature_at(traj.t[i], duration)
|
|
177
|
+
temperature[i] = temp
|
|
178
|
+
temp_factor = self.temp_model.noise_scale(temp)
|
|
179
|
+
true_accel = true_accel + self.temp_model.accel_offset(temp)
|
|
180
|
+
true_gyro = true_gyro + self.temp_model.gyro_offset(temp)
|
|
181
|
+
true_mag = true_mag + self.temp_model.mag_offset(temp)
|
|
182
|
+
|
|
183
|
+
accel_out[i] = self.accel_model.apply(true_accel, temp_factor, dt)
|
|
184
|
+
gyro_out[i] = self.gyro_model.apply(true_gyro, temp_factor, dt)
|
|
185
|
+
mag_out[i] = self.mag_model.apply(true_mag, temp_factor, dt)
|
|
186
|
+
|
|
187
|
+
return IMUData(
|
|
188
|
+
t=traj.t,
|
|
189
|
+
accel=accel_out,
|
|
190
|
+
gyro=gyro_out,
|
|
191
|
+
mag=mag_out,
|
|
192
|
+
temperature=temperature,
|
|
193
|
+
accel_bias=self.accel_model.bias.copy(),
|
|
194
|
+
gyro_bias=self.gyro_model.bias.copy(),
|
|
195
|
+
mag_bias=self.mag_model.bias.copy(),
|
|
196
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Sensor noise and temperature models for IMU simulation."""
|
|
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 SensorNoiseModel:
|
|
13
|
+
"""White noise + bias model for a 3-axis sensor.
|
|
14
|
+
|
|
15
|
+
Bias model
|
|
16
|
+
----------
|
|
17
|
+
When ``bias_time_constant`` is finite and ``bias_random_walk_std > 0``,
|
|
18
|
+
the bias evolves each call to ``apply(dt=...)`` as a discrete
|
|
19
|
+
first-order Gauss-Markov process:
|
|
20
|
+
|
|
21
|
+
b_{k+1} = exp(-dt/tau) * b_k + sigma_b * sqrt(1 - exp(-2dt/tau)) * eta
|
|
22
|
+
|
|
23
|
+
where ``tau = bias_time_constant`` and ``sigma_b = bias_random_walk_std``.
|
|
24
|
+
|
|
25
|
+
The default ``bias_time_constant=inf`` reproduces the original constant-bias
|
|
26
|
+
behaviour (backward compatible). Set ``bias_random_walk_std=0`` to disable
|
|
27
|
+
the stochastic drive while keeping the exponential decay.
|
|
28
|
+
"""
|
|
29
|
+
noise_std: float
|
|
30
|
+
bias_range: float
|
|
31
|
+
scale_factor_range: tuple[float, float] = (1.0, 1.0)
|
|
32
|
+
bias_time_constant: float = float('inf') # tau_b [s]; inf = constant bias
|
|
33
|
+
bias_random_walk_std: float = 0.0 # sigma_b [units/s^0.5]
|
|
34
|
+
rng: np.random.Generator | None = field(default=None, repr=False)
|
|
35
|
+
|
|
36
|
+
# Populated on init
|
|
37
|
+
bias: NDArray = field(init=False)
|
|
38
|
+
scale_factor: NDArray = field(init=False)
|
|
39
|
+
|
|
40
|
+
def __post_init__(self):
|
|
41
|
+
random = self.rng if self.rng is not None else np.random
|
|
42
|
+
self.bias = random.uniform(-self.bias_range, self.bias_range, 3)
|
|
43
|
+
lo, hi = self.scale_factor_range
|
|
44
|
+
self.scale_factor = random.uniform(lo, hi, 3)
|
|
45
|
+
|
|
46
|
+
def apply(
|
|
47
|
+
self,
|
|
48
|
+
true_value: NDArray,
|
|
49
|
+
temp_factor: float = 1.0,
|
|
50
|
+
dt: float | None = None,
|
|
51
|
+
) -> NDArray:
|
|
52
|
+
"""Apply scale factor, bias, and additive noise.
|
|
53
|
+
|
|
54
|
+
When ``dt`` is provided and bias dynamics are configured the bias
|
|
55
|
+
state is updated via the Gauss-Markov recursion before measurement
|
|
56
|
+
corruption.
|
|
57
|
+
"""
|
|
58
|
+
# Bias update (Gauss-Markov random walk)
|
|
59
|
+
if (
|
|
60
|
+
dt is not None
|
|
61
|
+
and not np.isinf(self.bias_time_constant)
|
|
62
|
+
and self.bias_time_constant > 0.0
|
|
63
|
+
):
|
|
64
|
+
decay = np.exp(-dt / self.bias_time_constant)
|
|
65
|
+
if self.bias_random_walk_std > 0.0:
|
|
66
|
+
noise_amp = self.bias_random_walk_std * np.sqrt(1.0 - decay ** 2)
|
|
67
|
+
random = self.rng if self.rng is not None else np.random
|
|
68
|
+
drive = random.normal(0.0, noise_amp, 3)
|
|
69
|
+
else:
|
|
70
|
+
drive = np.zeros(3)
|
|
71
|
+
self.bias = decay * self.bias + drive
|
|
72
|
+
|
|
73
|
+
noisy = true_value * self.scale_factor + self.bias
|
|
74
|
+
random = self.rng if self.rng is not None else np.random
|
|
75
|
+
noisy += random.normal(0, self.noise_std * temp_factor, 3)
|
|
76
|
+
return noisy
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class TemperatureModel:
|
|
81
|
+
"""Sinusoidal temperature profile with per-axis sensitivity coefficients."""
|
|
82
|
+
base_temp: float = 25.0
|
|
83
|
+
amplitude: float = 10.0
|
|
84
|
+
rng: np.random.Generator | None = field(default=None, repr=False)
|
|
85
|
+
|
|
86
|
+
# Per-axis temperature coefficients (populated randomly)
|
|
87
|
+
accel_coef: NDArray = field(init=False)
|
|
88
|
+
gyro_coef: NDArray = field(init=False)
|
|
89
|
+
mag_coef: NDArray = field(init=False)
|
|
90
|
+
|
|
91
|
+
def __post_init__(self):
|
|
92
|
+
random = self.rng if self.rng is not None else np.random
|
|
93
|
+
self.accel_coef = random.uniform(-0.002, 0.002, 3)
|
|
94
|
+
self.gyro_coef = random.uniform(-0.0005, 0.0005, 3)
|
|
95
|
+
self.mag_coef = random.uniform(-0.05, 0.05, 3)
|
|
96
|
+
|
|
97
|
+
def temperature_at(self, t: float, duration: float) -> float:
|
|
98
|
+
if duration <= 0.0:
|
|
99
|
+
return self.base_temp
|
|
100
|
+
return self.base_temp + self.amplitude * np.sin(2 * np.pi * t / duration)
|
|
101
|
+
|
|
102
|
+
def noise_scale(self, temp: float) -> float:
|
|
103
|
+
"""Noise increases slightly with temperature deviation."""
|
|
104
|
+
return 1.0 + 0.01 * (temp - self.base_temp) / 10.0
|
|
105
|
+
|
|
106
|
+
def accel_offset(self, temp: float) -> NDArray:
|
|
107
|
+
return self.accel_coef * (temp - self.base_temp)
|
|
108
|
+
|
|
109
|
+
def gyro_offset(self, temp: float) -> NDArray:
|
|
110
|
+
return self.gyro_coef * (temp - self.base_temp)
|
|
111
|
+
|
|
112
|
+
def mag_offset(self, temp: float) -> NDArray:
|
|
113
|
+
return self.mag_coef * (temp - self.base_temp)
|
drones_sim/simulation.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""High-level closed-loop orchestration for reproducible experiments."""
|
|
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
|
+
from .control import GeometricController
|
|
11
|
+
from .dynamics import QuadcopterDynamics
|
|
12
|
+
from .estimation import ExtendedKalmanFilter
|
|
13
|
+
from .math_utils import quat_to_euler
|
|
14
|
+
from .sensors import GPSConfig, GPSSimulator, IMUConfig, IMUSimulator
|
|
15
|
+
from .state import TrajectorySetpoint, VehicleState
|
|
16
|
+
from .trajectory import TrajectoryData
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SimulationConfig:
|
|
21
|
+
"""Timing, sensor, and reproducibility settings."""
|
|
22
|
+
|
|
23
|
+
dt: float = 0.01
|
|
24
|
+
use_estimator: bool = True
|
|
25
|
+
gps_rate: float = 10.0
|
|
26
|
+
seed: int = 0
|
|
27
|
+
# Gravity-as-measurement updates are valid only when translational
|
|
28
|
+
# acceleration is negligible. IMU acceleration is still used for INS
|
|
29
|
+
# propagation; opt in to this correction for quasi-static scenarios.
|
|
30
|
+
correct_accelerometer: bool = False
|
|
31
|
+
correct_magnetometer: bool = True
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if self.dt <= 0.0:
|
|
35
|
+
raise ValueError("dt must be positive")
|
|
36
|
+
if self.gps_rate <= 0.0:
|
|
37
|
+
raise ValueError("gps_rate must be positive")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SimulationResult:
|
|
42
|
+
"""Aligned truth, estimate, reference, actuation, and filter diagnostics."""
|
|
43
|
+
|
|
44
|
+
time: NDArray
|
|
45
|
+
position: NDArray
|
|
46
|
+
velocity: NDArray
|
|
47
|
+
quaternion: NDArray
|
|
48
|
+
body_rates: NDArray
|
|
49
|
+
estimated_position: NDArray
|
|
50
|
+
estimated_velocity: NDArray
|
|
51
|
+
estimated_quaternion: NDArray
|
|
52
|
+
reference_position: NDArray
|
|
53
|
+
reference_velocity: NDArray
|
|
54
|
+
motor_speeds: NDArray
|
|
55
|
+
commanded_wrench: NDArray
|
|
56
|
+
allocation_saturated: NDArray
|
|
57
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def tracking_error(self) -> NDArray:
|
|
61
|
+
return self.position - self.reference_position
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def estimation_error(self) -> NDArray:
|
|
65
|
+
return self.estimated_position - self.position
|
|
66
|
+
|
|
67
|
+
def summary(self) -> dict[str, float]:
|
|
68
|
+
tracking_norm = np.linalg.norm(self.tracking_error, axis=1)
|
|
69
|
+
estimation_norm = np.linalg.norm(self.estimation_error, axis=1)
|
|
70
|
+
return {
|
|
71
|
+
"duration_s": float(self.time[-1] - self.time[0]) if len(self.time) else 0.0,
|
|
72
|
+
"tracking_rmse_m": float(np.sqrt(np.mean(tracking_norm**2))),
|
|
73
|
+
"tracking_max_m": float(np.max(tracking_norm)),
|
|
74
|
+
"estimation_rmse_m": float(np.sqrt(np.mean(estimation_norm**2))),
|
|
75
|
+
"motor_peak_rad_s": float(np.max(self.motor_speeds)),
|
|
76
|
+
"allocation_saturation_fraction": float(
|
|
77
|
+
np.mean(self.allocation_saturated)
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ClosedLoopSimulator:
|
|
83
|
+
"""Own the full reference → control → plant → sensors → estimate loop."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
vehicle: QuadcopterDynamics | None = None,
|
|
88
|
+
controller=None,
|
|
89
|
+
estimator: ExtendedKalmanFilter | None = None,
|
|
90
|
+
imu: IMUSimulator | None = None,
|
|
91
|
+
gps: GPSSimulator | None = None,
|
|
92
|
+
config: SimulationConfig | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.config = config or SimulationConfig()
|
|
95
|
+
self.vehicle = vehicle or QuadcopterDynamics(motor_time_constant=0.04)
|
|
96
|
+
self.controller = controller or GeometricController(self.vehicle)
|
|
97
|
+
self.estimator = estimator
|
|
98
|
+
self.imu = imu or IMUSimulator(
|
|
99
|
+
IMUConfig(
|
|
100
|
+
accel_scale=(1.0, 1.0),
|
|
101
|
+
gyro_scale=(1.0, 1.0),
|
|
102
|
+
mag_scale=(1.0, 1.0),
|
|
103
|
+
),
|
|
104
|
+
seed=self.config.seed,
|
|
105
|
+
)
|
|
106
|
+
self.gps = gps or GPSSimulator(
|
|
107
|
+
GPSConfig(position_noise_std=0.5, update_rate=self.config.gps_rate),
|
|
108
|
+
seed=self.config.seed + 1,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _reset_estimator(self, initial: VehicleState) -> None:
|
|
112
|
+
if not self.config.use_estimator:
|
|
113
|
+
return
|
|
114
|
+
if self.estimator is None:
|
|
115
|
+
initial_filter_state = np.zeros(16)
|
|
116
|
+
initial_filter_state[:3] = initial.position
|
|
117
|
+
initial_filter_state[3:6] = initial.velocity
|
|
118
|
+
initial_filter_state[6:10] = initial.quaternion
|
|
119
|
+
self.estimator = ExtendedKalmanFilter(
|
|
120
|
+
self.config.dt,
|
|
121
|
+
initial_state=initial_filter_state,
|
|
122
|
+
gravity=np.array([0.0, 0.0, self.vehicle.g]),
|
|
123
|
+
mag_ref=self.imu.cfg.mag_field_ref,
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
self.estimator.x.fill(0.0)
|
|
127
|
+
self.estimator.x[:3] = initial.position
|
|
128
|
+
self.estimator.x[3:6] = initial.velocity
|
|
129
|
+
self.estimator.x[6:10] = initial.quaternion
|
|
130
|
+
self.estimator.P = np.eye(self.estimator.n) * 0.01
|
|
131
|
+
self.estimator.P[0:3, 0:3] *= 0.01
|
|
132
|
+
self.estimator.P[3:6, 3:6] *= 0.1
|
|
133
|
+
self.estimator.P[6:10, 6:10] *= 0.001
|
|
134
|
+
self.estimator.P[13:16, 13:16] = np.eye(3) * 0.0001
|
|
135
|
+
self.estimator.diagnostics = type(self.estimator.diagnostics)()
|
|
136
|
+
self.estimator._rejection_streak.clear()
|
|
137
|
+
|
|
138
|
+
def run(
|
|
139
|
+
self,
|
|
140
|
+
reference: TrajectoryData,
|
|
141
|
+
*,
|
|
142
|
+
initial_state: VehicleState | None = None,
|
|
143
|
+
) -> SimulationResult:
|
|
144
|
+
"""Run one deterministic simulation aligned to ``reference.t``."""
|
|
145
|
+
if len(reference.t) < 2:
|
|
146
|
+
raise ValueError("reference must contain at least two samples")
|
|
147
|
+
sample_dt = np.diff(reference.t)
|
|
148
|
+
if not np.allclose(sample_dt, self.config.dt, rtol=2e-2, atol=1e-8):
|
|
149
|
+
raise ValueError(
|
|
150
|
+
"reference sample period must match SimulationConfig.dt; "
|
|
151
|
+
f"got median {np.median(sample_dt):.6f}s vs {self.config.dt:.6f}s"
|
|
152
|
+
)
|
|
153
|
+
if initial_state is None:
|
|
154
|
+
initial_state = VehicleState(
|
|
155
|
+
position=reference.position[0],
|
|
156
|
+
velocity=reference.velocity[0],
|
|
157
|
+
quaternion=reference.orientation_quat[0],
|
|
158
|
+
)
|
|
159
|
+
self.vehicle.reset(state=initial_state)
|
|
160
|
+
self.imu.reset(self.config.seed)
|
|
161
|
+
self.gps.reset(self.config.seed + 1)
|
|
162
|
+
hover_speed = np.sqrt(
|
|
163
|
+
self.vehicle.mass * self.vehicle.g / (4.0 * self.vehicle.k_f)
|
|
164
|
+
)
|
|
165
|
+
self.vehicle.motor_states[:] = hover_speed
|
|
166
|
+
self.controller.reset()
|
|
167
|
+
self._reset_estimator(initial_state)
|
|
168
|
+
|
|
169
|
+
count = len(reference.t)
|
|
170
|
+
shape3 = (count, 3)
|
|
171
|
+
position = np.zeros(shape3)
|
|
172
|
+
velocity = np.zeros(shape3)
|
|
173
|
+
quaternion = np.zeros((count, 4))
|
|
174
|
+
body_rates = np.zeros(shape3)
|
|
175
|
+
estimated_position = np.zeros(shape3)
|
|
176
|
+
estimated_velocity = np.zeros(shape3)
|
|
177
|
+
estimated_quaternion = np.zeros((count, 4))
|
|
178
|
+
motor_speeds = np.zeros((count, 4))
|
|
179
|
+
commanded_wrench = np.zeros((count, 4))
|
|
180
|
+
allocation_saturated = np.zeros(count, dtype=bool)
|
|
181
|
+
gps_period = max(1, int(round(1.0 / (self.config.gps_rate * self.config.dt))))
|
|
182
|
+
|
|
183
|
+
for index in range(count):
|
|
184
|
+
true_state = self.vehicle.get_state()
|
|
185
|
+
measurement = self.imu.step(
|
|
186
|
+
true_state,
|
|
187
|
+
self.vehicle.last_acceleration,
|
|
188
|
+
self.config.dt,
|
|
189
|
+
duration=float(reference.t[-1]),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
control_state = true_state
|
|
193
|
+
if self.config.use_estimator:
|
|
194
|
+
assert self.estimator is not None
|
|
195
|
+
self.estimator.predict(
|
|
196
|
+
measurement.angular_velocity, measurement.acceleration
|
|
197
|
+
)
|
|
198
|
+
if self.config.correct_accelerometer:
|
|
199
|
+
self.estimator.correct_accel(measurement.acceleration)
|
|
200
|
+
if self.config.correct_magnetometer:
|
|
201
|
+
self.estimator.correct_mag(measurement.magnetic_field)
|
|
202
|
+
if index % gps_period == 0:
|
|
203
|
+
gps_position, gps_velocity, valid = self.gps.step(
|
|
204
|
+
true_state.position, true_state.velocity
|
|
205
|
+
)
|
|
206
|
+
if valid:
|
|
207
|
+
gps_covariance = np.diag(
|
|
208
|
+
[self.gps.config.position_noise_std**2] * 3
|
|
209
|
+
+ [self.gps.config.velocity_noise_std**2] * 3
|
|
210
|
+
)
|
|
211
|
+
self.estimator.correct_gps(
|
|
212
|
+
gps_position, gps_velocity, gps_covariance
|
|
213
|
+
)
|
|
214
|
+
corrected_rates = (
|
|
215
|
+
measurement.angular_velocity - self.estimator.x[13:16]
|
|
216
|
+
)
|
|
217
|
+
control_state = self.estimator.get_vehicle_state(
|
|
218
|
+
corrected_rates, time=true_state.time
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
yaw = float(quat_to_euler(reference.orientation_quat[index])[2])
|
|
222
|
+
setpoint = TrajectorySetpoint(
|
|
223
|
+
position=reference.position[index],
|
|
224
|
+
velocity=reference.velocity[index],
|
|
225
|
+
acceleration=reference.acceleration[index],
|
|
226
|
+
yaw=yaw,
|
|
227
|
+
yaw_rate=float(reference.angular_velocity[index, 2]),
|
|
228
|
+
)
|
|
229
|
+
if hasattr(self.controller, "compute_output"):
|
|
230
|
+
output = self.controller.compute_output(
|
|
231
|
+
setpoint, self.config.dt, state=control_state
|
|
232
|
+
)
|
|
233
|
+
command = output.motor_speeds
|
|
234
|
+
commanded_wrench[index] = np.concatenate(
|
|
235
|
+
[[output.thrust], output.torque]
|
|
236
|
+
)
|
|
237
|
+
allocation_saturated[index] = output.saturated
|
|
238
|
+
else:
|
|
239
|
+
previous = reference.position[max(0, index - 1)]
|
|
240
|
+
command = self.controller.compute(
|
|
241
|
+
setpoint.position, setpoint.yaw, self.config.dt, previous
|
|
242
|
+
)
|
|
243
|
+
commanded_wrench[index] = (
|
|
244
|
+
self.vehicle.allocation_matrix() @ np.square(command)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
position[index] = true_state.position
|
|
248
|
+
velocity[index] = true_state.velocity
|
|
249
|
+
quaternion[index] = true_state.quaternion
|
|
250
|
+
body_rates[index] = true_state.body_rates
|
|
251
|
+
estimated_position[index] = control_state.position
|
|
252
|
+
estimated_velocity[index] = control_state.velocity
|
|
253
|
+
estimated_quaternion[index] = control_state.quaternion
|
|
254
|
+
motor_speeds[index] = command
|
|
255
|
+
self.vehicle.update(self.config.dt, command)
|
|
256
|
+
|
|
257
|
+
metadata: dict[str, object] = {
|
|
258
|
+
"dt": self.config.dt,
|
|
259
|
+
"controller": type(self.controller).__name__,
|
|
260
|
+
"estimator": (
|
|
261
|
+
type(self.estimator).__name__ if self.config.use_estimator else "truth"
|
|
262
|
+
),
|
|
263
|
+
"seed": self.config.seed,
|
|
264
|
+
}
|
|
265
|
+
if self.estimator is not None:
|
|
266
|
+
metadata["ekf_accepted"] = dict(self.estimator.diagnostics.accepted)
|
|
267
|
+
metadata["ekf_rejected"] = dict(self.estimator.diagnostics.rejected)
|
|
268
|
+
return SimulationResult(
|
|
269
|
+
time=reference.t.copy(),
|
|
270
|
+
position=position,
|
|
271
|
+
velocity=velocity,
|
|
272
|
+
quaternion=quaternion,
|
|
273
|
+
body_rates=body_rates,
|
|
274
|
+
estimated_position=estimated_position,
|
|
275
|
+
estimated_velocity=estimated_velocity,
|
|
276
|
+
estimated_quaternion=estimated_quaternion,
|
|
277
|
+
reference_position=reference.position.copy(),
|
|
278
|
+
reference_velocity=reference.velocity.copy(),
|
|
279
|
+
motor_speeds=motor_speeds,
|
|
280
|
+
commanded_wrench=commanded_wrench,
|
|
281
|
+
allocation_saturated=allocation_saturated,
|
|
282
|
+
metadata=metadata,
|
|
283
|
+
)
|