1use rand::{rngs::StdRng, SeedableRng};
19use rand_distr::{Distribution, Normal};
20use rust_robotics_core::{RoboticsError, RoboticsResult};
21
22#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct RacingDroneState3D {
25 pub x: f64,
26 pub y: f64,
27 pub z: f64,
28 pub vx: f64,
29 pub vy: f64,
30 pub vz: f64,
31}
32
33impl RacingDroneState3D {
34 pub fn new(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> Self {
35 Self {
36 x,
37 y,
38 z,
39 vx,
40 vy,
41 vz,
42 }
43 }
44
45 pub fn at(x: f64, y: f64, z: f64) -> Self {
47 Self::new(x, y, z, 0.0, 0.0, 0.0)
48 }
49
50 pub fn position(self) -> [f64; 3] {
51 [self.x, self.y, self.z]
52 }
53
54 pub fn velocity(self) -> [f64; 3] {
55 [self.vx, self.vy, self.vz]
56 }
57
58 pub fn speed(self) -> f64 {
59 (self.vx * self.vx + self.vy * self.vy + self.vz * self.vz).sqrt()
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct RacingDroneControl3D {
66 pub ax: f64,
67 pub ay: f64,
68 pub az: f64,
69}
70
71impl RacingDroneControl3D {
72 pub fn new(ax: f64, ay: f64, az: f64) -> Self {
73 Self { ax, ay, az }
74 }
75
76 pub fn zero() -> Self {
77 Self::new(0.0, 0.0, 0.0)
78 }
79
80 pub fn magnitude(self) -> f64 {
81 (self.ax * self.ax + self.ay * self.ay + self.az * self.az).sqrt()
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq)]
94pub struct RacingDroneDynamics3D {
95 pub drag: f64,
97 pub gravity: f64,
99 pub max_speed: f64,
101 pub accel_limit: f64,
103}
104
105impl Default for RacingDroneDynamics3D {
106 fn default() -> Self {
107 Self {
108 drag: 0.4,
109 gravity: 0.0,
110 max_speed: 5.5,
111 accel_limit: 9.0,
112 }
113 }
114}
115
116impl RacingDroneDynamics3D {
117 pub fn new(drag: f64, gravity: f64, max_speed: f64, accel_limit: f64) -> RoboticsResult<Self> {
118 if !drag.is_finite() || drag < 0.0 {
119 return Err(RoboticsError::InvalidParameter(
120 "drone drag must be finite and non-negative".to_string(),
121 ));
122 }
123 if !gravity.is_finite() || gravity < 0.0 {
124 return Err(RoboticsError::InvalidParameter(
125 "drone gravity must be finite and non-negative".to_string(),
126 ));
127 }
128 if !max_speed.is_finite() || max_speed <= 0.0 {
129 return Err(RoboticsError::InvalidParameter(
130 "drone max_speed must be finite and positive".to_string(),
131 ));
132 }
133 if !accel_limit.is_finite() || accel_limit <= 0.0 {
134 return Err(RoboticsError::InvalidParameter(
135 "drone accel_limit must be finite and positive".to_string(),
136 ));
137 }
138 Ok(Self {
139 drag,
140 gravity,
141 max_speed,
142 accel_limit,
143 })
144 }
145
146 pub fn saturate(self, control: RacingDroneControl3D) -> RacingDroneControl3D {
148 let magnitude = control.magnitude();
149 if magnitude <= self.accel_limit || magnitude == 0.0 {
150 return control;
151 }
152 let scale = self.accel_limit / magnitude;
153 RacingDroneControl3D::new(control.ax * scale, control.ay * scale, control.az * scale)
154 }
155
156 pub fn step(
158 self,
159 state: RacingDroneState3D,
160 control: RacingDroneControl3D,
161 dt: f64,
162 ) -> RacingDroneState3D {
163 let control = self.saturate(control);
164 let mut vx = state.vx + control.ax * dt;
165 let mut vy = state.vy + control.ay * dt;
166 let mut vz = state.vz + (control.az - self.gravity) * dt;
167
168 let decay = (1.0 - self.drag * dt).clamp(0.0, 1.0);
169 vx *= decay;
170 vy *= decay;
171 vz *= decay;
172
173 let speed = (vx * vx + vy * vy + vz * vz).sqrt();
174 if speed > self.max_speed && speed > 0.0 {
175 let scale = self.max_speed / speed;
176 vx *= scale;
177 vy *= scale;
178 vz *= scale;
179 }
180
181 RacingDroneState3D {
182 x: state.x + vx * dt,
183 y: state.y + vy * dt,
184 z: state.z + vz * dt,
185 vx,
186 vy,
187 vz,
188 }
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq)]
199pub struct RacingGatePlane3D {
200 center: [f64; 3],
201 normal: [f64; 3],
202 up: [f64; 3],
203 right: [f64; 3],
204 half_width: f64,
205 half_height: f64,
206}
207
208impl RacingGatePlane3D {
209 pub fn new(
214 center: [f64; 3],
215 normal: [f64; 3],
216 up_hint: [f64; 3],
217 half_width: f64,
218 half_height: f64,
219 ) -> RoboticsResult<Self> {
220 if !center.iter().all(|v| v.is_finite()) {
221 return Err(RoboticsError::InvalidParameter(
222 "racing gate center must be finite".to_string(),
223 ));
224 }
225 let normal = normalize(normal).ok_or_else(|| {
226 RoboticsError::InvalidParameter(
227 "racing gate normal must be finite and non-zero".to_string(),
228 )
229 })?;
230 let up_projected = sub(up_hint, scale(normal, dot(up_hint, normal)));
232 let up = normalize(up_projected).ok_or_else(|| {
233 RoboticsError::InvalidParameter(
234 "racing gate up hint must not be parallel to the normal".to_string(),
235 )
236 })?;
237 let right = normalize(cross(normal, up)).ok_or_else(|| {
238 RoboticsError::InvalidParameter(
239 "racing gate axes must be linearly independent".to_string(),
240 )
241 })?;
242 if !half_width.is_finite() || half_width <= 0.0 {
243 return Err(RoboticsError::InvalidParameter(
244 "racing gate half_width must be finite and positive".to_string(),
245 ));
246 }
247 if !half_height.is_finite() || half_height <= 0.0 {
248 return Err(RoboticsError::InvalidParameter(
249 "racing gate half_height must be finite and positive".to_string(),
250 ));
251 }
252 Ok(Self {
253 center,
254 normal,
255 up,
256 right,
257 half_width,
258 half_height,
259 })
260 }
261
262 pub fn center(&self) -> [f64; 3] {
263 self.center
264 }
265
266 pub fn normal(&self) -> [f64; 3] {
267 self.normal
268 }
269
270 pub fn half_width(&self) -> f64 {
271 self.half_width
272 }
273
274 pub fn half_height(&self) -> f64 {
275 self.half_height
276 }
277
278 pub fn signed_distance(&self, point: [f64; 3]) -> f64 {
280 dot(sub(point, self.center), self.normal)
281 }
282
283 pub fn plane_offsets(&self, point: [f64; 3]) -> (f64, f64) {
285 let delta = sub(point, self.center);
286 (dot(delta, self.right), dot(delta, self.up))
287 }
288
289 pub fn squared_distance(&self, point: [f64; 3]) -> f64 {
290 let delta = sub(point, self.center);
291 dot(delta, delta)
292 }
293
294 pub fn within_aperture(&self, point: [f64; 3], tolerance: f64) -> bool {
296 let (right_off, up_off) = self.plane_offsets(point);
297 right_off.abs() <= self.half_width + tolerance
298 && up_off.abs() <= self.half_height + tolerance
299 }
300
301 pub fn crosses(&self, from: [f64; 3], to: [f64; 3], tolerance: f64) -> bool {
307 let from_signed = self.signed_distance(from);
308 let to_signed = self.signed_distance(to);
309 if from_signed > tolerance || to_signed < -tolerance {
310 return false;
311 }
312 let denom = to_signed - from_signed;
313 let t = if denom.abs() > 1e-9 {
314 (-from_signed / denom).clamp(0.0, 1.0)
315 } else {
316 0.0
317 };
318 let crossing = [
319 from[0] + t * (to[0] - from[0]),
320 from[1] + t * (to[1] - from[1]),
321 from[2] + t * (to[2] - from[2]),
322 ];
323 self.within_aperture(crossing, tolerance)
324 }
325
326 pub fn aperture_margin(&self, point: [f64; 3]) -> f64 {
329 let (right_off, up_off) = self.plane_offsets(point);
330 let right_margin = 1.0 - right_off.abs() / self.half_width;
331 let up_margin = 1.0 - up_off.abs() / self.half_height;
332 right_margin.min(up_margin)
333 }
334}
335
336#[derive(Debug, Clone, PartialEq)]
341pub struct RacingGateLap3D {
342 pub gates: Vec<RacingGatePlane3D>,
343 pub closed: bool,
345 pub progress_weight: f64,
346 pub lateral_weight: f64,
347 pub pass_bonus: f64,
348 pub miss_penalty: f64,
349 pub terminal_gate_weight: f64,
350 pub crossing_tolerance: f64,
351}
352
353#[derive(Debug, Clone, Copy, PartialEq)]
355struct GateTransition {
356 cost: f64,
357 advanced: bool,
358}
359
360impl RacingGateLap3D {
361 fn with_defaults(gates: Vec<RacingGatePlane3D>, closed: bool) -> RoboticsResult<Self> {
362 if gates.is_empty() {
363 return Err(RoboticsError::InvalidParameter(
364 "racing lap must contain at least one gate".to_string(),
365 ));
366 }
367 Ok(Self {
368 gates,
369 closed,
370 progress_weight: 6.0,
371 lateral_weight: 0.4,
372 pass_bonus: 9.0,
373 miss_penalty: 36.0,
374 terminal_gate_weight: 1.4,
375 crossing_tolerance: 0.04,
376 })
377 }
378
379 pub fn open(gates: Vec<RacingGatePlane3D>) -> RoboticsResult<Self> {
381 Self::with_defaults(gates, false)
382 }
383
384 pub fn closed_loop(gates: Vec<RacingGatePlane3D>) -> RoboticsResult<Self> {
386 Self::with_defaults(gates, true)
387 }
388
389 pub fn gate_count(&self) -> usize {
390 self.gates.len()
391 }
392
393 pub fn gate_for_pass_count(&self, passed: usize) -> &RacingGatePlane3D {
398 let index = if self.closed {
399 passed % self.gates.len()
400 } else {
401 passed.min(self.gates.len() - 1)
402 };
403 &self.gates[index]
404 }
405
406 pub fn centerline_length(&self) -> f64 {
409 let mut length = 0.0;
410 for pair in self.gates.windows(2) {
411 length += distance(pair[0].center, pair[1].center);
412 }
413 if self.closed && self.gates.len() > 1 {
414 length += distance(
415 self.gates[self.gates.len() - 1].center,
416 self.gates[0].center,
417 );
418 }
419 length
420 }
421
422 fn transition_cost(&self, from: [f64; 3], to: [f64; 3], passed: usize) -> GateTransition {
423 let gate = self.gate_for_pass_count(passed);
424 let before_sq = gate.squared_distance(from);
425 let after_sq = gate.squared_distance(to);
426 let (right_off, up_off) = gate.plane_offsets(to);
427 let after_distance = after_sq.sqrt();
428 let lateral_proximity = 1.0 / (1.0 + after_distance);
429 let lateral = right_off * right_off + up_off * up_off;
430 let mut cost = self.progress_weight * (after_sq - before_sq)
431 + self.lateral_weight * lateral_proximity * lateral;
432 let mut advanced = false;
433
434 if gate.crosses(from, to, self.crossing_tolerance) {
435 cost -= self.pass_bonus;
436 advanced = true;
437 } else if gate.signed_distance(to) >= 0.0 && !gate.within_aperture(to, 0.0) {
438 let right_miss = (right_off.abs() - gate.half_width).max(0.0);
439 let up_miss = (up_off.abs() - gate.half_height).max(0.0);
440 cost += self.miss_penalty * (right_miss * right_miss + up_miss * up_miss);
441 }
442
443 GateTransition { cost, advanced }
444 }
445
446 fn terminal_cost(&self, point: [f64; 3], passed: usize) -> f64 {
447 let gate = self.gate_for_pass_count(passed);
448 let remaining = if self.closed {
449 (self.gates.len() - passed % self.gates.len()) as f64
451 } else {
452 (self.gates.len() - passed.min(self.gates.len())) as f64
453 };
454 self.terminal_gate_weight * gate.squared_distance(point) + self.pass_bonus * remaining
455 }
456
457 pub fn score_positions(&self, positions: &[[f64; 3]], start_passed: usize) -> (f64, usize) {
460 if positions.len() < 2 {
461 return (0.0, 0);
462 }
463 let mut passed = start_passed;
464 let mut passed_here = 0;
465 let mut cost = 0.0;
466 for pair in positions.windows(2) {
467 let transition = self.transition_cost(pair[0], pair[1], passed);
468 cost += transition.cost;
469 if transition.advanced {
470 passed += 1;
471 passed_here += 1;
472 }
473 }
474 cost += self.terminal_cost(positions[positions.len() - 1], passed);
475 (cost, passed_here)
476 }
477}
478
479#[derive(Debug, Clone, PartialEq)]
481pub struct RacingLapReport3D {
482 pub steps: usize,
484 pub gates_passed: usize,
486 pub laps_completed: usize,
488 pub lap_fraction: f64,
490 pub mean_speed: f64,
492 pub max_speed: f64,
494 pub path_length: f64,
496 pub gate_distance: f64,
498 pub min_aperture_margin: f64,
501 pub mean_control_effort: f64,
503 pub first_lap_time: Option<f64>,
505 pub path: Vec<[f64; 3]>,
507}
508
509#[derive(Debug, Clone, PartialEq)]
511pub struct RacingMppi3DConfig {
512 pub horizon: usize,
513 pub samples: usize,
514 pub dt: f64,
515 pub noise_sigma: f64,
516 pub velocity_weight: f64,
517 pub control_weight: f64,
518 pub lambda: f64,
519 pub seed: u64,
520}
521
522impl Default for RacingMppi3DConfig {
523 fn default() -> Self {
524 Self {
525 horizon: 20,
526 samples: 480,
527 dt: 0.1,
528 noise_sigma: 3.2,
529 velocity_weight: 0.01,
530 control_weight: 0.01,
531 lambda: 1.0,
532 seed: 7,
533 }
534 }
535}
536
537fn validate_config(config: &RacingMppi3DConfig) -> RoboticsResult<()> {
538 if config.horizon == 0 {
539 return Err(RoboticsError::InvalidParameter(
540 "racing MPPI horizon must be positive".to_string(),
541 ));
542 }
543 if config.samples == 0 {
544 return Err(RoboticsError::InvalidParameter(
545 "racing MPPI samples must be positive".to_string(),
546 ));
547 }
548 if !config.dt.is_finite() || config.dt <= 0.0 {
549 return Err(RoboticsError::InvalidParameter(
550 "racing MPPI dt must be finite and positive".to_string(),
551 ));
552 }
553 if !config.noise_sigma.is_finite() || config.noise_sigma <= 0.0 {
554 return Err(RoboticsError::InvalidParameter(
555 "racing MPPI noise_sigma must be finite and positive".to_string(),
556 ));
557 }
558 if !config.lambda.is_finite() || config.lambda <= 0.0 {
559 return Err(RoboticsError::InvalidParameter(
560 "racing MPPI lambda must be finite and positive".to_string(),
561 ));
562 }
563 if config.velocity_weight < 0.0 || config.control_weight < 0.0 {
564 return Err(RoboticsError::InvalidParameter(
565 "racing MPPI weights must be non-negative".to_string(),
566 ));
567 }
568 Ok(())
569}
570
571#[derive(Debug, Clone)]
579pub struct RacingMppi3DController {
580 config: RacingMppi3DConfig,
581 dynamics: RacingDroneDynamics3D,
582 nominal: Vec<RacingDroneControl3D>,
583 rng: StdRng,
584}
585
586#[derive(Debug, Clone, PartialEq)]
588pub struct RacingMppi3DPlan {
589 pub control: RacingDroneControl3D,
590 pub best_cost: f64,
592 pub normalized_effective_sample_size: f64,
594}
595
596impl RacingMppi3DController {
597 pub fn new(
598 config: RacingMppi3DConfig,
599 dynamics: RacingDroneDynamics3D,
600 ) -> RoboticsResult<Self> {
601 validate_config(&config)?;
602 let nominal = vec![RacingDroneControl3D::zero(); config.horizon];
603 let rng = StdRng::seed_from_u64(config.seed);
604 Ok(Self {
605 config,
606 dynamics,
607 nominal,
608 rng,
609 })
610 }
611
612 pub fn config(&self) -> &RacingMppi3DConfig {
613 &self.config
614 }
615
616 fn rollout_positions(
617 &self,
618 start: RacingDroneState3D,
619 controls: &[RacingDroneControl3D],
620 ) -> (Vec<[f64; 3]>, f64) {
621 let mut state = start;
622 let mut positions = Vec::with_capacity(controls.len() + 1);
623 positions.push(state.position());
624 let mut regularizer = 0.0;
625 for &control in controls {
626 state = self.dynamics.step(state, control, self.config.dt);
627 positions.push(state.position());
628 regularizer += self.config.velocity_weight * state.speed() * state.speed()
629 + self.config.control_weight * control.magnitude() * control.magnitude();
630 }
631 (positions, regularizer)
632 }
633
634 pub fn plan(
636 &mut self,
637 start: RacingDroneState3D,
638 lap: &RacingGateLap3D,
639 passed: usize,
640 ) -> RoboticsResult<RacingMppi3DPlan> {
641 let normal = Normal::new(0.0, self.config.noise_sigma).map_err(|_| {
642 RoboticsError::InvalidParameter("invalid racing MPPI noise distribution".to_string())
643 })?;
644
645 let mut costs = Vec::with_capacity(self.config.samples);
646 let mut perturbed_sequences = Vec::with_capacity(self.config.samples);
647 let mut best_cost = f64::INFINITY;
648
649 for _ in 0..self.config.samples {
650 let mut controls = Vec::with_capacity(self.config.horizon);
651 for &base in &self.nominal {
652 let noisy = RacingDroneControl3D::new(
653 base.ax + normal.sample(&mut self.rng),
654 base.ay + normal.sample(&mut self.rng),
655 base.az + normal.sample(&mut self.rng),
656 );
657 controls.push(self.dynamics.saturate(noisy));
658 }
659 let (positions, regularizer) = self.rollout_positions(start, &controls);
660 let (gate_cost, _) = lap.score_positions(&positions, passed);
661 let cost = gate_cost + regularizer;
662 best_cost = best_cost.min(cost);
663 costs.push(cost);
664 perturbed_sequences.push(controls);
665 }
666
667 let min_cost = costs.iter().copied().fold(f64::INFINITY, f64::min);
668 let mut weights = Vec::with_capacity(costs.len());
669 let mut weight_sum = 0.0;
670 for &cost in &costs {
671 let weight = (-(cost - min_cost) / self.config.lambda).exp();
672 weight_sum += weight;
673 weights.push(weight);
674 }
675 if weight_sum <= 0.0 || !weight_sum.is_finite() {
676 let best_index = costs
678 .iter()
679 .enumerate()
680 .min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
681 .map(|(index, _)| index)
682 .unwrap_or(0);
683 let control = perturbed_sequences[best_index][0];
684 return Ok(RacingMppi3DPlan {
685 control,
686 best_cost,
687 normalized_effective_sample_size: 0.0,
688 });
689 }
690
691 let mut updated = vec![RacingDroneControl3D::zero(); self.config.horizon];
692 let mut sum_sq = 0.0;
693 for (weight, controls) in weights.iter().zip(&perturbed_sequences) {
694 let normalized = weight / weight_sum;
695 sum_sq += normalized * normalized;
696 for (acc, control) in updated.iter_mut().zip(controls) {
697 acc.ax += normalized * control.ax;
698 acc.ay += normalized * control.ay;
699 acc.az += normalized * control.az;
700 }
701 }
702 for control in &mut updated {
703 *control = self.dynamics.saturate(*control);
704 }
705
706 let first_control = updated[0];
707 self.nominal.clear();
709 self.nominal.extend_from_slice(&updated[1..]);
710 self.nominal.push(*updated.last().unwrap());
711
712 let normalized_effective_sample_size = if sum_sq > 0.0 {
713 1.0 / (sum_sq * self.config.samples as f64)
714 } else {
715 0.0
716 };
717
718 Ok(RacingMppi3DPlan {
719 control: first_control,
720 best_cost,
721 normalized_effective_sample_size,
722 })
723 }
724}
725
726pub fn simulate_lap_race(
733 config: RacingMppi3DConfig,
734 dynamics: RacingDroneDynamics3D,
735 lap: &RacingGateLap3D,
736 start: RacingDroneState3D,
737 max_steps: usize,
738 target_laps: usize,
739) -> RoboticsResult<RacingLapReport3D> {
740 let dt = config.dt;
741 let gate_count = lap.gate_count();
742 let mut controller = RacingMppi3DController::new(config, dynamics)?;
743
744 let mut state = start;
745 let mut passed = 0usize;
746 let mut path = vec![state.position()];
747 let mut sum_speed = 0.0;
748 let mut max_speed = 0.0_f64;
749 let mut path_length = 0.0;
750 let mut control_effort = 0.0;
751 let mut min_aperture_margin = f64::INFINITY;
752 let mut first_lap_time = None;
753 let mut executed_steps = 0usize;
754
755 for step in 0..max_steps {
756 let plan = controller.plan(state, lap, passed)?;
757 let next = dynamics.step(state, plan.control, dt);
758 let from = state.position();
759 let to = next.position();
760
761 loop {
763 let gate = lap.gate_for_pass_count(passed);
764 if !gate.crosses(from, to, lap.crossing_tolerance) {
765 break;
766 }
767 let margin = aperture_crossing_margin(gate, from, to);
768 min_aperture_margin = min_aperture_margin.min(margin);
769 passed += 1;
770 if passed == gate_count && first_lap_time.is_none() {
771 first_lap_time = Some((step + 1) as f64 * dt);
772 }
773 if !lap.closed && passed >= gate_count {
775 break;
776 }
777 }
778
779 path_length += distance(from, to);
780 control_effort += plan.control.magnitude();
781 let speed = next.speed();
782 sum_speed += speed;
783 max_speed = max_speed.max(speed);
784 state = next;
785 path.push(to);
786 executed_steps += 1;
787
788 if target_laps > 0 && passed >= target_laps * gate_count {
789 break;
790 }
791 if !lap.closed && passed >= gate_count {
792 break;
793 }
794 }
795
796 let laps_completed = passed / gate_count;
797 let lap_fraction = (passed % gate_count) as f64 / gate_count as f64;
798 let active_gate = lap.gate_for_pass_count(passed);
799 let gate_distance = active_gate.squared_distance(state.position()).sqrt();
800 let mean_speed = if executed_steps > 0 {
801 sum_speed / executed_steps as f64
802 } else {
803 0.0
804 };
805 let mean_control_effort = if executed_steps > 0 {
806 control_effort / executed_steps as f64
807 } else {
808 0.0
809 };
810
811 Ok(RacingLapReport3D {
812 steps: executed_steps,
813 gates_passed: passed,
814 laps_completed,
815 lap_fraction,
816 mean_speed,
817 max_speed,
818 path_length,
819 gate_distance,
820 min_aperture_margin,
821 mean_control_effort,
822 first_lap_time,
823 path,
824 })
825}
826
827fn aperture_crossing_margin(gate: &RacingGatePlane3D, from: [f64; 3], to: [f64; 3]) -> f64 {
828 let from_signed = gate.signed_distance(from);
829 let to_signed = gate.signed_distance(to);
830 let denom = to_signed - from_signed;
831 let t = if denom.abs() > 1e-9 {
832 (-from_signed / denom).clamp(0.0, 1.0)
833 } else {
834 0.0
835 };
836 let crossing = [
837 from[0] + t * (to[0] - from[0]),
838 from[1] + t * (to[1] - from[1]),
839 from[2] + t * (to[2] - from[2]),
840 ];
841 gate.aperture_margin(crossing)
842}
843
844fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
847 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
848}
849
850fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
851 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
852}
853
854fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
855 [a[0] * s, a[1] * s, a[2] * s]
856}
857
858fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
859 [
860 a[1] * b[2] - a[2] * b[1],
861 a[2] * b[0] - a[0] * b[2],
862 a[0] * b[1] - a[1] * b[0],
863 ]
864}
865
866fn normalize(a: [f64; 3]) -> Option<[f64; 3]> {
867 let norm = dot(a, a).sqrt();
868 if !norm.is_finite() || norm <= 0.0 {
869 return None;
870 }
871 Some(scale(a, 1.0 / norm))
872}
873
874fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
875 let d = sub(a, b);
876 dot(d, d).sqrt()
877}
878
879#[cfg(test)]
880mod tests {
881 use super::*;
882
883 fn axis_gate(center: [f64; 3]) -> RacingGatePlane3D {
884 RacingGatePlane3D::new(center, [1.0, 0.0, 0.0], [0.0, 0.0, 1.0], 0.5, 0.5).unwrap()
885 }
886
887 #[test]
888 fn gate_orthonormalizes_axes() {
889 let gate =
890 RacingGatePlane3D::new([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.3, 0.0, 1.0], 0.5, 0.4)
891 .unwrap();
892 assert!(dot(gate.normal(), [1.0, 0.0, 0.0]) > 0.999);
894 let (right, up) = (
895 gate.plane_offsets([0.0, 1.0, 0.0]),
896 gate.signed_distance([0.0, 1.0, 0.0]),
897 );
898 assert!(up.abs() < 1e-9); assert!(right.0.abs() > 0.0 || right.1.abs() > 0.0);
900 }
901
902 #[test]
903 fn gate_rejects_parallel_up_hint() {
904 let result =
905 RacingGatePlane3D::new([0.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 2.0], 0.5, 0.5);
906 assert!(result.is_err());
907 }
908
909 #[test]
910 fn gate_detects_central_crossing_and_rejects_miss() {
911 let gate = axis_gate([1.0, 0.0, 0.0]);
912 assert!(gate.crosses([0.5, 0.0, 0.0], [1.5, 0.0, 0.0], 0.04));
913 assert!(!gate.crosses([0.5, 3.0, 0.0], [1.5, 3.0, 0.0], 0.04));
915 assert!(!gate.crosses([1.5, 0.0, 0.0], [0.5, 0.0, 0.0], 0.04));
917 }
918
919 #[test]
920 fn aperture_margin_is_one_at_center() {
921 let gate = axis_gate([1.0, 0.0, 0.0]);
922 assert!((gate.aperture_margin([1.0, 0.0, 0.0]) - 1.0).abs() < 1e-9);
923 assert!(gate.aperture_margin([1.0, 0.5, 0.0]).abs() < 1e-9);
924 }
925
926 #[test]
927 fn drag_decays_velocity_without_control() {
928 let dynamics = RacingDroneDynamics3D::default();
929 let state = RacingDroneState3D::new(0.0, 0.0, 0.0, 4.0, 0.0, 0.0);
930 let next = dynamics.step(state, RacingDroneControl3D::zero(), 0.1);
931 assert!(next.vx < state.vx);
932 assert!(next.vx > 0.0);
933 }
934
935 #[test]
936 fn dynamics_caps_speed() {
937 let dynamics = RacingDroneDynamics3D::new(0.0, 0.0, 3.0, 100.0).unwrap();
938 let state = RacingDroneState3D::at(0.0, 0.0, 0.0);
939 let next = dynamics.step(state, RacingDroneControl3D::new(100.0, 0.0, 0.0), 0.1);
940 assert!(next.speed() <= 3.0 + 1e-9);
941 }
942
943 #[test]
944 fn closed_lap_centerline_includes_return_leg() {
945 let gates = vec![
946 axis_gate([0.0, 0.0, 0.0]),
947 axis_gate([1.0, 0.0, 0.0]),
948 axis_gate([1.0, 1.0, 0.0]),
949 ];
950 let open = RacingGateLap3D::open(gates.clone()).unwrap();
951 let closed = RacingGateLap3D::closed_loop(gates).unwrap();
952 assert!(closed.centerline_length() > open.centerline_length());
953 }
954
955 #[test]
956 fn closed_lap_wraps_active_gate() {
957 let gates = vec![axis_gate([0.0, 0.0, 0.0]), axis_gate([1.0, 0.0, 0.0])];
958 let lap = RacingGateLap3D::closed_loop(gates).unwrap();
959 assert_eq!(lap.gate_for_pass_count(0).center(), [0.0, 0.0, 0.0]);
960 assert_eq!(lap.gate_for_pass_count(2).center(), [0.0, 0.0, 0.0]);
961 assert_eq!(lap.gate_for_pass_count(3).center(), [1.0, 0.0, 0.0]);
962 }
963
964 fn straight_lap() -> RacingGateLap3D {
965 let gates = vec![
966 axis_gate([1.0, 0.0, 0.0]),
967 axis_gate([2.0, 0.0, 0.3]),
968 axis_gate([3.0, 0.0, 0.6]),
969 ];
970 RacingGateLap3D::open(gates).unwrap()
971 }
972
973 #[test]
974 fn controller_makes_gate_progress() {
975 let lap = straight_lap();
976 let report = simulate_lap_race(
977 RacingMppi3DConfig::default(),
978 RacingDroneDynamics3D::default(),
979 &lap,
980 RacingDroneState3D::at(0.0, 0.0, 0.0),
981 60,
982 1,
983 )
984 .unwrap();
985 assert!(
986 report.gates_passed >= 1,
987 "expected at least one gate, got {}",
988 report.gates_passed
989 );
990 assert!(report.path_length > 0.0);
991 }
992
993 #[test]
994 fn simulation_is_deterministic() {
995 let lap = straight_lap();
996 let run = || {
997 simulate_lap_race(
998 RacingMppi3DConfig::default(),
999 RacingDroneDynamics3D::default(),
1000 &lap,
1001 RacingDroneState3D::at(0.0, 0.0, 0.0),
1002 60,
1003 1,
1004 )
1005 .unwrap()
1006 };
1007 let first = run();
1008 let second = run();
1009 assert_eq!(first.path, second.path);
1010 assert_eq!(first.gates_passed, second.gates_passed);
1011 }
1012
1013 #[test]
1014 fn invalid_config_is_rejected() {
1015 let config = RacingMppi3DConfig {
1016 horizon: 0,
1017 ..RacingMppi3DConfig::default()
1018 };
1019 assert!(RacingMppi3DController::new(config, RacingDroneDynamics3D::default()).is_err());
1020 }
1021}