Skip to main content

oxiphysics_python/
vehicle_api.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Vehicle simulation API for Python interop.
6//!
7//! Provides Python-friendly types for full vehicle simulation including:
8//! tire models (Pacejka/Fiala), drivetrain, suspension, stability control,
9//! telemetry, and racing line optimization.
10
11#![allow(missing_docs)]
12#![allow(dead_code)]
13
14use serde::{Deserialize, Serialize};
15
16// ---------------------------------------------------------------------------
17// Helper functions
18// ---------------------------------------------------------------------------
19
20/// Compute the Pacejka magic formula lateral force Fy.
21///
22/// Parameters follow the standard Pacejka notation.
23/// - `alpha` — slip angle in radians
24/// - `b` — stiffness factor
25/// - `c` — shape factor
26/// - `d` — peak value
27/// - `e` — curvature factor
28///
29/// Returns the lateral force as a fraction of normal load (Fy / Fz).
30pub fn pacejka_fy(alpha: f64, b: f64, c: f64, d: f64, e: f64) -> f64 {
31    let x = alpha.to_degrees(); // Pacejka often uses degrees internally
32    let phi = (1.0 - e) * x + (e / b) * (b * x).atan();
33    d * (c * (b * phi).atan()).sin()
34}
35
36/// Compute the Pacejka magic formula longitudinal force Fx.
37///
38/// - `kappa` — slip ratio (dimensionless, -1 to 1)
39/// - `b` — stiffness factor
40/// - `c` — shape factor
41/// - `d` — peak value
42/// - `e` — curvature factor
43pub fn pacejka_fx(kappa: f64, b: f64, c: f64, d: f64, e: f64) -> f64 {
44    let x = kappa * 100.0; // Pacejka often uses percent slip
45    let phi = (1.0 - e) * x + (e / b) * (b * x).atan();
46    d * (c * (b * phi).atan()).sin()
47}
48
49/// Compute the slip angle from tire velocity components.
50///
51/// - `vy` — lateral velocity component of the tire contact point (m/s)
52/// - `vx` — longitudinal velocity component (m/s)
53///
54/// Returns slip angle in radians.
55pub fn compute_slip_angle(vy: f64, vx: f64) -> f64 {
56    if vx.abs() < 1e-4 {
57        return 0.0;
58    }
59    (-vy / vx).atan()
60}
61
62/// Compute the longitudinal slip ratio.
63///
64/// - `wheel_speed` — peripheral speed of the tire = omega * R (m/s)
65/// - `vehicle_speed` — forward speed of the vehicle (m/s)
66///
67/// Returns slip ratio (dimensionless, positive = drive slip, negative = brake slip).
68pub fn compute_slip_ratio(wheel_speed: f64, vehicle_speed: f64) -> f64 {
69    let v_ref = vehicle_speed.abs().max(wheel_speed.abs()).max(1e-4);
70    (wheel_speed - vehicle_speed) / v_ref
71}
72
73/// Compute Ackermann steering angles for inner and outer front wheels.
74///
75/// - `steer_angle` — rack steering angle (rad, positive = left turn)
76/// - `wheelbase` — distance between front and rear axles (m)
77/// - `track_width` — distance between left and right wheels (m)
78///
79/// Returns `(angle_inner, angle_outer)` in radians.
80pub fn ackermann_angles(steer_angle: f64, wheelbase: f64, track_width: f64) -> (f64, f64) {
81    if steer_angle.abs() < 1e-9 {
82        return (0.0, 0.0);
83    }
84    let r = wheelbase / steer_angle.abs();
85    let sign = steer_angle.signum();
86    let inner = (wheelbase / (r - track_width * 0.5)).atan() * sign;
87    let outer = (wheelbase / (r + track_width * 0.5)).atan() * sign;
88    if steer_angle > 0.0 {
89        (inner, outer)
90    } else {
91        (outer, inner)
92    }
93}
94
95// ---------------------------------------------------------------------------
96// PyTireModel
97// ---------------------------------------------------------------------------
98
99/// Pacejka magic formula tire coefficients.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct PacejkaCoeffs {
102    /// Stiffness factor B.
103    pub b: f64,
104    /// Shape factor C.
105    pub c: f64,
106    /// Peak factor D (as fraction of Fz).
107    pub d: f64,
108    /// Curvature factor E.
109    pub e: f64,
110}
111
112impl PacejkaCoeffs {
113    /// Typical road tire lateral coefficients.
114    pub fn road_lateral() -> Self {
115        Self {
116            b: 10.0,
117            c: 1.9,
118            d: 1.0,
119            e: 0.97,
120        }
121    }
122
123    /// Typical road tire longitudinal coefficients.
124    pub fn road_longitudinal() -> Self {
125        Self {
126            b: 11.0,
127            c: 1.65,
128            d: 1.0,
129            e: 0.0,
130        }
131    }
132
133    /// Slick racing tire coefficients.
134    pub fn slick_lateral() -> Self {
135        Self {
136            b: 12.0,
137            c: 2.0,
138            d: 1.4,
139            e: 0.95,
140        }
141    }
142}
143
144/// Fiala tire model parameters.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct FialaParams {
147    /// Cornering stiffness (N/rad).
148    pub cornering_stiffness: f64,
149    /// Friction coefficient.
150    pub mu: f64,
151}
152
153impl FialaParams {
154    /// Create default Fiala parameters.
155    pub fn new(cornering_stiffness: f64, mu: f64) -> Self {
156        Self {
157            cornering_stiffness,
158            mu,
159        }
160    }
161
162    /// Compute lateral force given slip angle and normal load.
163    pub fn lateral_force(&self, alpha: f64, fz: f64) -> f64 {
164        let f_max = self.mu * fz;
165        let f_lin = -self.cornering_stiffness * alpha;
166        if f_lin.abs() > f_max {
167            f_max * f_lin.signum()
168        } else {
169            f_lin
170        }
171    }
172}
173
174/// Tire model selection.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub enum TireModelKind {
177    /// Pacejka magic formula.
178    Pacejka {
179        lateral: PacejkaCoeffs,
180        longitudinal: PacejkaCoeffs,
181    },
182    /// Fiala brush model.
183    Fiala(FialaParams),
184    /// Simple linear tire model.
185    Linear {
186        cornering_stiffness: f64,
187        longitudinal_stiffness: f64,
188    },
189}
190
191/// Tire model for force computation.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct PyTireModel {
194    /// Tire model type.
195    pub kind: TireModelKind,
196    /// Tire rolling radius (m).
197    pub radius: f64,
198    /// Tire width (m).
199    pub width: f64,
200    /// Unloaded tire radius (m).
201    pub unloaded_radius: f64,
202}
203
204impl PyTireModel {
205    /// Create a Pacejka tire model for a road car.
206    pub fn road_pacejka(radius: f64) -> Self {
207        Self {
208            kind: TireModelKind::Pacejka {
209                lateral: PacejkaCoeffs::road_lateral(),
210                longitudinal: PacejkaCoeffs::road_longitudinal(),
211            },
212            radius,
213            width: 0.225,
214            unloaded_radius: radius,
215        }
216    }
217
218    /// Create a racing slick tire model.
219    pub fn slick_pacejka(radius: f64) -> Self {
220        Self {
221            kind: TireModelKind::Pacejka {
222                lateral: PacejkaCoeffs::slick_lateral(),
223                longitudinal: PacejkaCoeffs::road_longitudinal(),
224            },
225            radius,
226            width: 0.300,
227            unloaded_radius: radius,
228        }
229    }
230
231    /// Compute combined lateral and longitudinal forces.
232    ///
233    /// Returns `(Fx, Fy)` — longitudinal and lateral forces in N.
234    pub fn compute_forces(&self, alpha: f64, kappa: f64, fz: f64) -> (f64, f64) {
235        match &self.kind {
236            TireModelKind::Pacejka {
237                lateral,
238                longitudinal,
239            } => {
240                let fy = pacejka_fy(alpha, lateral.b, lateral.c, lateral.d, lateral.e) * fz;
241                let fx = pacejka_fx(
242                    kappa,
243                    longitudinal.b,
244                    longitudinal.c,
245                    longitudinal.d,
246                    longitudinal.e,
247                ) * fz;
248                (fx, fy)
249            }
250            TireModelKind::Fiala(params) => {
251                let fy = params.lateral_force(alpha, fz);
252                let fx = 0.0;
253                (fx, fy)
254            }
255            TireModelKind::Linear {
256                cornering_stiffness,
257                longitudinal_stiffness,
258            } => {
259                let fy = -cornering_stiffness * alpha;
260                let fx = longitudinal_stiffness * kappa * fz;
261                (fx, fy)
262            }
263        }
264    }
265}
266
267// ---------------------------------------------------------------------------
268// PyTireThermal
269// ---------------------------------------------------------------------------
270
271/// Three-layer tire thermal model (surface / bulk / carcass).
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct PyTireThermal {
274    /// Surface temperature (°C).
275    pub surface_temp: f64,
276    /// Bulk temperature (°C).
277    pub bulk_temp: f64,
278    /// Carcass temperature (°C).
279    pub carcass_temp: f64,
280    /// Ambient temperature (°C).
281    pub ambient_temp: f64,
282    /// Thermal conductivity surface→bulk (W/m²K).
283    pub k_surface_bulk: f64,
284    /// Thermal conductivity bulk→carcass (W/m²K).
285    pub k_bulk_carcass: f64,
286    /// Thermal conductivity carcass→ambient (W/m²K).
287    pub k_carcass_ambient: f64,
288    /// Optimal temperature for maximum grip (°C).
289    pub optimal_temp: f64,
290    /// Temperature window for full grip (°C).
291    pub optimal_window: f64,
292}
293
294impl PyTireThermal {
295    /// Create a default tire thermal model.
296    pub fn new() -> Self {
297        Self {
298            surface_temp: 25.0,
299            bulk_temp: 25.0,
300            carcass_temp: 25.0,
301            ambient_temp: 25.0,
302            k_surface_bulk: 50.0,
303            k_bulk_carcass: 20.0,
304            k_carcass_ambient: 5.0,
305            optimal_temp: 90.0,
306            optimal_window: 20.0,
307        }
308    }
309
310    /// Update the thermal model given power dissipation (W) and time step (s).
311    pub fn update(&mut self, power: f64, dt: f64) {
312        let mass_surface = 0.3; // kg (approximate)
313        let mass_bulk = 2.0;
314        let mass_carcass = 5.0;
315        let cp = 1300.0; // J/(kg K) rubber
316
317        let q_surface_bulk = self.k_surface_bulk * (self.surface_temp - self.bulk_temp);
318        let q_bulk_carcass = self.k_bulk_carcass * (self.bulk_temp - self.carcass_temp);
319        let q_carcass_ambient = self.k_carcass_ambient * (self.carcass_temp - self.ambient_temp);
320
321        self.surface_temp += (power - q_surface_bulk) / (mass_surface * cp) * dt;
322        self.bulk_temp += (q_surface_bulk - q_bulk_carcass) / (mass_bulk * cp) * dt;
323        self.carcass_temp += (q_bulk_carcass - q_carcass_ambient) / (mass_carcass * cp) * dt;
324    }
325
326    /// Compute grip scaling factor based on surface temperature.
327    pub fn grip_scale(&self) -> f64 {
328        let dt = (self.surface_temp - self.optimal_temp).abs();
329        if dt <= self.optimal_window * 0.5 {
330            1.0
331        } else {
332            let excess = dt - self.optimal_window * 0.5;
333            (1.0 - excess / self.optimal_window).max(0.3)
334        }
335    }
336}
337
338impl Default for PyTireThermal {
339    fn default() -> Self {
340        Self::new()
341    }
342}
343
344// ---------------------------------------------------------------------------
345// PyDrivetrain
346// ---------------------------------------------------------------------------
347
348/// Engine torque curve defined by RPM breakpoints and torque values.
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct EngineCurve {
351    /// RPM breakpoints (sorted ascending).
352    pub rpm: Vec<f64>,
353    /// Torque at each breakpoint (N·m).
354    pub torque: Vec<f64>,
355    /// Maximum RPM (redline).
356    pub redline: f64,
357    /// Idle RPM.
358    pub idle: f64,
359}
360
361impl EngineCurve {
362    /// Create a simple flat torque curve.
363    pub fn flat(peak_torque: f64, redline: f64) -> Self {
364        Self {
365            rpm: vec![0.0, redline * 0.3, redline * 0.7, redline],
366            torque: vec![
367                peak_torque * 0.6,
368                peak_torque,
369                peak_torque,
370                peak_torque * 0.8,
371            ],
372            redline,
373            idle: 800.0,
374        }
375    }
376
377    /// Interpolate torque at a given RPM.
378    pub fn torque_at(&self, rpm: f64) -> f64 {
379        let n = self.rpm.len();
380        if n == 0 {
381            return 0.0;
382        }
383        if rpm <= self.rpm[0] {
384            return self.torque[0];
385        }
386        if rpm >= self.rpm[n - 1] {
387            return self.torque[n - 1];
388        }
389        for i in 1..n {
390            if rpm <= self.rpm[i] {
391                let t = (rpm - self.rpm[i - 1]) / (self.rpm[i] - self.rpm[i - 1]);
392                return self.torque[i - 1] + t * (self.torque[i] - self.torque[i - 1]);
393            }
394        }
395        self.torque[n - 1]
396    }
397}
398
399/// Differential type.
400#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
401pub enum DiffType {
402    /// Open differential (no torque biasing).
403    Open,
404    /// Limited-slip differential.
405    Lsd,
406    /// Locked (spool).
407    Locked,
408}
409
410/// Drivetrain configuration.
411#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct PyDrivetrain {
413    /// Engine torque curve.
414    pub engine_curve: EngineCurve,
415    /// Gear ratios (index 0 = first gear).
416    pub gear_ratios: Vec<f64>,
417    /// Final drive ratio.
418    pub final_drive: f64,
419    /// Current gear (0-indexed).
420    pub current_gear: usize,
421    /// Differential type.
422    pub diff_type: DiffType,
423    /// Clutch engagement factor (0 = fully open, 1 = fully engaged).
424    pub clutch: f64,
425    /// Current engine RPM.
426    pub engine_rpm: f64,
427    /// Engine inertia (kg·m²).
428    pub engine_inertia: f64,
429}
430
431impl PyDrivetrain {
432    /// Create a default 6-speed drivetrain.
433    pub fn six_speed(peak_torque: f64) -> Self {
434        Self {
435            engine_curve: EngineCurve::flat(peak_torque, 7000.0),
436            gear_ratios: vec![3.31, 2.13, 1.55, 1.21, 0.97, 0.79],
437            final_drive: 3.73,
438            current_gear: 1,
439            diff_type: DiffType::Open,
440            clutch: 1.0,
441            engine_rpm: 1000.0,
442            engine_inertia: 0.2,
443        }
444    }
445
446    /// Compute wheel torque given throttle (0-1) and the current state.
447    pub fn wheel_torque(&self, throttle: f64) -> f64 {
448        let engine_torque = self.engine_curve.torque_at(self.engine_rpm) * throttle;
449        let gear_ratio = self
450            .gear_ratios
451            .get(self.current_gear)
452            .copied()
453            .unwrap_or(1.0);
454        engine_torque * gear_ratio * self.final_drive * self.clutch
455    }
456
457    /// Upshift to the next gear.
458    pub fn upshift(&mut self) {
459        if self.current_gear + 1 < self.gear_ratios.len() {
460            self.current_gear += 1;
461        }
462    }
463
464    /// Downshift.
465    pub fn downshift(&mut self) {
466        if self.current_gear > 0 {
467            self.current_gear -= 1;
468        }
469    }
470
471    /// Current gear ratio.
472    pub fn current_ratio(&self) -> f64 {
473        self.gear_ratios
474            .get(self.current_gear)
475            .copied()
476            .unwrap_or(1.0)
477    }
478
479    /// Update engine RPM from wheel speed.
480    pub fn update_rpm(&mut self, wheel_angular_velocity: f64) {
481        let ratio = self.current_ratio() * self.final_drive;
482        self.engine_rpm = (wheel_angular_velocity * ratio * 60.0 / (2.0 * std::f64::consts::PI))
483            .max(self.engine_curve.idle)
484            .min(self.engine_curve.redline);
485    }
486}
487
488// ---------------------------------------------------------------------------
489// PySteering
490// ---------------------------------------------------------------------------
491
492/// Steering system with Ackermann geometry and 4WS option.
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct PySteering {
495    /// Steering rack ratio (radians of wheel turn per radian of steering input).
496    pub rack_ratio: f64,
497    /// Maximum steering angle at the wheels (rad).
498    pub max_wheel_angle: f64,
499    /// Whether four-wheel steering is active.
500    pub four_ws: bool,
501    /// Rear steer angle ratio relative to front (0 = no rear steer, negative = counter-steer).
502    pub rear_steer_ratio: f64,
503    /// Wheelbase (m).
504    pub wheelbase: f64,
505    /// Track width (m).
506    pub track_width: f64,
507    /// Steering feel: damping coefficient.
508    pub steering_damping: f64,
509    /// Current steering angle input (normalized -1 to 1).
510    pub input: f64,
511}
512
513impl PySteering {
514    /// Create a default front-wheel-steering system.
515    pub fn new(wheelbase: f64, track_width: f64) -> Self {
516        Self {
517            rack_ratio: 0.15,
518            max_wheel_angle: 0.5,
519            four_ws: false,
520            rear_steer_ratio: 0.0,
521            wheelbase,
522            track_width,
523            steering_damping: 5.0,
524            input: 0.0,
525        }
526    }
527
528    /// Compute the front wheel steer angle from the input.
529    pub fn front_angle(&self) -> f64 {
530        (self.input * self.rack_ratio).clamp(-self.max_wheel_angle, self.max_wheel_angle)
531    }
532
533    /// Compute the rear wheel steer angle (0 for standard FWS).
534    pub fn rear_angle(&self) -> f64 {
535        self.front_angle() * self.rear_steer_ratio
536    }
537
538    /// Compute Ackermann inner/outer wheel angles.
539    pub fn ackermann_front_angles(&self) -> (f64, f64) {
540        ackermann_angles(self.front_angle(), self.wheelbase, self.track_width)
541    }
542}
543
544// ---------------------------------------------------------------------------
545// PyStabilityControl
546// ---------------------------------------------------------------------------
547
548/// Traction control system (TCS) state.
549#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct TcsState {
551    /// Whether TCS is active this frame.
552    pub active: bool,
553    /// Slip ratio threshold for TCS intervention.
554    pub slip_threshold: f64,
555    /// Throttle reduction factor applied by TCS.
556    pub throttle_reduction: f64,
557}
558
559impl TcsState {
560    /// Create default TCS state.
561    pub fn new() -> Self {
562        Self {
563            active: false,
564            slip_threshold: 0.2,
565            throttle_reduction: 0.0,
566        }
567    }
568
569    /// Update TCS given current drive slip ratios.
570    pub fn update(&mut self, slip_ratios: &[f64]) -> f64 {
571        let max_slip = slip_ratios.iter().cloned().fold(0.0_f64, f64::max);
572        if max_slip > self.slip_threshold {
573            self.active = true;
574            self.throttle_reduction =
575                ((max_slip - self.slip_threshold) / self.slip_threshold).min(1.0);
576        } else {
577            self.active = false;
578            self.throttle_reduction = 0.0;
579        }
580        1.0 - self.throttle_reduction
581    }
582}
583
584impl Default for TcsState {
585    fn default() -> Self {
586        Self::new()
587    }
588}
589
590/// ABS (anti-lock braking system) state.
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct AbsState {
593    /// Whether ABS is active this frame.
594    pub active: bool,
595    /// Brake slip threshold for ABS intervention.
596    pub slip_threshold: f64,
597    /// Brake pressure modulation factor.
598    pub brake_modulation: f64,
599    /// Current phase: pressure build (0), hold (1), release (2).
600    pub phase: u8,
601}
602
603impl AbsState {
604    /// Create default ABS state.
605    pub fn new() -> Self {
606        Self {
607            active: false,
608            slip_threshold: -0.15,
609            brake_modulation: 1.0,
610            phase: 0,
611        }
612    }
613
614    /// Update ABS given current brake slip ratios.
615    pub fn update(&mut self, slip_ratios: &[f64]) -> f64 {
616        let min_slip = slip_ratios.iter().cloned().fold(0.0_f64, f64::min);
617        if min_slip < self.slip_threshold {
618            self.active = true;
619            // Simple modulation: alternate between hold and release
620            self.phase = (self.phase + 1) % 3;
621            self.brake_modulation = if self.phase == 2 { 0.5 } else { 1.0 };
622        } else {
623            self.active = false;
624            self.brake_modulation = 1.0;
625            self.phase = 0;
626        }
627        self.brake_modulation
628    }
629}
630
631impl Default for AbsState {
632    fn default() -> Self {
633        Self::new()
634    }
635}
636
637/// Electronic stability control (ESC) state.
638#[derive(Debug, Clone, Serialize, Deserialize)]
639pub struct EscState {
640    /// Whether ESC is active this frame.
641    pub active: bool,
642    /// Yaw rate error threshold (rad/s).
643    pub yaw_error_threshold: f64,
644    /// Brake intervention torque (N·m per wheel).
645    pub brake_torque: f64,
646    /// Last computed yaw rate error.
647    pub yaw_error: f64,
648}
649
650impl EscState {
651    /// Create default ESC state.
652    pub fn new() -> Self {
653        Self {
654            active: false,
655            yaw_error_threshold: 0.1,
656            brake_torque: 0.0,
657            yaw_error: 0.0,
658        }
659    }
660
661    /// Update ESC given desired and actual yaw rates.
662    pub fn update(&mut self, desired_yaw: f64, actual_yaw: f64) {
663        self.yaw_error = desired_yaw - actual_yaw;
664        if self.yaw_error.abs() > self.yaw_error_threshold {
665            self.active = true;
666            self.brake_torque = self.yaw_error.abs() * 500.0; // proportional intervention
667        } else {
668            self.active = false;
669            self.brake_torque = 0.0;
670        }
671    }
672}
673
674impl Default for EscState {
675    fn default() -> Self {
676        Self::new()
677    }
678}
679
680/// Vehicle stability control system combining TCS, ABS, and ESC.
681#[derive(Debug, Clone, Serialize, Deserialize)]
682pub struct PyStabilityControl {
683    /// Traction control.
684    pub tcs: TcsState,
685    /// Anti-lock braking.
686    pub abs: AbsState,
687    /// Electronic stability control.
688    pub esc: EscState,
689    /// Whether the whole system is enabled.
690    pub enabled: bool,
691}
692
693impl PyStabilityControl {
694    /// Create a default stability control system.
695    pub fn new() -> Self {
696        Self {
697            tcs: TcsState::new(),
698            abs: AbsState::new(),
699            esc: EscState::new(),
700            enabled: true,
701        }
702    }
703
704    /// Compute throttle scale, brake modulation, and yaw correction.
705    pub fn update(&mut self, slip_ratios: &[f64], desired_yaw: f64, actual_yaw: f64) -> (f64, f64) {
706        if !self.enabled {
707            return (1.0, 1.0);
708        }
709        let throttle_scale = self.tcs.update(slip_ratios);
710        let brake_scale = self.abs.update(slip_ratios);
711        self.esc.update(desired_yaw, actual_yaw);
712        (throttle_scale, brake_scale)
713    }
714}
715
716impl Default for PyStabilityControl {
717    fn default() -> Self {
718        Self::new()
719    }
720}
721
722// ---------------------------------------------------------------------------
723// PySuspension
724// ---------------------------------------------------------------------------
725
726/// Suspension geometry type.
727#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
728pub enum SuspensionKind {
729    /// MacPherson strut (front).
730    MacPherson,
731    /// Double wishbone.
732    DoubleWishbone,
733    /// Multi-link.
734    MultiLink,
735    /// Simple spring-damper (used for rear or simplified models).
736    SpringDamper,
737}
738
739/// One corner suspension.
740#[derive(Debug, Clone, Serialize, Deserialize)]
741pub struct PySuspension {
742    /// Suspension geometry type.
743    pub kind: SuspensionKind,
744    /// Spring rate (N/m).
745    pub spring_rate: f64,
746    /// Damper coefficient in compression (N·s/m).
747    pub damper_compression: f64,
748    /// Damper coefficient in rebound (N·s/m).
749    pub damper_rebound: f64,
750    /// Anti-roll bar stiffness (N·m/rad).
751    pub arb_stiffness: f64,
752    /// Bump stop spring rate (N/m), activated near max compression.
753    pub bump_stop_rate: f64,
754    /// Bump stop activation distance from maximum compression (m).
755    pub bump_stop_clearance: f64,
756    /// Current displacement from rest (m, positive = compression).
757    pub displacement: f64,
758    /// Current displacement velocity (m/s).
759    pub velocity: f64,
760    /// Maximum compression travel (m).
761    pub max_compression: f64,
762    /// Maximum droop travel (m).
763    pub max_droop: f64,
764}
765
766impl PySuspension {
767    /// Create a default double wishbone suspension.
768    pub fn double_wishbone() -> Self {
769        Self {
770            kind: SuspensionKind::DoubleWishbone,
771            spring_rate: 30000.0,
772            damper_compression: 2000.0,
773            damper_rebound: 3000.0,
774            arb_stiffness: 5000.0,
775            bump_stop_rate: 100000.0,
776            bump_stop_clearance: 0.02,
777            displacement: 0.0,
778            velocity: 0.0,
779            max_compression: 0.08,
780            max_droop: 0.06,
781        }
782    }
783
784    /// Create a MacPherson strut suspension.
785    pub fn macpherson() -> Self {
786        Self {
787            kind: SuspensionKind::MacPherson,
788            spring_rate: 25000.0,
789            damper_compression: 1500.0,
790            damper_rebound: 2500.0,
791            arb_stiffness: 3000.0,
792            bump_stop_rate: 80000.0,
793            bump_stop_clearance: 0.02,
794            displacement: 0.0,
795            velocity: 0.0,
796            max_compression: 0.07,
797            max_droop: 0.05,
798        }
799    }
800
801    /// Compute the suspension force given displacement and velocity.
802    pub fn force(&self) -> f64 {
803        let spring_force = -self.spring_rate * self.displacement;
804        let damper_force = if self.velocity > 0.0 {
805            -self.damper_compression * self.velocity
806        } else {
807            -self.damper_rebound * self.velocity
808        };
809        let bump_force = if self.displacement > self.max_compression - self.bump_stop_clearance {
810            let excess = self.displacement - (self.max_compression - self.bump_stop_clearance);
811            -self.bump_stop_rate * excess
812        } else {
813            0.0
814        };
815        spring_force + damper_force + bump_force
816    }
817
818    /// Step the suspension dynamics.
819    pub fn step(&mut self, external_displacement: f64, dt: f64) {
820        let prev = self.displacement;
821        self.displacement = external_displacement.clamp(-self.max_droop, self.max_compression);
822        self.velocity = (self.displacement - prev) / dt.max(1e-9);
823    }
824}
825
826// ---------------------------------------------------------------------------
827// PyWheelDynamics
828// ---------------------------------------------------------------------------
829
830/// State and forces at a single wheel.
831#[derive(Debug, Clone, Serialize, Deserialize)]
832pub struct PyWheelDynamics {
833    /// Wheel angular velocity (rad/s).
834    pub omega: f64,
835    /// Wheel moment of inertia (kg·m²).
836    pub inertia: f64,
837    /// Effective rolling radius (m).
838    pub radius: f64,
839    /// Current slip ratio.
840    pub slip_ratio: f64,
841    /// Current slip angle (rad).
842    pub slip_angle: f64,
843    /// Camber angle (rad, positive = lean toward vehicle center).
844    pub camber: f64,
845    /// Toe angle (rad).
846    pub toe: f64,
847    /// Longitudinal force at the contact patch (N).
848    pub fx: f64,
849    /// Lateral force at the contact patch (N).
850    pub fy: f64,
851    /// Normal load (N).
852    pub fz: f64,
853    /// Whether the wheel is on the ground.
854    pub grounded: bool,
855}
856
857impl PyWheelDynamics {
858    /// Create a new wheel with given inertia and radius.
859    pub fn new(inertia: f64, radius: f64) -> Self {
860        Self {
861            omega: 0.0,
862            inertia,
863            radius,
864            slip_ratio: 0.0,
865            slip_angle: 0.0,
866            camber: 0.0,
867            toe: 0.0,
868            fx: 0.0,
869            fy: 0.0,
870            fz: 0.0,
871            grounded: false,
872        }
873    }
874
875    /// Update wheel angular velocity given applied torque and time step.
876    pub fn step_omega(&mut self, drive_torque: f64, brake_torque: f64, dt: f64) {
877        // Net torque = drive - brake - fx * radius (reaction from road)
878        let road_reaction = -self.fx * self.radius;
879        let net_torque = drive_torque - brake_torque + road_reaction;
880        self.omega += net_torque / self.inertia * dt;
881        if !self.grounded {
882            // No road reaction off ground, just dampen
883            self.omega *= (1.0 - 0.1 * dt).max(0.0);
884        }
885    }
886
887    /// Peripheral speed of the tire.
888    pub fn peripheral_speed(&self) -> f64 {
889        self.omega * self.radius
890    }
891}
892
893// ---------------------------------------------------------------------------
894// PyVehicle
895// ---------------------------------------------------------------------------
896
897/// Full vehicle state.
898#[derive(Debug, Clone, Serialize, Deserialize)]
899pub struct PyVehicle {
900    /// Chassis mass (kg).
901    pub mass: f64,
902    /// Inertia tensor diagonal \[Ixx, Iyy, Izz\] (kg·m²).
903    pub inertia: [f64; 3],
904    /// Center of mass height (m).
905    pub cog_height: f64,
906    /// World position of chassis center \[x, y, z\].
907    pub position: [f64; 3],
908    /// Velocity \[vx, vy, vz\] in world frame.
909    pub velocity: [f64; 3],
910    /// Euler angles \[roll, pitch, yaw\] (rad).
911    pub orientation: [f64; 3],
912    /// Angular velocity \[wx, wy, wz\] (rad/s).
913    pub angular_velocity: [f64; 3],
914    /// Wheels: \[FL, FR, RL, RR\].
915    pub wheels: [PyWheelDynamics; 4],
916    /// Wheel positions in local frame \[FL, FR, RL, RR\].
917    pub wheel_positions: [[f64; 3]; 4],
918    /// Suspensions \[FL, FR, RL, RR\].
919    pub suspensions: [PySuspension; 4],
920    /// Tire models \[FL, FR, RL, RR\].
921    pub tires: [PyTireModel; 4],
922    /// Drivetrain.
923    pub drivetrain: PyDrivetrain,
924    /// Steering system.
925    pub steering: PySteering,
926    /// Stability control.
927    pub stability: PyStabilityControl,
928    /// Current gear as display number (1-indexed).
929    pub gear_display: u32,
930}
931
932impl PyVehicle {
933    /// Create a default sedan-like vehicle.
934    pub fn sedan() -> Self {
935        let wheel = PyWheelDynamics::new(1.5, 0.32);
936        let suspension = PySuspension::macpherson();
937        let tire = PyTireModel::road_pacejka(0.32);
938        Self {
939            mass: 1500.0,
940            inertia: [2000.0, 2500.0, 3000.0],
941            cog_height: 0.5,
942            position: [0.0; 3],
943            velocity: [0.0; 3],
944            orientation: [0.0; 3],
945            angular_velocity: [0.0; 3],
946            wheels: [wheel.clone(), wheel.clone(), wheel.clone(), wheel.clone()],
947            wheel_positions: [
948                [-0.75, -0.5, 1.3],
949                [0.75, -0.5, 1.3],
950                [-0.75, -0.5, -1.2],
951                [0.75, -0.5, -1.2],
952            ],
953            suspensions: [
954                suspension.clone(),
955                suspension.clone(),
956                suspension.clone(),
957                suspension.clone(),
958            ],
959            tires: [tire.clone(), tire.clone(), tire.clone(), tire.clone()],
960            drivetrain: PyDrivetrain::six_speed(300.0),
961            steering: PySteering::new(2.6, 1.5),
962            stability: PyStabilityControl::new(),
963            gear_display: 1,
964        }
965    }
966
967    /// Create a racing car vehicle.
968    pub fn racing_car() -> Self {
969        let wheel = PyWheelDynamics::new(1.0, 0.30);
970        let suspension = PySuspension::double_wishbone();
971        let tire = PyTireModel::slick_pacejka(0.30);
972        Self {
973            mass: 700.0,
974            inertia: [800.0, 1000.0, 1200.0],
975            cog_height: 0.3,
976            position: [0.0; 3],
977            velocity: [0.0; 3],
978            orientation: [0.0; 3],
979            angular_velocity: [0.0; 3],
980            wheels: [wheel.clone(), wheel.clone(), wheel.clone(), wheel.clone()],
981            wheel_positions: [
982                [-0.7, -0.4, 1.6],
983                [0.7, -0.4, 1.6],
984                [-0.7, -0.4, -1.6],
985                [0.7, -0.4, -1.6],
986            ],
987            suspensions: [
988                suspension.clone(),
989                suspension.clone(),
990                suspension.clone(),
991                suspension.clone(),
992            ],
993            tires: [tire.clone(), tire.clone(), tire.clone(), tire.clone()],
994            drivetrain: PyDrivetrain::six_speed(500.0),
995            steering: PySteering::new(2.8, 1.4),
996            stability: PyStabilityControl::new(),
997            gear_display: 1,
998        }
999    }
1000
1001    /// Get the forward speed (m/s).
1002    pub fn forward_speed(&self) -> f64 {
1003        // Assume vehicle heading is along local X axis; yaw rotates it
1004        let yaw = self.orientation[2];
1005        self.velocity[0] * yaw.cos() + self.velocity[2] * yaw.sin()
1006    }
1007
1008    /// Perform one simulation step.
1009    ///
1010    /// `dt`       — time step (s)
1011    /// `throttle` — throttle input \[0, 1\]
1012    /// `brake`    — brake input \[0, 1\]
1013    /// `steer`    — steering input \[-1, 1\]
1014    pub fn step(&mut self, dt: f64, throttle: f64, brake: f64, steer: f64) {
1015        // Update steering
1016        self.steering.input = steer.clamp(-1.0, 1.0);
1017        let front_angle = self.steering.front_angle();
1018
1019        // Compute slip ratios (simplified)
1020        let v = self.forward_speed().abs().max(0.01);
1021        let wheel_speeds: [f64; 4] = core::array::from_fn(|i| self.wheels[i].peripheral_speed());
1022        let slip_ratios: Vec<f64> = wheel_speeds
1023            .iter()
1024            .map(|&ws| compute_slip_ratio(ws, v))
1025            .collect();
1026
1027        // Stability control
1028        let desired_yaw = v / 2.6 * front_angle;
1029        let actual_yaw = self.angular_velocity[1];
1030        let (throttle_scale, brake_scale) =
1031            self.stability.update(&slip_ratios, desired_yaw, actual_yaw);
1032        let effective_throttle = throttle * throttle_scale;
1033        let effective_brake = brake * brake_scale;
1034
1035        // Drivetrain
1036        let drive_torque_total = self.drivetrain.wheel_torque(effective_throttle);
1037        let drive_torque_per_wheel = drive_torque_total / 2.0; // simplified rear-wheel drive
1038
1039        // Brake torque
1040        let max_brake_torque = 2000.0;
1041        let brake_torque = effective_brake * max_brake_torque;
1042
1043        // Update rear wheels with drive torque
1044        for i in [2, 3] {
1045            let slip_angle = self.wheels[i].slip_angle;
1046            let kappa = self.wheels[i].slip_ratio;
1047            let fz = self.mass * 9.81 * 0.25; // equal load distribution
1048            let (fx, fy) = self.tires[i].compute_forces(slip_angle, kappa, fz);
1049            self.wheels[i].fx = fx;
1050            self.wheels[i].fy = fy;
1051            self.wheels[i].fz = fz;
1052            self.wheels[i].grounded = true;
1053            self.wheels[i].step_omega(drive_torque_per_wheel, brake_torque, dt);
1054        }
1055        // Update front wheels with steering
1056        for i in [0, 1] {
1057            self.wheels[i].slip_angle = -front_angle;
1058            let fz = self.mass * 9.81 * 0.25;
1059            let (fx, fy) = self.tires[i].compute_forces(self.wheels[i].slip_angle, 0.0, fz);
1060            self.wheels[i].fx = fx;
1061            self.wheels[i].fy = fy;
1062            self.wheels[i].fz = fz;
1063            self.wheels[i].grounded = true;
1064            self.wheels[i].step_omega(0.0, brake_torque, dt);
1065        }
1066
1067        // Total forces on chassis (simplified)
1068        let total_fx: f64 = self.wheels.iter().map(|w| w.fx).sum();
1069        let total_fy: f64 = self.wheels.iter().map(|w| w.fy).sum();
1070
1071        // Integrate velocity / position (Euler)
1072        let ax = total_fx / self.mass;
1073        let az = total_fy / self.mass - 9.81 * self.orientation[0].sin();
1074        self.velocity[0] += ax * dt;
1075        self.velocity[2] += az * dt;
1076
1077        // Drag
1078        let drag = 0.3 * self.velocity[0] * self.velocity[0].abs();
1079        self.velocity[0] -= drag / self.mass * dt;
1080
1081        self.position[0] += self.velocity[0] * dt;
1082        self.position[1] += self.velocity[1] * dt;
1083        self.position[2] += self.velocity[2] * dt;
1084
1085        // Update engine RPM
1086        let avg_rear_omega = (self.wheels[2].omega + self.wheels[3].omega) * 0.5;
1087        self.drivetrain.update_rpm(avg_rear_omega);
1088        self.gear_display = (self.drivetrain.current_gear + 1) as u32;
1089    }
1090}
1091
1092// ---------------------------------------------------------------------------
1093// PyTelemetry
1094// ---------------------------------------------------------------------------
1095
1096/// A single telemetry sample.
1097#[derive(Debug, Clone, Serialize, Deserialize)]
1098pub struct TelemetrySample {
1099    /// Simulation time (s).
1100    pub time: f64,
1101    /// Vehicle speed (m/s).
1102    pub speed: f64,
1103    /// Longitudinal G-force.
1104    pub g_lon: f64,
1105    /// Lateral G-force.
1106    pub g_lat: f64,
1107    /// Slip angles \[FL, FR, RL, RR\] (rad).
1108    pub slip_angles: [f64; 4],
1109    /// Tire temperatures \[FL, FR, RL, RR\] (°C, surface).
1110    pub tire_temps: [f64; 4],
1111    /// Current gear.
1112    pub gear: u32,
1113    /// Engine RPM.
1114    pub rpm: f64,
1115    /// Throttle (0-1).
1116    pub throttle: f64,
1117    /// Brake (0-1).
1118    pub brake: f64,
1119    /// Steering angle (rad).
1120    pub steer: f64,
1121}
1122
1123/// Lap statistics derived from telemetry.
1124#[derive(Debug, Clone, Serialize, Deserialize)]
1125pub struct LapStats {
1126    /// Lap time (s).
1127    pub lap_time: f64,
1128    /// Maximum speed (m/s).
1129    pub max_speed: f64,
1130    /// Average speed (m/s).
1131    pub avg_speed: f64,
1132    /// Maximum lateral G.
1133    pub max_g_lat: f64,
1134    /// Maximum longitudinal G (braking).
1135    pub max_g_lon_brake: f64,
1136    /// Maximum longitudinal G (acceleration).
1137    pub max_g_lon_accel: f64,
1138    /// Number of samples.
1139    pub sample_count: usize,
1140}
1141
1142/// Vehicle telemetry recorder.
1143#[derive(Debug, Clone, Serialize, Deserialize)]
1144pub struct PyTelemetry {
1145    /// Recorded samples.
1146    pub samples: Vec<TelemetrySample>,
1147    /// Maximum number of samples to retain.
1148    pub max_samples: usize,
1149    /// Whether recording is active.
1150    pub recording: bool,
1151}
1152
1153impl PyTelemetry {
1154    /// Create a new telemetry recorder.
1155    pub fn new(max_samples: usize) -> Self {
1156        Self {
1157            samples: Vec::new(),
1158            max_samples,
1159            recording: true,
1160        }
1161    }
1162
1163    /// Record a sample.
1164    pub fn record(&mut self, sample: TelemetrySample) {
1165        if !self.recording {
1166            return;
1167        }
1168        if self.samples.len() >= self.max_samples {
1169            self.samples.remove(0);
1170        }
1171        self.samples.push(sample);
1172    }
1173
1174    /// Compute lap statistics from all recorded samples.
1175    pub fn lap_stats(&self) -> Option<LapStats> {
1176        if self.samples.is_empty() {
1177            return None;
1178        }
1179        let n = self.samples.len();
1180        let total_time = self
1181            .samples
1182            .last()
1183            .expect("collection should not be empty")
1184            .time
1185            - self.samples[0].time;
1186        let max_speed = self.samples.iter().map(|s| s.speed).fold(0.0_f64, f64::max);
1187        let avg_speed = self.samples.iter().map(|s| s.speed).sum::<f64>() / n as f64;
1188        let max_g_lat = self
1189            .samples
1190            .iter()
1191            .map(|s| s.g_lat.abs())
1192            .fold(0.0_f64, f64::max);
1193        let max_g_lon_brake = self
1194            .samples
1195            .iter()
1196            .map(|s| (-s.g_lon).max(0.0))
1197            .fold(0.0_f64, f64::max);
1198        let max_g_lon_accel = self
1199            .samples
1200            .iter()
1201            .map(|s| s.g_lon.max(0.0))
1202            .fold(0.0_f64, f64::max);
1203        Some(LapStats {
1204            lap_time: total_time,
1205            max_speed,
1206            avg_speed,
1207            max_g_lat,
1208            max_g_lon_brake,
1209            max_g_lon_accel,
1210            sample_count: n,
1211        })
1212    }
1213
1214    /// Clear all samples.
1215    pub fn clear(&mut self) {
1216        self.samples.clear();
1217    }
1218}
1219
1220// ---------------------------------------------------------------------------
1221// PyRacingLine
1222// ---------------------------------------------------------------------------
1223
1224/// A 2D point on the track.
1225#[derive(Debug, Clone, Serialize, Deserialize)]
1226pub struct TrackPoint {
1227    /// X coordinate (m).
1228    pub x: f64,
1229    /// Y coordinate (m).
1230    pub y: f64,
1231    /// Track half-width at this point (m).
1232    pub half_width: f64,
1233}
1234
1235impl TrackPoint {
1236    /// Create a new track point.
1237    pub fn new(x: f64, y: f64, half_width: f64) -> Self {
1238        Self { x, y, half_width }
1239    }
1240}
1241
1242/// Racing line optimizer and analyzer.
1243#[derive(Debug, Clone, Serialize, Deserialize)]
1244pub struct PyRacingLine {
1245    /// Track centerline points.
1246    pub centerline: Vec<TrackPoint>,
1247    /// Optimized racing line (same count as centerline).
1248    pub racing_line: Vec<[f64; 2]>,
1249    /// Sector boundaries (indices into centerline).
1250    pub sector_boundaries: Vec<usize>,
1251    /// Sector times (s) from the last lap simulation.
1252    pub sector_times: Vec<f64>,
1253    /// Total track length (m).
1254    pub track_length: f64,
1255}
1256
1257impl PyRacingLine {
1258    /// Create a racing line optimizer from a centerline.
1259    pub fn new(centerline: Vec<TrackPoint>) -> Self {
1260        let n = centerline.len();
1261        // Initialize racing line to centerline
1262        let racing_line = centerline.iter().map(|p| [p.x, p.y]).collect();
1263        let track_length = Self::compute_length(&centerline);
1264        Self {
1265            centerline,
1266            racing_line,
1267            sector_boundaries: if n >= 3 {
1268                vec![0, n / 3, 2 * n / 3, n - 1]
1269            } else {
1270                vec![0, n.max(1) - 1]
1271            },
1272            sector_times: Vec::new(),
1273            track_length,
1274        }
1275    }
1276
1277    fn compute_length(pts: &[TrackPoint]) -> f64 {
1278        let mut len = 0.0;
1279        for i in 1..pts.len() {
1280            let dx = pts[i].x - pts[i - 1].x;
1281            let dy = pts[i].y - pts[i - 1].y;
1282            len += (dx * dx + dy * dy).sqrt();
1283        }
1284        len
1285    }
1286
1287    /// Compute local curvature at point i (using finite differences).
1288    pub fn curvature_at(&self, i: usize) -> f64 {
1289        let n = self.racing_line.len();
1290        if n < 3 || i == 0 || i >= n - 1 {
1291            return 0.0;
1292        }
1293        let p0 = self.racing_line[i - 1];
1294        let p1 = self.racing_line[i];
1295        let p2 = self.racing_line[i + 1];
1296        // Menger curvature
1297        let a = ((p1[0] - p0[0]).powi(2) + (p1[1] - p0[1]).powi(2)).sqrt();
1298        let b = ((p2[0] - p1[0]).powi(2) + (p2[1] - p1[1]).powi(2)).sqrt();
1299        let c = ((p2[0] - p0[0]).powi(2) + (p2[1] - p0[1]).powi(2)).sqrt();
1300        let area2 = ((p1[0] - p0[0]) * (p2[1] - p0[1]) - (p1[1] - p0[1]) * (p2[0] - p0[0])).abs();
1301        if a * b * c < 1e-12 {
1302            0.0
1303        } else {
1304            area2 / (a * b * c)
1305        }
1306    }
1307
1308    /// Run one step of minimum-curvature optimization (gradient descent on curvature).
1309    pub fn optimize_step(&mut self, alpha: f64) {
1310        let n = self.racing_line.len();
1311        if n < 3 {
1312            return;
1313        }
1314        let mut new_line = self.racing_line.clone();
1315        for i in 1..n - 1 {
1316            let p0 = self.racing_line[i - 1];
1317            let p1 = self.racing_line[i];
1318            let p2 = self.racing_line[i + 1];
1319            // Move toward midpoint of neighbors (smoothing)
1320            let mid = [(p0[0] + p2[0]) * 0.5, (p0[1] + p2[1]) * 0.5];
1321            let new_x = p1[0] + alpha * (mid[0] - p1[0]);
1322            let new_y = p1[1] + alpha * (mid[1] - p1[1]);
1323            // Clamp to track width
1324            let cl = &self.centerline[i];
1325            let dx = new_x - cl.x;
1326            let dy = new_y - cl.y;
1327            let dist = (dx * dx + dy * dy).sqrt();
1328            if dist <= cl.half_width {
1329                new_line[i] = [new_x, new_y];
1330            } else if dist > 1e-12 {
1331                new_line[i] = [
1332                    cl.x + dx / dist * cl.half_width,
1333                    cl.y + dy / dist * cl.half_width,
1334                ];
1335            }
1336        }
1337        self.racing_line = new_line;
1338    }
1339
1340    /// Run n_iter optimization iterations.
1341    pub fn optimize(&mut self, n_iter: u32, alpha: f64) {
1342        for _ in 0..n_iter {
1343            self.optimize_step(alpha);
1344        }
1345    }
1346
1347    /// Compute the total curvature of the current racing line.
1348    pub fn total_curvature(&self) -> f64 {
1349        (1..self.racing_line.len() - 1)
1350            .map(|i| self.curvature_at(i))
1351            .sum()
1352    }
1353}
1354
1355// ---------------------------------------------------------------------------
1356// Tests
1357// ---------------------------------------------------------------------------
1358
1359#[cfg(test)]
1360mod tests {
1361    use super::*;
1362
1363    #[test]
1364    fn test_pacejka_fy_zero_slip() {
1365        let fy = pacejka_fy(0.0, 10.0, 1.9, 1.0, 0.97);
1366        assert!(fy.abs() < 1e-9);
1367    }
1368
1369    #[test]
1370    fn test_pacejka_fy_nonzero() {
1371        let fy = pacejka_fy(0.1, 10.0, 1.9, 1.0, 0.97);
1372        assert!(fy.abs() > 0.0);
1373    }
1374
1375    #[test]
1376    fn test_pacejka_fx_peak() {
1377        let fx = pacejka_fx(0.2, 11.0, 1.65, 1.0, 0.0);
1378        assert!(fx.abs() > 0.0);
1379    }
1380
1381    #[test]
1382    fn test_compute_slip_angle() {
1383        let alpha = compute_slip_angle(1.0, 10.0);
1384        assert!(alpha.abs() < 0.2);
1385    }
1386
1387    #[test]
1388    fn test_compute_slip_ratio_no_slip() {
1389        let kappa = compute_slip_ratio(10.0, 10.0);
1390        assert!(kappa.abs() < 1e-9);
1391    }
1392
1393    #[test]
1394    fn test_compute_slip_ratio_drive_slip() {
1395        let kappa = compute_slip_ratio(12.0, 10.0);
1396        assert!(kappa > 0.0);
1397    }
1398
1399    #[test]
1400    fn test_ackermann_straight() {
1401        let (a, b) = ackermann_angles(0.0, 2.5, 1.5);
1402        assert!(a.abs() < 1e-9);
1403        assert!(b.abs() < 1e-9);
1404    }
1405
1406    #[test]
1407    fn test_ackermann_turn() {
1408        let (inner, outer) = ackermann_angles(0.3, 2.5, 1.5);
1409        assert!(inner.abs() > outer.abs());
1410    }
1411
1412    #[test]
1413    fn test_tire_model_forces() {
1414        let tm = PyTireModel::road_pacejka(0.32);
1415        let (fx, fy) = tm.compute_forces(0.1, 0.05, 3000.0);
1416        let _ = fx;
1417        assert!(fy.abs() > 0.0);
1418    }
1419
1420    #[test]
1421    fn test_tire_thermal_update() {
1422        let mut tt = PyTireThermal::new();
1423        let grip0 = tt.grip_scale();
1424        tt.update(5000.0, 10.0);
1425        assert!(tt.surface_temp > 25.0);
1426        // Check grip scale is valid
1427        assert!(tt.grip_scale() >= 0.0 && tt.grip_scale() <= 1.0);
1428        let _ = grip0;
1429    }
1430
1431    #[test]
1432    fn test_tire_thermal_optimal() {
1433        let mut tt = PyTireThermal::new();
1434        tt.surface_temp = 90.0;
1435        assert!((tt.grip_scale() - 1.0).abs() < 1e-9);
1436    }
1437
1438    #[test]
1439    fn test_engine_curve_flat() {
1440        let ec = EngineCurve::flat(300.0, 7000.0);
1441        let t = ec.torque_at(3500.0);
1442        assert!(t > 0.0);
1443        assert!(t <= 300.0 * 1.01);
1444    }
1445
1446    #[test]
1447    fn test_drivetrain_upshift() {
1448        let mut dt = PyDrivetrain::six_speed(300.0);
1449        dt.upshift();
1450        assert_eq!(dt.current_gear, 2);
1451    }
1452
1453    #[test]
1454    fn test_drivetrain_downshift() {
1455        let mut dt = PyDrivetrain::six_speed(300.0);
1456        dt.downshift();
1457        assert_eq!(dt.current_gear, 0);
1458    }
1459
1460    #[test]
1461    fn test_drivetrain_wheel_torque() {
1462        let dt = PyDrivetrain::six_speed(300.0);
1463        let t = dt.wheel_torque(1.0);
1464        assert!(t > 0.0);
1465    }
1466
1467    #[test]
1468    fn test_steering_angles() {
1469        let mut steer = PySteering::new(2.6, 1.5);
1470        steer.input = 1.0;
1471        let fa = steer.front_angle();
1472        assert!(fa > 0.0);
1473        assert!(fa <= steer.max_wheel_angle);
1474    }
1475
1476    #[test]
1477    fn test_stability_tcs() {
1478        let mut sc = PyStabilityControl::new();
1479        let (ts, _) = sc.update(&[0.5, 0.5, 0.5, 0.5], 0.0, 0.0);
1480        assert!(ts < 1.0);
1481    }
1482
1483    #[test]
1484    fn test_stability_disabled() {
1485        let mut sc = PyStabilityControl::new();
1486        sc.enabled = false;
1487        let (ts, bs) = sc.update(&[2.0, 2.0, 2.0, 2.0], 0.0, 0.0);
1488        assert!((ts - 1.0).abs() < 1e-9);
1489        assert!((bs - 1.0).abs() < 1e-9);
1490    }
1491
1492    #[test]
1493    fn test_suspension_force() {
1494        let mut s = PySuspension::double_wishbone();
1495        s.step(0.03, 0.01);
1496        let f = s.force();
1497        assert!(f < 0.0); // spring pushes back
1498    }
1499
1500    #[test]
1501    fn test_suspension_bump_stop() {
1502        let mut s = PySuspension::double_wishbone();
1503        s.step(s.max_compression, 0.01);
1504        let f = s.force();
1505        assert!(f < 0.0);
1506    }
1507
1508    #[test]
1509    fn test_wheel_dynamics_step() {
1510        let mut w = PyWheelDynamics::new(1.5, 0.32);
1511        w.fx = 100.0;
1512        w.grounded = true;
1513        w.step_omega(200.0, 0.0, 0.01);
1514        assert!(w.omega > 0.0);
1515    }
1516
1517    #[test]
1518    fn test_vehicle_sedan_step() {
1519        let mut v = PyVehicle::sedan();
1520        v.step(0.016, 0.5, 0.0, 0.0);
1521        // Vehicle should have moved or velocity changed
1522        let speed = v.forward_speed();
1523        let _ = speed;
1524        assert!(v.drivetrain.engine_rpm > 0.0);
1525    }
1526
1527    #[test]
1528    fn test_vehicle_racing_step() {
1529        let mut v = PyVehicle::racing_car();
1530        v.step(0.016, 1.0, 0.0, 0.1);
1531        assert!(v.gear_display >= 1);
1532    }
1533
1534    #[test]
1535    fn test_telemetry_record() {
1536        let mut tel = PyTelemetry::new(100);
1537        let sample = TelemetrySample {
1538            time: 0.0,
1539            speed: 50.0,
1540            g_lon: 0.5,
1541            g_lat: 0.3,
1542            slip_angles: [0.0; 4],
1543            tire_temps: [80.0; 4],
1544            gear: 3,
1545            rpm: 4000.0,
1546            throttle: 0.8,
1547            brake: 0.0,
1548            steer: 0.1,
1549        };
1550        tel.record(sample);
1551        assert_eq!(tel.samples.len(), 1);
1552    }
1553
1554    #[test]
1555    fn test_telemetry_lap_stats() {
1556        let mut tel = PyTelemetry::new(100);
1557        for i in 0..10 {
1558            let sample = TelemetrySample {
1559                time: i as f64 * 0.1,
1560                speed: 50.0 + i as f64,
1561                g_lon: 0.1,
1562                g_lat: 0.2,
1563                slip_angles: [0.0; 4],
1564                tire_temps: [80.0; 4],
1565                gear: 3,
1566                rpm: 4000.0,
1567                throttle: 0.8,
1568                brake: 0.0,
1569                steer: 0.0,
1570            };
1571            tel.record(sample);
1572        }
1573        let stats = tel.lap_stats().unwrap();
1574        assert!(stats.max_speed > 50.0);
1575        assert!(stats.sample_count == 10);
1576    }
1577
1578    #[test]
1579    fn test_racing_line_curvature() {
1580        let pts: Vec<TrackPoint> = (0..10)
1581            .map(|i| TrackPoint::new(i as f64, 0.0, 3.0))
1582            .collect();
1583        let rl = PyRacingLine::new(pts);
1584        // Straight line should have near-zero curvature
1585        let k = rl.curvature_at(5);
1586        assert!(k < 1e-9);
1587    }
1588
1589    #[test]
1590    fn test_racing_line_optimize() {
1591        let pts: Vec<TrackPoint> = (0..20)
1592            .map(|i| {
1593                let t = i as f64 / 20.0 * 2.0 * std::f64::consts::PI;
1594                TrackPoint::new(t.cos() * 10.0, t.sin() * 10.0, 2.0)
1595            })
1596            .collect();
1597        let mut rl = PyRacingLine::new(pts);
1598        let k0 = rl.total_curvature();
1599        rl.optimize(10, 0.1);
1600        let k1 = rl.total_curvature();
1601        // After optimization curvature should not increase dramatically
1602        assert!(k1 <= k0 * 1.5 + 0.1);
1603    }
1604
1605    #[test]
1606    fn test_fiala_tire_linear() {
1607        let fp = FialaParams::new(50000.0, 1.0);
1608        let fy = fp.lateral_force(0.05, 4000.0);
1609        let expected = -50000.0 * 0.05;
1610        assert!((fy - expected).abs() < 1.0);
1611    }
1612
1613    #[test]
1614    fn test_fiala_tire_saturated() {
1615        let fp = FialaParams::new(50000.0, 1.0);
1616        let fy = fp.lateral_force(1.0, 4000.0);
1617        assert!(fy.abs() <= 4000.0 + 1.0);
1618    }
1619}