Skip to main content

rust_robotics_control/
racing_mppi_powertrain.rs

1//! Reference-free racing MPPI with a motor-lag and battery-sag powertrain.
2//!
3//! This deepens [`crate::racing_mppi_motor`] one more level: between the
4//! commanded rotor thrusts and the rigid-body physics it inserts a *powertrain*
5//! with two unmodeled-by-the-planner effects that every real racing quad has.
6//!
7//! 1. **First-order motor lag.** A rotor cannot change thrust instantly; the
8//!    actual thrust tracks the command through a first-order spin-up lag with
9//!    time constant `motor_tau`. Aggressive attitude flicks are therefore
10//!    *smeared* in time — the torque the body actually feels lags the command.
11//! 2. **Battery sag.** The available per-rotor thrust ceiling is not constant.
12//!    It droops instantaneously with electrical load (internal resistance) and
13//!    permanently as the pack's state of charge falls over the run. A drone
14//!    that flies hard early has less authority late in the race.
15//!
16//! The powertrain is built by *composition*: it reuses
17//! [`MotorQuadParams::step`] verbatim for the translational and rotational
18//! physics and only adds the actuator front-end (lagged rotor thrusts plus a
19//! battery state). With the [`PowertrainParams::ideal`] constructor — zero lag,
20//! no discharge, no sag — it reduces exactly to the motor-level model, which is
21//! the baseline the benchmark compares against.
22//!
23//! - [`PowertrainState`] wraps a [`MotorQuadState`] with the actual (lagged)
24//!   rotor thrusts and the battery state of charge.
25//! - [`PowertrainParams`] holds the base [`MotorQuadParams`] plus the lag,
26//!   discharge, and sag coefficients, and exposes [`PowertrainParams::step`].
27//! - [`simulate_powertrain_race`] drives the *powertrain-unaware*
28//!   [`MotorMppiController`] (which plans assuming ideal actuators) through the
29//!   real powertrain and reports [`PowertrainLapReport`], adding battery and
30//!   ceiling-saturation metrics. The honest result is how much an idealized
31//!   plan costs when it meets a laggy, sagging powertrain.
32//! - [`PowertrainMppiController`] is the *powertrain-aware* answer: it rolls
33//!   candidates out through [`PowertrainParams::step`], so it plans within the
34//!   deliverable authority and conserves charge for later gates.
35//!   [`simulate_powertrain_race_aware`] runs it through the same closed loop.
36//! - [`ChargeBudget`] adds an explicit energy term to the aware controller,
37//!   penalizing below-reserve current draw so it can trade lap progress for
38//!   charge held back; [`simulate_powertrain_race_budgeted`] runs it.
39//! - [`PowertrainParams::with_recovery`] adds a relaxation overpotential so the
40//!   terminal voltage (and thus the thrust ceiling) recovers when the load eases,
41//!   even as the state of charge only ever falls.
42
43use crate::racing_mppi_3d::{RacingGateLap3D, RacingGatePlane3D};
44use crate::racing_mppi_motor::{
45    MotorCommand, MotorMppiConfig, MotorMppiController, MotorMppiPlan, MotorQuadParams,
46    MotorQuadState,
47};
48use rand::{rngs::StdRng, SeedableRng};
49use rand_distr::{Distribution, Normal};
50use rust_robotics_core::{RoboticsError, RoboticsResult};
51
52/// State of the powertrain-level quadrotor.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct PowertrainState {
55    /// Rigid-body state (position, velocity, attitude, body rates).
56    pub motor: MotorQuadState,
57    /// Actual (lagged) per-rotor thrusts \[m/s^2\], mass-normalized.
58    pub rotor_thrust: [f64; 4],
59    /// Battery state of charge in `[0, 1]`.
60    pub battery_soc: f64,
61    /// Battery relaxation overpotential in `[0, 1]`: a recoverable voltage
62    /// depression that builds under sustained load and decays (the terminal
63    /// voltage recovers) when the load eases. Distinct from `battery_soc`, which
64    /// is the irreversible charge consumed.
65    pub relaxation: f64,
66}
67
68impl PowertrainState {
69    /// Level hover state with a full battery and rotors already at hover thrust.
70    pub fn at(x: f64, y: f64, z: f64, gravity: f64) -> Self {
71        Self::at_soc(x, y, z, gravity, 1.0)
72    }
73
74    /// Level hover state at a given starting state of charge.
75    pub fn at_soc(x: f64, y: f64, z: f64, gravity: f64, soc: f64) -> Self {
76        Self {
77            motor: MotorQuadState::at(x, y, z),
78            rotor_thrust: [gravity / 4.0; 4],
79            battery_soc: soc.clamp(0.0, 1.0),
80            relaxation: 0.0,
81        }
82    }
83
84    pub fn speed(self) -> f64 {
85        self.motor.speed()
86    }
87
88    pub fn tilt_angle(self) -> f64 {
89        self.motor.tilt_angle()
90    }
91}
92
93/// Powertrain parameters layered on top of the base motor model.
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct PowertrainParams {
96    /// Base rigid-body and rotor-mixing parameters.
97    pub base: MotorQuadParams,
98    /// First-order motor spin-up time constant \[s\]; `0` means instant.
99    pub motor_tau: f64,
100    /// Battery discharge rate per unit load per second \[1/s\].
101    pub discharge_rate: f64,
102    /// Instantaneous voltage droop fraction at full electrical load, in `[0, 1)`.
103    pub sag_coeff: f64,
104    /// Open-circuit voltage scale at an empty pack, in `(0, 1]`.
105    pub min_voltage_scale: f64,
106    /// Rate \[1/s\] at which the relaxation overpotential builds toward the load.
107    pub relax_build: f64,
108    /// Rate \[1/s\] at which the relaxation overpotential decays (recovers).
109    pub relax_recover: f64,
110    /// Voltage-scale depression per unit relaxation overpotential, in `[0, 1)`.
111    pub relax_coeff: f64,
112}
113
114impl PowertrainParams {
115    /// Build a powertrain with explicit lag/battery coefficients.
116    pub fn new(
117        base: MotorQuadParams,
118        motor_tau: f64,
119        discharge_rate: f64,
120        sag_coeff: f64,
121        min_voltage_scale: f64,
122    ) -> RoboticsResult<Self> {
123        if !motor_tau.is_finite() || motor_tau < 0.0 {
124            return Err(RoboticsError::InvalidParameter(
125                "powertrain motor_tau must be finite and non-negative".to_string(),
126            ));
127        }
128        if !discharge_rate.is_finite() || discharge_rate < 0.0 {
129            return Err(RoboticsError::InvalidParameter(
130                "powertrain discharge_rate must be finite and non-negative".to_string(),
131            ));
132        }
133        if !sag_coeff.is_finite() || !(0.0..1.0).contains(&sag_coeff) {
134            return Err(RoboticsError::InvalidParameter(
135                "powertrain sag_coeff must be in [0, 1)".to_string(),
136            ));
137        }
138        if !min_voltage_scale.is_finite()
139            || !(0.0..=1.0).contains(&min_voltage_scale)
140            || min_voltage_scale <= 0.0
141        {
142            return Err(RoboticsError::InvalidParameter(
143                "powertrain min_voltage_scale must be in (0, 1]".to_string(),
144            ));
145        }
146        Ok(Self {
147            base,
148            motor_tau,
149            discharge_rate,
150            sag_coeff,
151            min_voltage_scale,
152            relax_build: 0.0,
153            relax_recover: 0.0,
154            relax_coeff: 0.0,
155        })
156    }
157
158    /// Add a battery-recovery (relaxation-overpotential) model: the relaxation
159    /// state builds toward the load at `build` \[1/s\] and decays at `recover`
160    /// \[1/s\], depressing the terminal voltage by `coeff` per unit. Easing off
161    /// therefore lets the terminal voltage — and thus the thrust ceiling —
162    /// recover even though the state of charge keeps falling. All arguments are
163    /// sanitized (`build`/`recover >= 0` and finite, `coeff` in `[0, 1)`); the
164    /// defaults of zero leave the pack with no recovery dynamics.
165    pub fn with_recovery(mut self, build: f64, recover: f64, coeff: f64) -> Self {
166        let nonneg = |v: f64| if v.is_finite() && v > 0.0 { v } else { 0.0 };
167        self.relax_build = nonneg(build);
168        self.relax_recover = nonneg(recover);
169        self.relax_coeff = if coeff.is_finite() && (0.0..1.0).contains(&coeff) {
170            coeff
171        } else {
172            0.0
173        };
174        self
175    }
176
177    /// Idealized powertrain: no lag, no discharge, no sag. Reduces exactly to
178    /// the base [`MotorQuadParams`] dynamics — the benchmark baseline.
179    pub fn ideal(base: MotorQuadParams) -> Self {
180        Self {
181            base,
182            motor_tau: 0.0,
183            discharge_rate: 0.0,
184            sag_coeff: 0.0,
185            min_voltage_scale: 1.0,
186            relax_build: 0.0,
187            relax_recover: 0.0,
188            relax_coeff: 0.0,
189        }
190    }
191
192    /// Electrical load in `[0, 1]`: total rotor thrust over the absolute max.
193    pub fn load(self, rotor_thrust: [f64; 4]) -> f64 {
194        let total: f64 = rotor_thrust.iter().sum();
195        (total / (4.0 * self.base.max_rotor_thrust)).clamp(0.0, 1.0)
196    }
197
198    /// Terminal-voltage scale in `[0, 1]` from state of charge and load.
199    ///
200    /// Open-circuit voltage falls linearly with state of charge from `1` (full)
201    /// to `min_voltage_scale` (empty); instantaneous sag subtracts
202    /// `sag_coeff * load` on top of that.
203    pub fn voltage_scale(self, soc: f64, load: f64) -> f64 {
204        let soc = soc.clamp(0.0, 1.0);
205        let ocv = self.min_voltage_scale + (1.0 - self.min_voltage_scale) * soc;
206        (ocv - self.sag_coeff * load).clamp(0.0, 1.0)
207    }
208
209    /// Terminal-voltage scale including the relaxation overpotential: the
210    /// open-circuit-minus-sag [`Self::voltage_scale`] less `relax_coeff *
211    /// relaxation`. This is the voltage actually available at the rotors.
212    pub fn terminal_voltage_scale(self, soc: f64, load: f64, relaxation: f64) -> f64 {
213        (self.voltage_scale(soc, load) - self.relax_coeff * relaxation.clamp(0.0, 1.0))
214            .clamp(0.0, 1.0)
215    }
216
217    /// Battery-limited per-rotor thrust ceiling for the current state, including
218    /// the relaxation overpotential.
219    pub fn effective_max_rotor(self, state: PowertrainState) -> f64 {
220        let load = self.load(state.rotor_thrust);
221        self.base.max_rotor_thrust
222            * self.terminal_voltage_scale(state.battery_soc, load, state.relaxation)
223    }
224
225    /// Per-step lag blend factor `alpha = 1 - exp(-dt / tau)`, or `1` if instant.
226    fn lag_alpha(self, dt: f64) -> f64 {
227        if self.motor_tau > 0.0 {
228            1.0 - (-dt / self.motor_tau).exp()
229        } else {
230            1.0
231        }
232    }
233
234    /// Advance the powertrain one step under the commanded rotor thrusts.
235    pub fn step(self, state: PowertrainState, command: MotorCommand, dt: f64) -> PowertrainState {
236        // Battery-limited ceiling from the current (causal) load, including the
237        // relaxation overpotential.
238        let load = self.load(state.rotor_thrust);
239        let eff_max = self.base.max_rotor_thrust
240            * self.terminal_voltage_scale(state.battery_soc, load, state.relaxation);
241
242        // First-order motor lag toward the ceiling-clamped command.
243        let alpha = self.lag_alpha(dt);
244        let mut rotor_thrust = state.rotor_thrust;
245        for (actual, &commanded) in rotor_thrust.iter_mut().zip(command.rotors.iter()) {
246            let target = commanded.clamp(0.0, eff_max);
247            *actual += (target - *actual) * alpha;
248        }
249
250        // Reuse the base rigid-body + rotor-mixing physics with the *actual*
251        // (lagged) rotor thrusts.
252        let motor = self
253            .base
254            .step(state.motor, MotorCommand::new(rotor_thrust), dt);
255
256        // Discharge from the new actual load; monotone, no regeneration.
257        let new_load = self.load(rotor_thrust);
258        let battery_soc = (state.battery_soc - self.discharge_rate * new_load * dt).clamp(0.0, 1.0);
259
260        // Relaxation overpotential builds toward the load and decays when idle,
261        // so easing off lets the terminal voltage recover.
262        let relax_dot = self.relax_build * new_load - self.relax_recover * state.relaxation;
263        let relaxation = (state.relaxation + relax_dot * dt).clamp(0.0, 1.0);
264
265        PowertrainState {
266            motor,
267            rotor_thrust,
268            battery_soc,
269            relaxation,
270        }
271    }
272}
273
274/// Lap-progress, attitude, and powertrain (lag + battery) metrics.
275#[derive(Debug, Clone, PartialEq)]
276pub struct PowertrainLapReport {
277    pub steps: usize,
278    pub gates_passed: usize,
279    pub laps_completed: usize,
280    pub lap_fraction: f64,
281    pub mean_speed: f64,
282    pub max_speed: f64,
283    pub path_length: f64,
284    pub gate_distance: f64,
285    pub min_aperture_margin: f64,
286    pub max_tilt: f64,
287    pub max_body_rate: f64,
288    /// State of charge at the end of the run.
289    pub final_battery_soc: f64,
290    /// Lowest battery state of charge reached.
291    pub min_battery_soc: f64,
292    /// Mean terminal-voltage scale over the run (1 = no sag).
293    pub mean_voltage_scale: f64,
294    /// Fraction of steps whose commanded rotor exceeded the battery ceiling.
295    pub ceiling_saturation_fraction: f64,
296    pub first_lap_time: Option<f64>,
297    pub path: Vec<[f64; 3]>,
298    /// Battery state of charge after each executed step.
299    pub battery_trace: Vec<f64>,
300}
301
302/// Validate the MPPI config fields shared by both powertrain controllers.
303fn validate_config(config: &MotorMppiConfig) -> RoboticsResult<()> {
304    if config.horizon == 0 || config.samples == 0 {
305        return Err(RoboticsError::InvalidParameter(
306            "powertrain MPPI horizon and samples must be positive".to_string(),
307        ));
308    }
309    if !config.dt.is_finite() || config.dt <= 0.0 {
310        return Err(RoboticsError::InvalidParameter(
311            "powertrain MPPI dt must be finite and positive".to_string(),
312        ));
313    }
314    if !config.rotor_sigma.is_finite() || config.rotor_sigma <= 0.0 {
315        return Err(RoboticsError::InvalidParameter(
316            "powertrain MPPI rotor_sigma must be finite and positive".to_string(),
317        ));
318    }
319    if !config.lambda.is_finite() || config.lambda <= 0.0 {
320        return Err(RoboticsError::InvalidParameter(
321            "powertrain MPPI lambda must be finite and positive".to_string(),
322        ));
323    }
324    if config.velocity_weight < 0.0 || config.rotor_weight < 0.0 || config.level_weight < 0.0 {
325        return Err(RoboticsError::InvalidParameter(
326            "powertrain MPPI weights must be non-negative".to_string(),
327        ));
328    }
329    Ok(())
330}
331
332/// Powertrain-*aware* MPPI controller.
333///
334/// Unlike [`MotorMppiController`], which rolls candidate commands out through
335/// the ideal motor model, this controller rolls them out through the full
336/// [`PowertrainParams::step`] — so its samples already feel the motor lag and
337/// the battery-limited thrust ceiling. Commands above the (causal) ceiling are
338/// clamped inside the rollout, so over-commanding earns no gate progress while
339/// still paying the rotor-effort penalty; the controller is therefore pushed to
340/// plan within the authority the battery can actually deliver, and it sees the
341/// pack drain over the horizon rather than assuming infinite charge.
342/// A charge-budget term for the powertrain-aware controller.
343///
344/// Once a rollout step's state of charge falls below `reserve`, the rollout cost
345/// gains `weight * (reserve - soc) * load`, where `load` is the electrical draw.
346/// Because the penalty scales with `load` it gives the rollout an actionable
347/// gradient — throttle back when low — that a slow-moving state-of-charge term
348/// cannot. `weight = 0` recovers the plain aware controller.
349#[derive(Debug, Clone, Copy, PartialEq)]
350pub struct ChargeBudget {
351    /// Penalty scale on below-reserve current draw; clamped to `>= 0`.
352    pub weight: f64,
353    /// State-of-charge floor to protect, in `[0, 1]`.
354    pub reserve: f64,
355}
356
357impl ChargeBudget {
358    /// A charge budget with the given weight and reserve, sanitized to sane
359    /// ranges (`weight >= 0` and finite, `reserve` in `[0, 1]`).
360    pub fn new(weight: f64, reserve: f64) -> Self {
361        Self {
362            weight: if weight.is_finite() && weight > 0.0 {
363                weight
364            } else {
365                0.0
366            },
367            reserve: reserve.clamp(0.0, 1.0),
368        }
369    }
370
371    /// The no-op budget (`weight = 0`): the plain aware controller.
372    pub fn none() -> Self {
373        Self {
374            weight: 0.0,
375            reserve: 0.0,
376        }
377    }
378}
379
380#[derive(Debug, Clone)]
381pub struct PowertrainMppiController {
382    config: MotorMppiConfig,
383    params: PowertrainParams,
384    nominal: Vec<MotorCommand>,
385    rng: StdRng,
386    budget: ChargeBudget,
387}
388
389impl PowertrainMppiController {
390    pub fn new(config: MotorMppiConfig, params: PowertrainParams) -> RoboticsResult<Self> {
391        validate_config(&config)?;
392        let nominal = vec![MotorCommand::hover(params.base.gravity); config.horizon];
393        let rng = StdRng::seed_from_u64(config.seed);
394        Ok(Self {
395            config,
396            params,
397            nominal,
398            rng,
399            budget: ChargeBudget::none(),
400        })
401    }
402
403    /// Attach a [`ChargeBudget`] so the controller eases off below the reserve
404    /// and trades raw gate progress for charge held back for later laps.
405    pub fn with_charge_budget(mut self, budget: ChargeBudget) -> Self {
406        self.budget = ChargeBudget::new(budget.weight, budget.reserve);
407        self
408    }
409
410    pub fn config(&self) -> &MotorMppiConfig {
411        &self.config
412    }
413
414    fn rollout(&self, start: PowertrainState, controls: &[MotorCommand]) -> (Vec<[f64; 3]>, f64) {
415        let mut state = start;
416        let mut positions = Vec::with_capacity(controls.len() + 1);
417        positions.push(state.motor.position);
418        let mut regularizer = 0.0;
419        let hover = self.params.base.gravity / 4.0;
420        for &command in controls {
421            state = self.params.step(state, command, self.config.dt);
422            let speed = state.speed();
423            let tilt_cost = 1.0 - state.motor.thrust_axis()[2];
424            // The effort penalty is on the *commanded* thrust, so commanding
425            // past the battery ceiling (which the rollout clamps away) costs
426            // effort for no benefit.
427            let rotor_effort: f64 = command.rotors.iter().map(|&r| (r - hover).powi(2)).sum();
428            // Charge-budget term: once below the protected reserve, penalize the
429            // electrical load (current draw) itself, scaled by how deep below the
430            // reserve the pack is. Because load couples directly to thrust each
431            // step, this gives the rollout an actionable gradient — throttle back
432            // when low — that a slow-moving state-of-charge penalty cannot.
433            let below_reserve = (self.budget.reserve - state.battery_soc).max(0.0);
434            let load = self.params.load(state.rotor_thrust);
435            regularizer += self.config.velocity_weight * speed * speed
436                + self.config.rotor_weight * rotor_effort
437                + self.config.level_weight * tilt_cost
438                + self.budget.weight * below_reserve * load;
439            positions.push(state.motor.position);
440        }
441        (positions, regularizer)
442    }
443
444    /// Plan the next rotor command given the full powertrain state, lap, and
445    /// pass count.
446    pub fn plan(
447        &mut self,
448        start: PowertrainState,
449        lap: &RacingGateLap3D,
450        passed: usize,
451    ) -> RoboticsResult<MotorMppiPlan> {
452        let noise = Normal::new(0.0, self.config.rotor_sigma).map_err(|_| {
453            RoboticsError::InvalidParameter(
454                "invalid powertrain MPPI rotor distribution".to_string(),
455            )
456        })?;
457
458        let mut costs = Vec::with_capacity(self.config.samples);
459        let mut sequences = Vec::with_capacity(self.config.samples);
460        let mut best_cost = f64::INFINITY;
461
462        for _ in 0..self.config.samples {
463            let mut controls = Vec::with_capacity(self.config.horizon);
464            for &base in &self.nominal {
465                let mut rotors = base.rotors;
466                for r in &mut rotors {
467                    *r += noise.sample(&mut self.rng);
468                }
469                controls.push(self.params.base.saturate(MotorCommand::new(rotors)));
470            }
471            let (positions, regularizer) = self.rollout(start, &controls);
472            let (gate_cost, _) = lap.score_positions(&positions, passed);
473            let cost = gate_cost + regularizer;
474            best_cost = best_cost.min(cost);
475            costs.push(cost);
476            sequences.push(controls);
477        }
478
479        let min_cost = costs.iter().copied().fold(f64::INFINITY, f64::min);
480        let mut weights = Vec::with_capacity(costs.len());
481        let mut weight_sum = 0.0;
482        for &cost in &costs {
483            let weight = (-(cost - min_cost) / self.config.lambda).exp();
484            weight_sum += weight;
485            weights.push(weight);
486        }
487
488        if weight_sum <= 0.0 || !weight_sum.is_finite() {
489            let best_index = costs
490                .iter()
491                .enumerate()
492                .min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
493                .map(|(index, _)| index)
494                .unwrap_or(0);
495            let command = sequences[best_index][0];
496            return Ok(MotorMppiPlan {
497                command,
498                best_cost,
499                normalized_effective_sample_size: 0.0,
500            });
501        }
502
503        let mut updated = vec![MotorCommand::new([0.0; 4]); self.config.horizon];
504        let mut sum_sq = 0.0;
505        for (weight, controls) in weights.iter().zip(&sequences) {
506            let normalized = weight / weight_sum;
507            sum_sq += normalized * normalized;
508            for (acc, command) in updated.iter_mut().zip(controls) {
509                for k in 0..4 {
510                    acc.rotors[k] += normalized * command.rotors[k];
511                }
512            }
513        }
514        for command in &mut updated {
515            *command = self.params.base.saturate(*command);
516        }
517
518        let first_command = updated[0];
519        self.nominal.clear();
520        self.nominal.extend_from_slice(&updated[1..]);
521        self.nominal.push(*updated.last().unwrap());
522
523        let normalized_effective_sample_size = if sum_sq > 0.0 {
524            1.0 / (sum_sq * self.config.samples as f64)
525        } else {
526            0.0
527        };
528
529        Ok(MotorMppiPlan {
530            command: first_command,
531            best_cost,
532            normalized_effective_sample_size,
533        })
534    }
535}
536
537/// Shared closed-loop driver: step the powertrain under whatever command the
538/// `plan_command` closure returns each step, accumulating the lap report.
539fn run_powertrain_race<F>(
540    dt: f64,
541    params: PowertrainParams,
542    lap: &RacingGateLap3D,
543    start: PowertrainState,
544    max_steps: usize,
545    target_laps: usize,
546    mut plan_command: F,
547) -> RoboticsResult<PowertrainLapReport>
548where
549    F: FnMut(&PowertrainState, usize) -> RoboticsResult<MotorCommand>,
550{
551    let gate_count = lap.gate_count();
552    let mut state = start;
553    let mut passed = 0usize;
554    let mut path = vec![state.motor.position];
555    let mut battery_trace = Vec::new();
556    let mut sum_speed = 0.0;
557    let mut max_speed = 0.0_f64;
558    let mut max_tilt = 0.0_f64;
559    let mut max_body_rate = 0.0_f64;
560    let mut sum_voltage_scale = 0.0;
561    let mut ceiling_saturated = 0usize;
562    let mut path_length = 0.0;
563    let mut min_aperture_margin = f64::INFINITY;
564    let mut min_battery_soc = state.battery_soc;
565    let mut first_lap_time = None;
566    let mut executed_steps = 0usize;
567
568    for step in 0..max_steps {
569        let command = plan_command(&state, passed)?;
570
571        // Diagnose whether this command will hit the battery ceiling.
572        let eff_max = params.effective_max_rotor(state);
573        let load = params.load(state.rotor_thrust);
574        sum_voltage_scale +=
575            params.terminal_voltage_scale(state.battery_soc, load, state.relaxation);
576        if command.rotors.iter().any(|&r| r > eff_max + 1e-9) {
577            ceiling_saturated += 1;
578        }
579
580        // Execute through the real powertrain.
581        let next = params.step(state, command, dt);
582        let from = state.motor.position;
583        let to = next.motor.position;
584
585        loop {
586            let gate = lap.gate_for_pass_count(passed);
587            if !gate.crosses(from, to, lap.crossing_tolerance) {
588                break;
589            }
590            let margin = aperture_crossing_margin(gate, from, to);
591            min_aperture_margin = min_aperture_margin.min(margin);
592            passed += 1;
593            if passed == gate_count && first_lap_time.is_none() {
594                first_lap_time = Some((step + 1) as f64 * dt);
595            }
596            if !lap.closed && passed >= gate_count {
597                break;
598            }
599        }
600
601        path_length += distance(from, to);
602        let speed = next.speed();
603        sum_speed += speed;
604        max_speed = max_speed.max(speed);
605        max_tilt = max_tilt.max(next.tilt_angle());
606        max_body_rate = max_body_rate.max(norm(next.motor.body_rates));
607        min_battery_soc = min_battery_soc.min(next.battery_soc);
608        state = next;
609        path.push(to);
610        battery_trace.push(state.battery_soc);
611        executed_steps += 1;
612
613        if target_laps > 0 && passed >= target_laps * gate_count {
614            break;
615        }
616        if !lap.closed && passed >= gate_count {
617            break;
618        }
619    }
620
621    let laps_completed = passed / gate_count;
622    let lap_fraction = (passed % gate_count) as f64 / gate_count as f64;
623    let active_gate = lap.gate_for_pass_count(passed);
624    let gate_distance = distance(active_gate.center(), state.motor.position);
625    let denom = executed_steps.max(1) as f64;
626
627    Ok(PowertrainLapReport {
628        steps: executed_steps,
629        gates_passed: passed,
630        laps_completed,
631        lap_fraction,
632        mean_speed: sum_speed / denom,
633        max_speed,
634        path_length,
635        gate_distance,
636        min_aperture_margin,
637        max_tilt,
638        max_body_rate,
639        final_battery_soc: state.battery_soc,
640        min_battery_soc,
641        mean_voltage_scale: sum_voltage_scale / denom,
642        ceiling_saturation_fraction: ceiling_saturated as f64 / denom,
643        first_lap_time,
644        path,
645        battery_trace,
646    })
647}
648
649/// Drive the powertrain-*unaware* MPPI controller through the real powertrain.
650///
651/// The controller plans rotor commands with `config` and `params.base`,
652/// assuming ideal actuators; each command is then executed through the lagging,
653/// sagging powertrain. The gap between plan and execution is the point.
654pub fn simulate_powertrain_race(
655    config: MotorMppiConfig,
656    params: PowertrainParams,
657    lap: &RacingGateLap3D,
658    start: PowertrainState,
659    max_steps: usize,
660    target_laps: usize,
661) -> RoboticsResult<PowertrainLapReport> {
662    let dt = config.dt;
663    let mut controller = MotorMppiController::new(config, params.base)?;
664    run_powertrain_race(
665        dt,
666        params,
667        lap,
668        start,
669        max_steps,
670        target_laps,
671        |state, passed| controller.plan(state.motor, lap, passed).map(|p| p.command),
672    )
673}
674
675/// Drive the powertrain-*aware* MPPI controller through the real powertrain.
676///
677/// The controller rolls candidates out through [`PowertrainParams::step`], so it
678/// plans within the lag and battery-limited authority instead of assuming ideal
679/// actuators. On a drained pack this conserves the authority the unaware
680/// controller wastes against the ceiling.
681pub fn simulate_powertrain_race_aware(
682    config: MotorMppiConfig,
683    params: PowertrainParams,
684    lap: &RacingGateLap3D,
685    start: PowertrainState,
686    max_steps: usize,
687    target_laps: usize,
688) -> RoboticsResult<PowertrainLapReport> {
689    let dt = config.dt;
690    let mut controller = PowertrainMppiController::new(config, params)?;
691    run_powertrain_race(
692        dt,
693        params,
694        lap,
695        start,
696        max_steps,
697        target_laps,
698        |state, passed| controller.plan(*state, lap, passed).map(|p| p.command),
699    )
700}
701
702/// Drive a *charge-budgeted* powertrain-aware controller through the powertrain.
703///
704/// Like [`simulate_powertrain_race_aware`] but the controller carries a
705/// [`ChargeBudget`] that penalizes below-reserve current draw. Over a multi-lap
706/// race the budgeted controller eases off as it nears the reserve, trading raw
707/// lap progress for charge held back in the pack. A zero-weight budget recovers
708/// the plain aware controller.
709pub fn simulate_powertrain_race_budgeted(
710    config: MotorMppiConfig,
711    params: PowertrainParams,
712    budget: ChargeBudget,
713    lap: &RacingGateLap3D,
714    start: PowertrainState,
715    max_steps: usize,
716    target_laps: usize,
717) -> RoboticsResult<PowertrainLapReport> {
718    let dt = config.dt;
719    let mut controller = PowertrainMppiController::new(config, params)?.with_charge_budget(budget);
720    run_powertrain_race(
721        dt,
722        params,
723        lap,
724        start,
725        max_steps,
726        target_laps,
727        |state, passed| controller.plan(*state, lap, passed).map(|p| p.command),
728    )
729}
730
731fn aperture_crossing_margin(gate: &RacingGatePlane3D, from: [f64; 3], to: [f64; 3]) -> f64 {
732    let from_signed = gate.signed_distance(from);
733    let to_signed = gate.signed_distance(to);
734    let denom = to_signed - from_signed;
735    let t = if denom.abs() > 1e-9 {
736        (-from_signed / denom).clamp(0.0, 1.0)
737    } else {
738        0.0
739    };
740    let crossing = [
741        from[0] + t * (to[0] - from[0]),
742        from[1] + t * (to[1] - from[1]),
743        from[2] + t * (to[2] - from[2]),
744    ];
745    gate.aperture_margin(crossing)
746}
747
748fn norm(a: [f64; 3]) -> f64 {
749    (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
750}
751
752fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
753    let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
754    norm(d)
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    fn gate(center: [f64; 3], normal: [f64; 3]) -> RacingGatePlane3D {
762        RacingGatePlane3D::new(center, normal, [0.0, 0.0, 1.0], 0.9, 0.9).unwrap()
763    }
764
765    fn straight_lap() -> RacingGateLap3D {
766        let gates = vec![
767            gate([1.5, 0.0, 0.0], [1.0, 0.0, 0.0]),
768            gate([3.0, 0.0, 0.0], [1.0, 0.0, 0.0]),
769            gate([4.5, 0.0, 0.0], [1.0, 0.0, 0.0]),
770        ];
771        RacingGateLap3D::open(gates).unwrap()
772    }
773
774    fn racing_params() -> PowertrainParams {
775        PowertrainParams::new(MotorQuadParams::default(), 0.08, 0.05, 0.18, 0.55).unwrap()
776    }
777
778    #[test]
779    fn ideal_powertrain_matches_base_motor_step() {
780        let base = MotorQuadParams::default();
781        let pt = PowertrainParams::ideal(base);
782        let mut pstate = PowertrainState::at(0.0, 0.0, 0.0, base.gravity);
783        let mut mstate = MotorQuadState::at(0.0, 0.0, 0.0);
784        let hover = base.gravity / 4.0;
785        let command = MotorCommand::new([hover + 1.0, hover + 1.0, hover - 1.0, hover - 1.0]);
786        for _ in 0..20 {
787            pstate = pt.step(pstate, command, 0.05);
788            mstate = base.step(mstate, command, 0.05);
789        }
790        assert_eq!(pstate.motor.position, mstate.position);
791        assert_eq!(pstate.motor.attitude, mstate.attitude);
792        assert_eq!(pstate.motor.body_rates, mstate.body_rates);
793        assert!((pstate.battery_soc - 1.0).abs() < 1e-12);
794    }
795
796    #[test]
797    fn motor_lag_delays_thrust_response() {
798        let pt = racing_params();
799        let base = pt.base;
800        let mut state = PowertrainState::at(0.0, 0.0, 1.0, base.gravity);
801        let hover = base.gravity / 4.0;
802        // Step every rotor up toward the ceiling.
803        let command = MotorCommand::new([hover + 2.0; 4]);
804        let target = (hover + 2.0).min(pt.effective_max_rotor(state));
805        let before = state.rotor_thrust[0];
806        state = pt.step(state, command, 0.05);
807        let after_one = state.rotor_thrust[0];
808        // After one step the actual thrust has moved toward the target but not
809        // reached it (first-order lag).
810        assert!(
811            after_one > before,
812            "lag should ramp up: {after_one} vs {before}"
813        );
814        assert!(
815            after_one < target - 1e-6,
816            "lag should not snap: {after_one} vs {target}"
817        );
818        // Many steps later it has essentially converged to the ceiling target.
819        for _ in 0..200 {
820            state = pt.step(state, command, 0.05);
821        }
822        let converged = pt.effective_max_rotor(state);
823        assert!((state.rotor_thrust[0] - converged).abs() < 0.2);
824    }
825
826    #[test]
827    fn battery_depletes_under_load() {
828        let pt = racing_params();
829        let base = pt.base;
830        let mut state = PowertrainState::at(0.0, 0.0, 1.0, base.gravity);
831        let start_soc = state.battery_soc;
832        for _ in 0..200 {
833            state = pt.step(state, MotorCommand::hover(base.gravity), 0.05);
834        }
835        assert!(state.battery_soc < start_soc, "soc {} ", state.battery_soc);
836        assert!(state.battery_soc >= 0.0);
837    }
838
839    #[test]
840    fn sag_lowers_ceiling_as_battery_drains() {
841        let pt = racing_params();
842        let base = pt.base;
843        let full = PowertrainState::at_soc(0.0, 0.0, 0.0, base.gravity, 1.0);
844        let empty = PowertrainState::at_soc(0.0, 0.0, 0.0, base.gravity, 0.0);
845        assert!(
846            pt.effective_max_rotor(full) > pt.effective_max_rotor(empty),
847            "full {} empty {}",
848            pt.effective_max_rotor(full),
849            pt.effective_max_rotor(empty)
850        );
851        // Even empty, the drone can still hover (ceiling*4 >= gravity).
852        assert!(4.0 * pt.effective_max_rotor(empty) >= base.gravity);
853    }
854
855    #[test]
856    fn instantaneous_sag_drops_with_load() {
857        let pt = racing_params();
858        let high = pt.voltage_scale(1.0, 1.0);
859        let low = pt.voltage_scale(1.0, 0.2);
860        assert!(low > high, "more load should sag more: {low} vs {high}");
861    }
862
863    fn recovery_params() -> PowertrainParams {
864        racing_params().with_recovery(0.8, 0.5, 0.25)
865    }
866
867    #[test]
868    fn recovery_is_off_by_default() {
869        // Without with_recovery, the relaxation state never leaves zero.
870        let pt = racing_params();
871        let mut state = PowertrainState::at(0.0, 0.0, 1.0, pt.base.gravity);
872        for _ in 0..40 {
873            state = pt.step(
874                state,
875                MotorCommand::new([pt.base.max_rotor_thrust; 4]),
876                0.05,
877            );
878        }
879        assert_eq!(state.relaxation, 0.0);
880    }
881
882    #[test]
883    fn relaxation_depresses_terminal_voltage() {
884        let pt = recovery_params();
885        // Same charge and load, but a relaxed pack delivers less terminal voltage.
886        let fresh = pt.terminal_voltage_scale(0.8, 0.5, 0.0);
887        let relaxed = pt.terminal_voltage_scale(0.8, 0.5, 0.6);
888        assert!(
889            relaxed < fresh,
890            "relaxed {relaxed} should be below fresh {fresh}"
891        );
892        // With recovery off, relaxation has no effect.
893        let no_recovery = racing_params();
894        assert_eq!(
895            no_recovery.terminal_voltage_scale(0.8, 0.5, 0.6),
896            no_recovery.voltage_scale(0.8, 0.5)
897        );
898    }
899
900    #[test]
901    fn terminal_voltage_recovers_when_load_eases() {
902        let pt = recovery_params();
903        let mut state = PowertrainState::at(0.0, 0.0, 1.0, pt.base.gravity);
904        // Drive hard: the relaxation overpotential builds up.
905        for _ in 0..40 {
906            state = pt.step(
907                state,
908                MotorCommand::new([pt.base.max_rotor_thrust; 4]),
909                0.05,
910            );
911        }
912        let relax_hot = state.relaxation;
913        let soc_hot = state.battery_soc;
914        assert!(relax_hot > 0.1, "relaxation should build: {relax_hot}");
915        // Ease off to hover: the overpotential decays and voltage recovers.
916        for _ in 0..80 {
917            state = pt.step(state, MotorCommand::hover(pt.base.gravity), 0.05);
918        }
919        assert!(
920            state.relaxation < relax_hot,
921            "relaxation should recover: {} vs {relax_hot}",
922            state.relaxation
923        );
924        // ...but the state of charge keeps falling — recovery is not free energy.
925        assert!(
926            state.battery_soc < soc_hot,
927            "soc should keep dropping: {} vs {soc_hot}",
928            state.battery_soc
929        );
930    }
931
932    #[test]
933    fn rejects_invalid_params() {
934        let base = MotorQuadParams::default();
935        assert!(PowertrainParams::new(base, -1.0, 0.05, 0.18, 0.55).is_err());
936        assert!(PowertrainParams::new(base, 0.08, -0.1, 0.18, 0.55).is_err());
937        assert!(PowertrainParams::new(base, 0.08, 0.05, 1.0, 0.55).is_err());
938        assert!(PowertrainParams::new(base, 0.08, 0.05, 0.18, 0.0).is_err());
939    }
940
941    #[test]
942    fn controller_makes_gate_progress_through_powertrain() {
943        let lap = straight_lap();
944        let pt = racing_params();
945        let report = simulate_powertrain_race(
946            MotorMppiConfig::default(),
947            pt,
948            &lap,
949            PowertrainState::at(0.0, 0.0, 0.0, pt.base.gravity),
950            160,
951            1,
952        )
953        .unwrap();
954        assert!(
955            report.gates_passed >= 1,
956            "expected gate progress, got {}",
957            report.gates_passed
958        );
959        assert!(report.final_battery_soc < 1.0);
960        assert!((0.0..=1.0).contains(&report.ceiling_saturation_fraction));
961        assert_eq!(report.battery_trace.len(), report.steps);
962    }
963
964    #[test]
965    fn simulation_is_deterministic() {
966        let lap = straight_lap();
967        let pt = racing_params();
968        let run = || {
969            simulate_powertrain_race(
970                MotorMppiConfig::default(),
971                pt,
972                &lap,
973                PowertrainState::at(0.0, 0.0, 0.0, pt.base.gravity),
974                120,
975                1,
976            )
977            .unwrap()
978        };
979        let first = run();
980        let second = run();
981        assert_eq!(first.path, second.path);
982        assert_eq!(first.battery_trace, second.battery_trace);
983        assert_eq!(first.gates_passed, second.gates_passed);
984    }
985
986    #[test]
987    fn aware_on_ideal_matches_motor_controller() {
988        // On an ideal powertrain the aware rollout equals the base motor model,
989        // so with the same seed the aware controller must plan the same command.
990        let base = MotorQuadParams::default();
991        let lap = straight_lap();
992        let config = MotorMppiConfig::default();
993        let mut motor = MotorMppiController::new(config.clone(), base).unwrap();
994        let mut aware =
995            PowertrainMppiController::new(config, PowertrainParams::ideal(base)).unwrap();
996        let motor_plan = motor
997            .plan(MotorQuadState::at(0.0, 0.0, 0.0), &lap, 0)
998            .unwrap();
999        let aware_plan = aware
1000            .plan(PowertrainState::at(0.0, 0.0, 0.0, base.gravity), &lap, 0)
1001            .unwrap();
1002        // Equal up to the float rounding of the (no-op) lag blend `h + (t - h)`.
1003        for (m, a) in motor_plan
1004            .command
1005            .rotors
1006            .iter()
1007            .zip(aware_plan.command.rotors.iter())
1008        {
1009            assert!((m - a).abs() < 1e-9, "{m} vs {a}");
1010        }
1011    }
1012
1013    #[test]
1014    fn aware_controller_makes_gate_progress() {
1015        let lap = straight_lap();
1016        let pt = racing_params();
1017        let report = simulate_powertrain_race_aware(
1018            MotorMppiConfig::default(),
1019            pt,
1020            &lap,
1021            PowertrainState::at(0.0, 0.0, 0.0, pt.base.gravity),
1022            160,
1023            1,
1024        )
1025        .unwrap();
1026        assert!(
1027            report.gates_passed >= 1,
1028            "expected gate progress, got {}",
1029            report.gates_passed
1030        );
1031        assert_eq!(report.battery_trace.len(), report.steps);
1032    }
1033
1034    #[test]
1035    fn budget_zero_weight_matches_aware() {
1036        // A zero-weight charge budget must recover the plain aware controller.
1037        let lap = straight_lap();
1038        let pt = racing_params();
1039        let start = PowertrainState::at_soc(0.0, 0.0, 0.0, pt.base.gravity, 0.6);
1040        let aware =
1041            simulate_powertrain_race_aware(MotorMppiConfig::default(), pt, &lap, start, 120, 1)
1042                .unwrap();
1043        let budgeted = simulate_powertrain_race_budgeted(
1044            MotorMppiConfig::default(),
1045            pt,
1046            ChargeBudget::new(0.0, 0.4),
1047            &lap,
1048            start,
1049            120,
1050            1,
1051        )
1052        .unwrap();
1053        assert_eq!(aware.path, budgeted.path);
1054        assert_eq!(aware.battery_trace, budgeted.battery_trace);
1055    }
1056
1057    #[test]
1058    fn budget_protects_the_reserve() {
1059        // On a draining closed lap, a charge budget ends with more charge than
1060        // the greedy aware controller flying the same course.
1061        let s = 2.0;
1062        let gates = vec![
1063            gate([s, 0.0, 0.0], [0.0, 1.0, 0.0]),
1064            gate([0.0, s, 0.0], [-1.0, 0.0, 0.0]),
1065            gate([-s, 0.0, 0.0], [0.0, -1.0, 0.0]),
1066            gate([0.0, -s, 0.0], [1.0, 0.0, 0.0]),
1067        ];
1068        let lap = RacingGateLap3D::closed_loop(gates).unwrap();
1069        let pt = PowertrainParams::new(MotorQuadParams::default(), 0.08, 0.12, 0.18, 0.55).unwrap();
1070        let start = PowertrainState::at_soc(s, -1.5, 0.0, pt.base.gravity, 0.7);
1071        let greedy = simulate_powertrain_race_budgeted(
1072            MotorMppiConfig::default(),
1073            pt,
1074            ChargeBudget::none(),
1075            &lap,
1076            start,
1077            220,
1078            6,
1079        )
1080        .unwrap();
1081        let budgeted = simulate_powertrain_race_budgeted(
1082            MotorMppiConfig::default(),
1083            pt,
1084            ChargeBudget::new(40.0, 0.4),
1085            &lap,
1086            start,
1087            220,
1088            6,
1089        )
1090        .unwrap();
1091        assert!(
1092            budgeted.final_battery_soc > greedy.final_battery_soc,
1093            "budgeted soc {} should exceed greedy {}",
1094            budgeted.final_battery_soc,
1095            greedy.final_battery_soc
1096        );
1097    }
1098
1099    #[test]
1100    fn aware_beats_unaware_on_drained_pack() {
1101        // A reach-up climb under a drained pack: the unaware controller wastes
1102        // authority against the ceiling, the aware one budgets within it.
1103        let gates = vec![
1104            gate([1.4, 0.0, 0.5], [1.0, 0.0, 0.4]),
1105            gate([2.8, 0.0, 1.1], [1.0, 0.0, 0.4]),
1106        ];
1107        let lap = RacingGateLap3D::open(gates).unwrap();
1108        let pt = racing_params();
1109        let start = PowertrainState::at_soc(0.0, 0.0, 0.0, pt.base.gravity, 0.22);
1110        let unaware =
1111            simulate_powertrain_race(MotorMppiConfig::default(), pt, &lap, start, 200, 1).unwrap();
1112        let aware =
1113            simulate_powertrain_race_aware(MotorMppiConfig::default(), pt, &lap, start, 200, 1)
1114                .unwrap();
1115        assert!(
1116            aware.gates_passed >= unaware.gates_passed,
1117            "aware {} should reach >= unaware {}",
1118            aware.gates_passed,
1119            unaware.gates_passed
1120        );
1121    }
1122}