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.
Files changed (45) hide show
  1. drones_sim/__init__.py +15 -0
  2. drones_sim/control/__init__.py +15 -0
  3. drones_sim/control/allocation.py +76 -0
  4. drones_sim/control/cascaded.py +204 -0
  5. drones_sim/control/geometric.py +170 -0
  6. drones_sim/control/lqr.py +174 -0
  7. drones_sim/control/pid.py +50 -0
  8. drones_sim/dynamics/__init__.py +13 -0
  9. drones_sim/dynamics/config.py +117 -0
  10. drones_sim/dynamics/disturbances.py +262 -0
  11. drones_sim/dynamics/quadcopter.py +317 -0
  12. drones_sim/estimation/__init__.py +2 -0
  13. drones_sim/estimation/ahrs.py +79 -0
  14. drones_sim/estimation/ekf.py +594 -0
  15. drones_sim/logging/__init__.py +13 -0
  16. drones_sim/logging/csv_logger.py +76 -0
  17. drones_sim/logging/json_logger.py +53 -0
  18. drones_sim/math_utils.py +119 -0
  19. drones_sim/models/__init__.py +21 -0
  20. drones_sim/models/quadcopter.urdf +296 -0
  21. drones_sim/models/urdf_loader.py +444 -0
  22. drones_sim/rl/__init__.py +16 -0
  23. drones_sim/rl/actions.py +219 -0
  24. drones_sim/rl/env.py +195 -0
  25. drones_sim/rl/observations.py +40 -0
  26. drones_sim/rl/reward.py +69 -0
  27. drones_sim/rl/tasks.py +73 -0
  28. drones_sim/sensors/__init__.py +3 -0
  29. drones_sim/sensors/gps.py +158 -0
  30. drones_sim/sensors/imu.py +196 -0
  31. drones_sim/sensors/models.py +113 -0
  32. drones_sim/simulation.py +283 -0
  33. drones_sim/state.py +133 -0
  34. drones_sim/trajectory.py +391 -0
  35. drones_sim/visualization/__init__.py +23 -0
  36. drones_sim/visualization/api.py +50 -0
  37. drones_sim/visualization/dashboard.py +74 -0
  38. drones_sim/visualization/plots.py +183 -0
  39. drones_sim/visualization/rerun_viewer.py +411 -0
  40. drones_sim/visualization/viewer.py +452 -0
  41. drones_sim-0.2.0.dist-info/METADATA +323 -0
  42. drones_sim-0.2.0.dist-info/RECORD +45 -0
  43. drones_sim-0.2.0.dist-info/WHEEL +5 -0
  44. drones_sim-0.2.0.dist-info/licenses/LICENSE +21 -0
  45. 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)
@@ -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
+ )