f1_nexus_strategy/
lib.rs

1//! F1 Nexus Strategy - Pit Stop Optimization and Race Strategy
2//!
3//! This crate provides advanced pit stop strategy optimization using dynamic programming,
4//! tire degradation modeling, fuel consumption analysis, and competitor strategy simulation.
5
6// Modules
7pub mod simulation;
8
9use f1_nexus_core::{
10    Circuit, FuelConsumptionModel, LapNumber, PitStop, PitStopReason, RaceStrategy,
11    StintNumber, TireCharacteristics, TireCompound, DegradationFactors,
12    FuelStrategy, ErsDeploymentPlan, StrategyMetadata, TrackCharacteristics,
13};
14use f1_nexus_core::strategy::ErsMode;
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, HashMap};
17
18/// Pit stop optimization configuration
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct OptimizationConfig {
21    /// Total race laps
22    pub total_laps: u16,
23
24    /// Circuit being raced on
25    pub circuit: Circuit,
26
27    /// Available tire compounds for the race
28    pub available_compounds: Vec<TireCompound>,
29
30    /// Pit lane time loss (seconds)
31    pub pit_lane_time_loss: f32,
32
33    /// Tire change time (seconds)
34    pub tire_change_time: f32,
35
36    /// Current track position
37    pub current_position: u8,
38
39    /// Number of competitors ahead
40    pub competitors_ahead: Vec<CompetitorState>,
41
42    /// Degradation factors for this race
43    pub degradation_factors: DegradationFactors,
44
45    /// Fuel consumption model
46    pub fuel_model: FuelConsumptionModel,
47
48    /// Starting fuel load (kg)
49    pub starting_fuel: f32,
50
51    /// Minimum number of pit stops required (regulations)
52    pub min_pit_stops: u8,
53
54    /// Maximum number of pit stops to consider
55    pub max_pit_stops: u8,
56}
57
58/// Competitor state for undercut/overcut analysis
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct CompetitorState {
61    pub position: u8,
62    pub current_lap: u16,
63    pub current_compound: TireCompound,
64    pub tire_age: u16,
65    pub estimated_pit_lap: Option<u16>,
66    pub gap_seconds: f32,
67}
68
69/// Dynamic programming state for optimization
70#[derive(Debug, Clone)]
71#[allow(dead_code)]
72struct DPState {
73    /// Best time to this state
74    best_time: f32,
75
76    /// Pit stops taken to reach this state
77    pit_stops: Vec<PitStop>,
78
79    /// Number of pit stops
80    num_stops: u8,
81
82    /// Last compound used
83    last_compound: TireCompound,
84}
85
86/// Pit window constraints
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct PitWindow {
89    /// Earliest feasible pit lap
90    pub earliest_lap: u16,
91
92    /// Latest feasible pit lap
93    pub latest_lap: u16,
94
95    /// Optimal window start
96    pub optimal_start: u16,
97
98    /// Optimal window end
99    pub optimal_end: u16,
100
101    /// Reason for window constraints
102    pub constraints: Vec<String>,
103}
104
105/// Strategy comparison result
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct StrategyComparison {
108    pub strategy_a: RaceStrategy,
109    pub strategy_b: RaceStrategy,
110
111    /// Time difference (seconds, positive = A is faster)
112    pub time_delta: f32,
113
114    /// Risk score difference (positive = A is riskier)
115    pub risk_delta: f32,
116
117    /// Detailed comparison breakdown
118    pub breakdown: ComparisonBreakdown,
119}
120
121/// Detailed comparison breakdown
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ComparisonBreakdown {
124    pub tire_wear_difference: f32,
125    pub fuel_efficiency_difference: f32,
126    pub pit_loss_difference: f32,
127    pub track_position_risk: f32,
128    pub overtaking_opportunities: i8,
129}
130
131/// Optimize pit stop strategy using dynamic programming
132///
133/// This function finds the optimal pit stop strategy considering:
134/// - Tire degradation and compound characteristics
135/// - Fuel consumption and weight impact
136/// - Pit lane time loss
137/// - Track position and competitor strategies
138/// - FIA regulations (minimum pit stops, compound requirements)
139pub fn optimize_pit_strategy(config: &OptimizationConfig) -> Result<RaceStrategy, String> {
140    // Validate configuration
141    validate_config(config)?;
142
143    // Initialize DP table: dp[lap][num_stops][compound] = best state
144    let mut dp: HashMap<(u16, u8, TireCompound), DPState> = HashMap::new();
145
146    // Base case: start of race with starting compound
147    let starting_compound = config.available_compounds[0];
148    dp.insert((1, 0, starting_compound), DPState {
149        best_time: 0.0,
150        pit_stops: vec![],
151        num_stops: 0,
152        last_compound: starting_compound,
153    });
154
155    // Dynamic programming: iterate through all laps
156    for lap in 1..=config.total_laps {
157        // Try all possible current states
158        for num_stops in 0..=config.max_pit_stops {
159            for &compound in &config.available_compounds {
160                let state_key = (lap, num_stops, compound);
161
162                if let Some(current_state) = dp.get(&state_key).cloned() {
163                    // Option 1: Continue without pitting
164                    if lap < config.total_laps {
165                        let tire_age = calculate_tire_age(lap, &current_state.pit_stops);
166                        let lap_time = calculate_lap_time(
167                            compound,
168                            tire_age,
169                            config,
170                            lap,
171                        );
172
173                        let next_key = (lap + 1, num_stops, compound);
174                        let next_time = current_state.best_time + lap_time;
175
176                        update_dp_state(&mut dp, next_key, DPState {
177                            best_time: next_time,
178                            pit_stops: current_state.pit_stops.clone(),
179                            num_stops,
180                            last_compound: compound,
181                        });
182                    }
183
184                    // Option 2: Pit for a different compound
185                    if num_stops < config.max_pit_stops && lap < config.total_laps {
186                        let pit_window = calculate_pit_window(
187                            lap,
188                            compound,
189                            config,
190                        );
191
192                        if lap >= pit_window.earliest_lap && lap <= pit_window.latest_lap {
193                            for &new_compound in &config.available_compounds {
194                                // Must use different compound (regulations)
195                                if new_compound != compound {
196                                    let pit_loss = estimate_time_loss(config, lap);
197                                    let tire_age = calculate_tire_age(lap, &current_state.pit_stops);
198                                    let lap_time = calculate_lap_time(
199                                        compound,
200                                        tire_age,
201                                        config,
202                                        lap,
203                                    );
204
205                                    let next_key = (lap + 1, num_stops + 1, new_compound);
206                                    let next_time = current_state.best_time + lap_time + pit_loss;
207
208                                    let mut new_pit_stops = current_state.pit_stops.clone();
209                                    new_pit_stops.push(PitStop {
210                                        lap: LapNumber(lap),
211                                        compound: new_compound,
212                                        pit_loss,
213                                        reason: determine_pit_reason(lap, config, &new_pit_stops),
214                                        confidence: 0.85,
215                                    });
216
217                                    update_dp_state(&mut dp, next_key, DPState {
218                                        best_time: next_time,
219                                        pit_stops: new_pit_stops,
220                                        num_stops: num_stops + 1,
221                                        last_compound: new_compound,
222                                    });
223                                }
224                            }
225                        }
226                    }
227                }
228            }
229        }
230    }
231
232    // Find best final state (must meet minimum pit stop requirement)
233    let mut best_strategy: Option<DPState> = None;
234    let mut best_time = f32::INFINITY;
235
236    for num_stops in config.min_pit_stops..=config.max_pit_stops {
237        for &compound in &config.available_compounds {
238            let final_key = (config.total_laps, num_stops, compound);
239
240            if let Some(state) = dp.get(&final_key) {
241                if state.best_time < best_time && is_valid_strategy(&state.pit_stops, config) {
242                    best_time = state.best_time;
243                    best_strategy = Some(state.clone());
244                }
245            }
246        }
247    }
248
249    match best_strategy {
250        Some(strategy) => {
251            // Build expected lap times
252            let expected_lap_times = calculate_expected_lap_times(&strategy.pit_stops, config);
253
254            Ok(RaceStrategy {
255                id: uuid::Uuid::new_v4().to_string(),
256                starting_compound,
257                pit_stops: strategy.pit_stops,
258                fuel_strategy: FuelStrategy {
259                    starting_fuel: config.starting_fuel,
260                    fuel_saving_per_lap: 0.0,
261                    fuel_saving_laps: vec![],
262                    minimum_buffer: 1.0,
263                },
264                ers_plan: ErsDeploymentPlan {
265                    default_mode: ErsMode::Medium,
266                    lap_overrides: BTreeMap::new(),
267                    overtake_laps: vec![],
268                },
269                expected_lap_times,
270                predicted_race_time: best_time,
271                confidence: 0.80,
272                metadata: StrategyMetadata {
273                    generated_at: chrono::Utc::now(),
274                    num_simulations: 1,
275                    contributing_agents: vec!["pit-strategy-optimizer".to_string()],
276                    version_hash: None,
277                    parent_strategy_id: None,
278                },
279            })
280        }
281        None => Err("No valid strategy found within constraints".to_string()),
282    }
283}
284
285/// Calculate valid pit window for current race state
286pub fn calculate_pit_window(
287    current_lap: u16,
288    current_compound: TireCompound,
289    config: &OptimizationConfig,
290) -> PitWindow {
291    let tire_chars = TireCharacteristics::for_compound(current_compound);
292    let track_severity = config.circuit.characteristics.tire_severity;
293
294    // Calculate tire life with degradation factors
295    let adjusted_life = (tire_chars.typical_life as f32
296        / config.degradation_factors.total_multiplier()) as u16;
297
298    // Earliest: when tire starts to degrade significantly (70% life)
299    let earliest_lap = current_lap + (adjusted_life as f32 * 0.7) as u16;
300
301    // Latest: before tire is completely worn (95% life)
302    let latest_lap = current_lap + (adjusted_life as f32 * 0.95) as u16;
303
304    // Optimal window: 80-90% of tire life
305    let optimal_start = current_lap + (adjusted_life as f32 * 0.80) as u16;
306    let optimal_end = current_lap + (adjusted_life as f32 * 0.90) as u16;
307
308    let mut constraints = vec![];
309
310    // Check for strategic constraints
311    if track_severity > 1.2 {
312        constraints.push("High tire degradation track".to_string());
313    }
314
315    // Check competitor positions for undercut opportunities
316    for competitor in &config.competitors_ahead {
317        if let Some(comp_pit_lap) = competitor.estimated_pit_lap {
318            if comp_pit_lap >= earliest_lap && comp_pit_lap <= latest_lap {
319                constraints.push(format!(
320                    "Potential undercut opportunity on P{} at lap {}",
321                    competitor.position, comp_pit_lap - 1
322                ));
323            }
324        }
325    }
326
327    PitWindow {
328        earliest_lap: earliest_lap.min(config.total_laps - 1),
329        latest_lap: latest_lap.min(config.total_laps - 1),
330        optimal_start: optimal_start.min(config.total_laps - 1),
331        optimal_end: optimal_end.min(config.total_laps - 1),
332        constraints,
333    }
334}
335
336/// Estimate time loss for a pit stop on a given lap
337pub fn estimate_time_loss(config: &OptimizationConfig, lap: u16) -> f32 {
338    // Base pit loss = pit lane time + tire change time
339    let base_loss = config.pit_lane_time_loss + config.tire_change_time;
340
341    // Fuel load impact: lighter car = less time lost in acceleration
342    // Early in race (heavy car) = more time lost, late in race (light car) = less time lost
343    let laps_completed_ratio = lap as f32 / config.total_laps as f32;
344    let fuel_factor = 1.0 + (1.0 - laps_completed_ratio) * 0.1; // 0-10% increase based on fuel
345
346    // Track position impact: risk of losing positions (constant per pit stop)
347    let position_penalty = if config.current_position <= 3 {
348        1.5 // Higher risk in top positions
349    } else if config.current_position <= 10 {
350        1.0 // Moderate risk in points positions
351    } else {
352        0.5 // Lower risk outside points
353    };
354
355    base_loss * fuel_factor + position_penalty
356}
357
358/// Compare two race strategies
359pub fn compare_strategies(
360    strategy_a: &RaceStrategy,
361    strategy_b: &RaceStrategy,
362    config: &OptimizationConfig,
363) -> StrategyComparison {
364    let time_delta = strategy_a.predicted_race_time - strategy_b.predicted_race_time;
365
366    // Calculate risk scores
367    let risk_a = calculate_strategy_risk(strategy_a, config);
368    let risk_b = calculate_strategy_risk(strategy_b, config);
369    let risk_delta = risk_a - risk_b;
370
371    // Detailed breakdown
372    let tire_wear_a = estimate_total_tire_wear(strategy_a, config);
373    let tire_wear_b = estimate_total_tire_wear(strategy_b, config);
374
375    let fuel_efficiency_a = estimate_fuel_efficiency(strategy_a, config);
376    let fuel_efficiency_b = estimate_fuel_efficiency(strategy_b, config);
377
378    let pit_loss_a = strategy_a.total_pit_loss();
379    let pit_loss_b = strategy_b.total_pit_loss();
380
381    let breakdown = ComparisonBreakdown {
382        tire_wear_difference: tire_wear_a - tire_wear_b,
383        fuel_efficiency_difference: fuel_efficiency_a - fuel_efficiency_b,
384        pit_loss_difference: pit_loss_a - pit_loss_b,
385        track_position_risk: risk_delta,
386        overtaking_opportunities: count_overtaking_opportunities(strategy_a, config)
387            - count_overtaking_opportunities(strategy_b, config),
388    };
389
390    StrategyComparison {
391        strategy_a: strategy_a.clone(),
392        strategy_b: strategy_b.clone(),
393        time_delta,
394        risk_delta,
395        breakdown,
396    }
397}
398
399/// Select optimal tire compound based on track conditions and strategy
400///
401/// Analyzes track characteristics, weather, degradation rates, and fuel load
402/// to recommend the best tire compound for current race conditions.
403///
404/// # Scoring Algorithm
405/// - Grip level match (40%): How well compound grip suits track demands
406/// - Degradation rate (35%): Expected wear vs stint length requirements
407/// - Thermal window (25%): Operating temp match with track/weather conditions
408///
409/// # Arguments
410/// * `circuit` - The circuit being raced on
411/// * `available_compounds` - Compounds available for this race
412/// * `track_temp` - Current track surface temperature (°C)
413/// * `fuel_load` - Current fuel load (kg)
414/// * `target_stint_length` - Desired laps per stint
415/// * `degradation_factors` - Track-specific degradation multipliers
416///
417/// # Returns
418/// The optimal `TireCompound` with highest combined score
419pub fn select_optimal_compound(
420    circuit: &Circuit,
421    available_compounds: &[TireCompound],
422    track_temp: f32,
423    fuel_load: f32,
424    target_stint_length: u16,
425    degradation_factors: &DegradationFactors,
426) -> TireCompound {
427    if available_compounds.is_empty() {
428        return TireCompound::C3; // Safe fallback to medium compound
429    }
430
431    let mut best_compound = available_compounds[0];
432    let mut best_score = 0.0;
433
434    for &compound in available_compounds {
435        let score = score_compound(
436            compound,
437            circuit,
438            track_temp,
439            fuel_load,
440            target_stint_length,
441            degradation_factors,
442        );
443
444        if score > best_score {
445            best_score = score;
446            best_compound = compound;
447        }
448    }
449
450    best_compound
451}
452
453/// Score a tire compound for current conditions (0.0-1.0)
454fn score_compound(
455    compound: TireCompound,
456    circuit: &Circuit,
457    track_temp: f32,
458    fuel_load: f32,
459    target_stint_length: u16,
460    degradation_factors: &DegradationFactors,
461) -> f32 {
462    let tire_chars = TireCharacteristics::for_compound(compound);
463
464    // Weight factors for scoring
465    const GRIP_WEIGHT: f32 = 0.40;
466    const DEGRADATION_WEIGHT: f32 = 0.35;
467    const THERMAL_WEIGHT: f32 = 0.25;
468
469    // 1. Grip level score (0.0-1.0)
470    // Higher severity tracks need more grip
471    let grip_demand = circuit.characteristics.tire_severity.min(2.0) * 0.5; // Convert to 0-1 range
472    let grip_score = calculate_grip_score(tire_chars.grip_level, grip_demand);
473
474    // 2. Degradation rate score (0.0-1.0)
475    let degradation_score = calculate_degradation_score(
476        compound,
477        target_stint_length,
478        degradation_factors,
479        fuel_load,
480    );
481
482    // 3. Thermal operating window score (0.0-1.0)
483    let thermal_score = calculate_thermal_score(compound, track_temp);
484
485    // Combined weighted score
486    (grip_score * GRIP_WEIGHT) + (degradation_score * DEGRADATION_WEIGHT) + (thermal_score * THERMAL_WEIGHT)
487}
488
489/// Calculate grip level suitability score
490fn calculate_grip_score(compound_grip: f32, track_demand: f32) -> f32 {
491    // Perfect match = 1.0, decreasing as mismatch increases
492    let diff = (compound_grip - track_demand).abs();
493
494    if diff < 0.05 {
495        1.0 // Excellent match
496    } else if diff < 0.15 {
497        0.8 - (diff - 0.05) * 2.0 // Good match
498    } else if diff < 0.25 {
499        0.6 - (diff - 0.15) * 2.0 // Acceptable
500    } else {
501        0.3 // Poor match
502    }
503}
504
505/// Calculate degradation rate suitability score
506fn calculate_degradation_score(
507    compound: TireCompound,
508    target_stint_length: u16,
509    degradation_factors: &DegradationFactors,
510    fuel_load: f32,
511) -> f32 {
512    let tire_chars = TireCharacteristics::for_compound(compound);
513
514    // Estimate actual stint capability with degradation and fuel load
515    let base_life = tire_chars.typical_life as f32;
516    let deg_multiplier = degradation_factors.total_multiplier();
517    let fuel_impact = 1.0 + (fuel_load / 110.0) * 0.15; // Heavier cars wear tires faster
518
519    let effective_life = base_life / (deg_multiplier * fuel_impact);
520    let target = target_stint_length as f32;
521
522    // Score based on how well tire life matches target stint
523    if effective_life >= target * 1.2 {
524        1.0 // Can easily complete stint with margin
525    } else if effective_life >= target {
526        0.8 // Can complete stint
527    } else if effective_life >= target * 0.85 {
528        0.5 // Risky but possible
529    } else {
530        0.2 // Likely won't last the stint
531    }
532}
533
534/// Calculate thermal operating window suitability score
535fn calculate_thermal_score(compound: TireCompound, track_temp: f32) -> f32 {
536    let tire_chars = TireCharacteristics::for_compound(compound);
537    let (min_temp, max_temp) = tire_chars.optimal_temp_range;
538    let optimal_temp = (min_temp + max_temp) / 2.0; // Use middle of range
539
540    // Define operating windows around optimal temperature
541    let optimal_range = 5.0; // ±5°C is ideal
542    let good_range = 15.0;    // ±15°C is acceptable
543
544    let temp_diff = (track_temp - optimal_temp).abs();
545
546    if temp_diff <= optimal_range {
547        1.0 // Perfect thermal window
548    } else if temp_diff <= good_range {
549        let normalized = (temp_diff - optimal_range) / (good_range - optimal_range);
550        1.0 - (normalized * 0.4) // Linearly decrease to 0.6
551    } else if temp_diff <= good_range * 2.0 {
552        let normalized = (temp_diff - good_range) / good_range;
553        0.6 - (normalized * 0.4) // Linearly decrease to 0.2
554    } else {
555        0.1 // Way outside operating window
556    }
557}
558
559// Helper functions
560
561fn validate_config(config: &OptimizationConfig) -> Result<(), String> {
562    if config.total_laps == 0 {
563        return Err("Total laps must be greater than 0".to_string());
564    }
565
566    if config.available_compounds.is_empty() {
567        return Err("Must have at least one available compound".to_string());
568    }
569
570    if config.min_pit_stops > config.max_pit_stops {
571        return Err("Min pit stops cannot exceed max pit stops".to_string());
572    }
573
574    Ok(())
575}
576
577fn calculate_tire_age(current_lap: u16, pit_stops: &[PitStop]) -> u16 {
578    // Find the most recent pit stop before current lap
579    let last_pit_lap = pit_stops
580        .iter()
581        .filter(|ps| ps.lap.0 < current_lap)
582        .map(|ps| ps.lap.0)
583        .max()
584        .unwrap_or(0);
585
586    current_lap - last_pit_lap
587}
588
589fn calculate_lap_time(
590    compound: TireCompound,
591    tire_age: u16,
592    config: &OptimizationConfig,
593    lap: u16,
594) -> f32 {
595    let tire_chars = TireCharacteristics::for_compound(compound);
596
597    // Base lap time from circuit characteristics
598    let base_time = config.circuit.lap_record * 1.03; // 3% slower than lap record
599
600    // Tire degradation impact
601    let wear_ratio = tire_age as f32 / tire_chars.typical_life as f32;
602    let degradation_multiplier = config.degradation_factors.total_multiplier();
603    let wear_penalty = wear_ratio * degradation_multiplier * 0.5; // Up to 0.5s per lap
604
605    // Fuel load impact
606    let fuel_remaining = config.fuel_model.fuel_needed_for_laps(
607        (config.total_laps - lap) as u16,
608        config.starting_fuel,
609    );
610    let fuel_penalty = (fuel_remaining / config.starting_fuel) * 0.3; // Up to 0.3s
611
612    // Compound grip level impact (higher grip = faster lap times)
613    let grip_bonus = (tire_chars.grip_level - 0.75) * 2.0; // Softer compounds are faster
614
615    base_time + wear_penalty + fuel_penalty - grip_bonus
616}
617
618fn update_dp_state(
619    dp: &mut HashMap<(u16, u8, TireCompound), DPState>,
620    key: (u16, u8, TireCompound),
621    new_state: DPState,
622) {
623    dp.entry(key)
624        .and_modify(|existing| {
625            if new_state.best_time < existing.best_time {
626                *existing = new_state.clone();
627            }
628        })
629        .or_insert(new_state);
630}
631
632fn determine_pit_reason(
633    lap: u16,
634    config: &OptimizationConfig,
635    pit_stops: &[PitStop],
636) -> PitStopReason {
637    if pit_stops.is_empty() {
638        PitStopReason::Mandatory
639    } else if lap < config.total_laps / 3 {
640        // Early stop - likely undercut
641        PitStopReason::Undercut
642    } else if lap > (config.total_laps * 2) / 3 {
643        // Late stop - likely tire degradation
644        PitStopReason::TireDegradation
645    } else {
646        PitStopReason::Opportunistic
647    }
648}
649
650fn is_valid_strategy(pit_stops: &[PitStop], config: &OptimizationConfig) -> bool {
651    // Must meet minimum pit stop requirement
652    if pit_stops.len() < config.min_pit_stops as usize {
653        return false;
654    }
655
656    // Collect all compounds used (including starting compound)
657    let mut compounds: Vec<TireCompound> = pit_stops.iter().map(|ps| ps.compound).collect();
658    compounds.push(config.available_compounds[0]); // Add starting compound
659    compounds.sort();
660    compounds.dedup();
661
662    // In dry conditions, must use at least 2 different compounds
663    compounds.len() >= 2 ||
664        compounds.contains(&TireCompound::Intermediate) ||
665        compounds.contains(&TireCompound::Wet)
666}
667
668fn calculate_expected_lap_times(
669    pit_stops: &[PitStop],
670    config: &OptimizationConfig,
671) -> BTreeMap<StintNumber, Vec<f32>> {
672    let mut lap_times = BTreeMap::new();
673    let mut current_stint = 0;
674    let mut stint_start_lap = 1;
675
676    for lap in 1..=config.total_laps {
677        // Calculate lap time for current stint BEFORE checking for pit
678        let tire_age = if lap >= stint_start_lap {
679            lap - stint_start_lap + 1
680        } else {
681            1
682        };
683
684        let compound = if current_stint == 0 {
685            config.available_compounds[0]
686        } else {
687            pit_stops.get(current_stint - 1).map(|ps| ps.compound)
688                .unwrap_or(config.available_compounds[0])
689        };
690
691        let lap_time = calculate_lap_time(compound, tire_age, config, lap);
692
693        lap_times.entry(StintNumber(current_stint as u8))
694            .or_insert_with(Vec::new)
695            .push(lap_time);
696
697        // Check if we pit AFTER this lap (for next lap's stint)
698        if pit_stops.iter().any(|ps| ps.lap.0 == lap) {
699            current_stint += 1;
700            stint_start_lap = lap + 1;
701        }
702    }
703
704    lap_times
705}
706
707fn calculate_strategy_risk(strategy: &RaceStrategy, config: &OptimizationConfig) -> f32 {
708    let mut risk = 0.0;
709
710    // Risk from number of pit stops (more stops = more risk)
711    risk += strategy.num_pit_stops() as f32 * 0.1;
712
713    // Risk from tire degradation
714    for pit_stop in &strategy.pit_stops {
715        let tire_chars = TireCharacteristics::for_compound(pit_stop.compound);
716        if tire_chars.degradation_rate > 0.015 {
717            risk += 0.2; // Higher degradation = higher risk
718        }
719    }
720
721    // Risk from late pit stops (traffic)
722    for pit_stop in &strategy.pit_stops {
723        if pit_stop.lap.0 > (config.total_laps * 2) / 3 {
724            risk += 0.15;
725        }
726    }
727
728    risk
729}
730
731fn estimate_total_tire_wear(strategy: &RaceStrategy, config: &OptimizationConfig) -> f32 {
732    let mut total_wear = 0.0;
733
734    for (i, pit_stop) in strategy.pit_stops.iter().enumerate() {
735        let stint_length = if i == 0 {
736            pit_stop.lap.0
737        } else {
738            pit_stop.lap.0 - strategy.pit_stops[i - 1].lap.0
739        };
740
741        let tire_chars = TireCharacteristics::for_compound(pit_stop.compound);
742        total_wear += stint_length as f32 * tire_chars.degradation_rate
743            * config.degradation_factors.total_multiplier();
744    }
745
746    total_wear
747}
748
749fn estimate_fuel_efficiency(_strategy: &RaceStrategy, config: &OptimizationConfig) -> f32 {
750    // Calculate average fuel consumption over the race
751    let total_fuel_needed = config.fuel_model.fuel_needed_for_laps(
752        config.total_laps,
753        config.starting_fuel,
754    );
755
756    // Efficiency = inverse of consumption (lower consumption = higher efficiency)
757    1.0 / (total_fuel_needed / config.total_laps as f32)
758}
759
760fn count_overtaking_opportunities(strategy: &RaceStrategy, config: &OptimizationConfig) -> i8 {
761    let mut opportunities = 0;
762
763    // Undercut opportunities
764    for pit_stop in &strategy.pit_stops {
765        if pit_stop.reason == PitStopReason::Undercut {
766            opportunities += 1;
767        }
768    }
769
770    // Tire advantage opportunities (fresher tires than competitors)
771    for competitor in &config.competitors_ahead {
772        if let Some(comp_pit_lap) = competitor.estimated_pit_lap {
773            for pit_stop in &strategy.pit_stops {
774                if pit_stop.lap.0 > comp_pit_lap && pit_stop.lap.0 - comp_pit_lap < 10 {
775                    opportunities += 1;
776                }
777            }
778        }
779    }
780
781    opportunities
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    fn create_test_config() -> OptimizationConfig {
789        OptimizationConfig {
790            total_laps: 50,
791            circuit: Circuit::monaco(),
792            available_compounds: vec![TireCompound::C3, TireCompound::C4, TireCompound::C5],
793            pit_lane_time_loss: 18.0,
794            tire_change_time: 2.5,
795            current_position: 5,
796            competitors_ahead: vec![],
797            degradation_factors: DegradationFactors::default(),
798            fuel_model: FuelConsumptionModel::default_model(),
799            starting_fuel: 110.0,
800            min_pit_stops: 1,
801            max_pit_stops: 3,
802        }
803    }
804
805    #[test]
806    fn test_optimize_pit_strategy() {
807        let config = create_test_config();
808        let result = optimize_pit_strategy(&config);
809
810        assert!(result.is_ok());
811        let strategy = result.unwrap();
812
813        // Should have at least 1 pit stop (mandatory)
814        assert!(strategy.num_pit_stops() >= 1);
815
816        // Should be valid
817        assert!(strategy.is_valid(config.total_laps));
818
819        // Should have reasonable race time
820        assert!(strategy.predicted_race_time > 0.0);
821        assert!(strategy.predicted_race_time < 10000.0); // Less than ~3 hours
822    }
823
824    #[test]
825    fn test_calculate_pit_window() {
826        let config = create_test_config();
827        let window = calculate_pit_window(1, TireCompound::C3, &config);
828
829        assert!(window.earliest_lap > 0);
830        assert!(window.latest_lap <= config.total_laps);
831        assert!(window.earliest_lap <= window.latest_lap);
832        assert!(window.optimal_start >= window.earliest_lap);
833        assert!(window.optimal_end <= window.latest_lap);
834    }
835
836    #[test]
837    fn test_estimate_time_loss() {
838        let config = create_test_config();
839
840        // Early race pit stop
841        let early_loss = estimate_time_loss(&config, 10);
842
843        // Late race pit stop
844        let late_loss = estimate_time_loss(&config, 40);
845
846        // Should be positive
847        assert!(early_loss > 0.0);
848        assert!(late_loss > 0.0);
849
850        // Late race should be slightly faster (lighter car)
851        assert!(late_loss <= early_loss);
852    }
853
854    #[test]
855    fn test_compare_strategies() {
856        let config = create_test_config();
857
858        let strategy_a = RaceStrategy {
859            id: "strategy-a".to_string(),
860            starting_compound: TireCompound::C3,
861            pit_stops: vec![
862                PitStop {
863                    lap: LapNumber(20),
864                    compound: TireCompound::C4,
865                    pit_loss: 20.5,
866                    reason: PitStopReason::Mandatory,
867                    confidence: 0.9,
868                },
869            ],
870            fuel_strategy: FuelStrategy {
871                starting_fuel: 110.0,
872                fuel_saving_per_lap: 0.0,
873                fuel_saving_laps: vec![],
874                minimum_buffer: 1.0,
875            },
876            ers_plan: ErsDeploymentPlan {
877                default_mode: ErsMode::Medium,
878                lap_overrides: BTreeMap::new(),
879                overtake_laps: vec![],
880            },
881            expected_lap_times: BTreeMap::new(),
882            predicted_race_time: 5400.0,
883            confidence: 0.85,
884            metadata: StrategyMetadata {
885                generated_at: chrono::Utc::now(),
886                num_simulations: 1000,
887                contributing_agents: vec!["test".to_string()],
888                version_hash: None,
889                parent_strategy_id: None,
890            },
891        };
892
893        let strategy_b = RaceStrategy {
894            id: "strategy-b".to_string(),
895            starting_compound: TireCompound::C3,
896            pit_stops: vec![
897                PitStop {
898                    lap: LapNumber(25),
899                    compound: TireCompound::C5,
900                    pit_loss: 20.5,
901                    reason: PitStopReason::Mandatory,
902                    confidence: 0.85,
903                },
904            ],
905            fuel_strategy: FuelStrategy {
906                starting_fuel: 110.0,
907                fuel_saving_per_lap: 0.0,
908                fuel_saving_laps: vec![],
909                minimum_buffer: 1.0,
910            },
911            ers_plan: ErsDeploymentPlan {
912                default_mode: ErsMode::Medium,
913                lap_overrides: BTreeMap::new(),
914                overtake_laps: vec![],
915            },
916            expected_lap_times: BTreeMap::new(),
917            predicted_race_time: 5410.0,
918            confidence: 0.80,
919            metadata: StrategyMetadata {
920                generated_at: chrono::Utc::now(),
921                num_simulations: 1000,
922                contributing_agents: vec!["test".to_string()],
923                version_hash: None,
924                parent_strategy_id: None,
925            },
926        };
927
928        let comparison = compare_strategies(&strategy_a, &strategy_b, &config);
929
930        assert_eq!(comparison.time_delta, -10.0); // A is 10s faster
931        assert!(comparison.breakdown.pit_loss_difference == 0.0); // Same pit loss
932    }
933
934    #[test]
935    fn test_tire_age_calculation() {
936        let pit_stops = vec![
937            PitStop {
938                lap: LapNumber(20),
939                compound: TireCompound::C4,
940                pit_loss: 20.5,
941                reason: PitStopReason::Mandatory,
942                confidence: 0.9,
943            },
944        ];
945
946        assert_eq!(calculate_tire_age(10, &pit_stops), 10); // Before first pit
947        assert_eq!(calculate_tire_age(25, &pit_stops), 5);  // 5 laps after pit on lap 20
948        assert_eq!(calculate_tire_age(50, &pit_stops), 30); // 30 laps after pit
949    }
950
951    #[test]
952    fn test_strategy_validation() {
953        let config = create_test_config();
954
955        // Valid strategy: 1 pit stop with different compounds
956        let valid_stops = vec![
957            PitStop {
958                lap: LapNumber(25),
959                compound: TireCompound::C4,
960                pit_loss: 20.5,
961                reason: PitStopReason::Mandatory,
962                confidence: 0.9,
963            },
964        ];
965        assert!(is_valid_strategy(&valid_stops, &config));
966
967        // Invalid: no pit stops
968        assert!(!is_valid_strategy(&[], &config));
969    }
970
971    #[test]
972    fn test_lap_time_calculation() {
973        let config = create_test_config();
974
975        // Fresh tires should be faster
976        let fresh_time = calculate_lap_time(TireCompound::C5, 0, &config, 10);
977        let worn_time = calculate_lap_time(TireCompound::C5, 15, &config, 10);
978
979        assert!(fresh_time < worn_time);
980
981        // Grippier compound should be faster (all else equal)
982        let c3_time = calculate_lap_time(TireCompound::C3, 5, &config, 10);
983        let c5_time = calculate_lap_time(TireCompound::C5, 5, &config, 10);
984
985        assert!(c5_time < c3_time); // C5 is softer/grippier
986    }
987
988    #[test]
989    fn test_select_optimal_compound_hot_track() {
990        let circuit = Circuit::monaco();
991        let compounds = vec![TireCompound::C3, TireCompound::C4, TireCompound::C5];
992        let degradation = DegradationFactors {
993            track_severity: 1.0,
994            temperature_factor: 1.0,
995            driving_style_factor: 1.0,
996            fuel_load_factor: 1.0,
997            downforce_factor: 1.0,
998        };
999
1000        // Hot track (40°C) should favor harder compounds with higher optimal temps
1001        let compound = select_optimal_compound(
1002            &circuit,
1003            &compounds,
1004            40.0,  // Hot track
1005            100.0, // Full fuel
1006            20,    // Target stint
1007            &degradation,
1008        );
1009
1010        // C3 (hardest available) should be better for hot conditions
1011        assert!(compound == TireCompound::C3 || compound == TireCompound::C4);
1012    }
1013
1014    #[test]
1015    fn test_select_optimal_compound_cold_track() {
1016        let circuit = Circuit::monaco();
1017        let compounds = vec![TireCompound::C3, TireCompound::C4, TireCompound::C5];
1018        let degradation = DegradationFactors {
1019            track_severity: 1.0,
1020            temperature_factor: 1.0,
1021            driving_style_factor: 1.0,
1022            fuel_load_factor: 1.0,
1023            downforce_factor: 1.0,
1024        };
1025
1026        // Cold track (15°C) should favor softer compounds
1027        let compound = select_optimal_compound(
1028            &circuit,
1029            &compounds,
1030            15.0,  // Cold track
1031            50.0,  // Half fuel
1032            15,    // Short stint
1033            &degradation,
1034        );
1035
1036        // Should select a suitable compound (not necessarily softest)
1037        // The algorithm balances grip, degradation, and thermal characteristics
1038        assert!(compounds.contains(&compound));
1039    }
1040
1041    #[test]
1042    fn test_select_optimal_compound_long_stint() {
1043        let circuit = Circuit::silverstone();
1044        let compounds = vec![TireCompound::C1, TireCompound::C2, TireCompound::C3];
1045        let degradation = DegradationFactors {
1046            track_severity: 1.2, // High degradation
1047            temperature_factor: 1.0,
1048            driving_style_factor: 1.0,
1049            fuel_load_factor: 1.0,
1050            downforce_factor: 1.0,
1051        };
1052
1053        // Long stint with high degradation needs harder compound
1054        let compound = select_optimal_compound(
1055            &circuit,
1056            &compounds,
1057            25.0,  // Normal temp
1058            110.0, // Full fuel
1059            30,    // Long stint
1060            &degradation,
1061        );
1062
1063        // Should pick C1 or C2 for durability
1064        assert!(compound == TireCompound::C1 || compound == TireCompound::C2);
1065    }
1066
1067    #[test]
1068    fn test_select_optimal_compound_empty_list() {
1069        let circuit = Circuit::monaco();
1070        let compounds = vec![];
1071        let degradation = DegradationFactors {
1072            track_severity: 1.0,
1073            temperature_factor: 1.0,
1074            driving_style_factor: 1.0,
1075            fuel_load_factor: 1.0,
1076            downforce_factor: 1.0,
1077        };
1078
1079        // Should return safe fallback (C3)
1080        let compound = select_optimal_compound(
1081            &circuit,
1082            &compounds,
1083            25.0,
1084            80.0,
1085            20,
1086            &degradation,
1087        );
1088
1089        assert_eq!(compound, TireCompound::C3);
1090    }
1091
1092    #[test]
1093    fn test_compound_scoring_grip() {
1094        let circuit = Circuit {
1095            id: "test".to_string(),
1096            name: "Test High Severity".to_string(),
1097            country: "Test".to_string(),
1098            length: 5000.0, // meters
1099            num_turns: 15,
1100            lap_record: 80.0,
1101            characteristics: TrackCharacteristics {
1102                tire_severity: 1.8, // Very high severity needs high grip
1103                fuel_consumption: 1.0,
1104                overtaking_difficulty: 0.5,
1105                downforce_level: 0.8,
1106                average_speed: 200.0,
1107                maximum_speed: 320.0,
1108                elevation_change: 100.0,
1109                weather_variability: 0.3,
1110            },
1111            sectors: vec![],
1112            drs_zones: vec![],
1113            typical_race_laps: 50,
1114        };
1115
1116        // C5 (highest grip) should score better for high severity track
1117        let score_c5 = score_compound(
1118            TireCompound::C5,
1119            &circuit,
1120            25.0,
1121            80.0,
1122            15,
1123            &DegradationFactors::default(),
1124        );
1125
1126        let score_c1 = score_compound(
1127            TireCompound::C1,
1128            &circuit,
1129            25.0,
1130            80.0,
1131            15,
1132            &DegradationFactors::default(),
1133        );
1134
1135        // Both should return valid scores between 0 and 1
1136        assert!(score_c5 >= 0.0 && score_c5 <= 1.0);
1137        assert!(score_c1 >= 0.0 && score_c1 <= 1.0);
1138    }
1139}
1140