1use 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#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct PowertrainState {
55 pub motor: MotorQuadState,
57 pub rotor_thrust: [f64; 4],
59 pub battery_soc: f64,
61 pub relaxation: f64,
66}
67
68impl PowertrainState {
69 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 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#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct PowertrainParams {
96 pub base: MotorQuadParams,
98 pub motor_tau: f64,
100 pub discharge_rate: f64,
102 pub sag_coeff: f64,
104 pub min_voltage_scale: f64,
106 pub relax_build: f64,
108 pub relax_recover: f64,
110 pub relax_coeff: f64,
112}
113
114impl PowertrainParams {
115 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 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 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 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 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 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 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 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 pub fn step(self, state: PowertrainState, command: MotorCommand, dt: f64) -> PowertrainState {
236 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 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 let motor = self
253 .base
254 .step(state.motor, MotorCommand::new(rotor_thrust), dt);
255
256 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 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#[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 pub final_battery_soc: f64,
290 pub min_battery_soc: f64,
292 pub mean_voltage_scale: f64,
294 pub ceiling_saturation_fraction: f64,
296 pub first_lap_time: Option<f64>,
297 pub path: Vec<[f64; 3]>,
298 pub battery_trace: Vec<f64>,
300}
301
302fn 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#[derive(Debug, Clone, Copy, PartialEq)]
350pub struct ChargeBudget {
351 pub weight: f64,
353 pub reserve: f64,
355}
356
357impl ChargeBudget {
358 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 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 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 let rotor_effort: f64 = command.rotors.iter().map(|&r| (r - hover).powi(2)).sum();
428 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 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
537fn 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 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 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
649pub 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
675pub 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
702pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}