1pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct OptimizationConfig {
21 pub total_laps: u16,
23
24 pub circuit: Circuit,
26
27 pub available_compounds: Vec<TireCompound>,
29
30 pub pit_lane_time_loss: f32,
32
33 pub tire_change_time: f32,
35
36 pub current_position: u8,
38
39 pub competitors_ahead: Vec<CompetitorState>,
41
42 pub degradation_factors: DegradationFactors,
44
45 pub fuel_model: FuelConsumptionModel,
47
48 pub starting_fuel: f32,
50
51 pub min_pit_stops: u8,
53
54 pub max_pit_stops: u8,
56}
57
58#[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#[derive(Debug, Clone)]
71#[allow(dead_code)]
72struct DPState {
73 best_time: f32,
75
76 pit_stops: Vec<PitStop>,
78
79 num_stops: u8,
81
82 last_compound: TireCompound,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct PitWindow {
89 pub earliest_lap: u16,
91
92 pub latest_lap: u16,
94
95 pub optimal_start: u16,
97
98 pub optimal_end: u16,
100
101 pub constraints: Vec<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct StrategyComparison {
108 pub strategy_a: RaceStrategy,
109 pub strategy_b: RaceStrategy,
110
111 pub time_delta: f32,
113
114 pub risk_delta: f32,
116
117 pub breakdown: ComparisonBreakdown,
119}
120
121#[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
131pub fn optimize_pit_strategy(config: &OptimizationConfig) -> Result<RaceStrategy, String> {
140 validate_config(config)?;
142
143 let mut dp: HashMap<(u16, u8, TireCompound), DPState> = HashMap::new();
145
146 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 for lap in 1..=config.total_laps {
157 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 if lap < config.total_laps {
165 let tire_age = calculate_tire_age(lap, ¤t_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 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 if new_compound != compound {
196 let pit_loss = estimate_time_loss(config, lap);
197 let tire_age = calculate_tire_age(lap, ¤t_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 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 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
285pub 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 let adjusted_life = (tire_chars.typical_life as f32
296 / config.degradation_factors.total_multiplier()) as u16;
297
298 let earliest_lap = current_lap + (adjusted_life as f32 * 0.7) as u16;
300
301 let latest_lap = current_lap + (adjusted_life as f32 * 0.95) as u16;
303
304 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 if track_severity > 1.2 {
312 constraints.push("High tire degradation track".to_string());
313 }
314
315 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
336pub fn estimate_time_loss(config: &OptimizationConfig, lap: u16) -> f32 {
338 let base_loss = config.pit_lane_time_loss + config.tire_change_time;
340
341 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; let position_penalty = if config.current_position <= 3 {
348 1.5 } else if config.current_position <= 10 {
350 1.0 } else {
352 0.5 };
354
355 base_loss * fuel_factor + position_penalty
356}
357
358pub 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 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 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
399pub 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; }
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
453fn 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 const GRIP_WEIGHT: f32 = 0.40;
466 const DEGRADATION_WEIGHT: f32 = 0.35;
467 const THERMAL_WEIGHT: f32 = 0.25;
468
469 let grip_demand = circuit.characteristics.tire_severity.min(2.0) * 0.5; let grip_score = calculate_grip_score(tire_chars.grip_level, grip_demand);
473
474 let degradation_score = calculate_degradation_score(
476 compound,
477 target_stint_length,
478 degradation_factors,
479 fuel_load,
480 );
481
482 let thermal_score = calculate_thermal_score(compound, track_temp);
484
485 (grip_score * GRIP_WEIGHT) + (degradation_score * DEGRADATION_WEIGHT) + (thermal_score * THERMAL_WEIGHT)
487}
488
489fn calculate_grip_score(compound_grip: f32, track_demand: f32) -> f32 {
491 let diff = (compound_grip - track_demand).abs();
493
494 if diff < 0.05 {
495 1.0 } else if diff < 0.15 {
497 0.8 - (diff - 0.05) * 2.0 } else if diff < 0.25 {
499 0.6 - (diff - 0.15) * 2.0 } else {
501 0.3 }
503}
504
505fn 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 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; let effective_life = base_life / (deg_multiplier * fuel_impact);
520 let target = target_stint_length as f32;
521
522 if effective_life >= target * 1.2 {
524 1.0 } else if effective_life >= target {
526 0.8 } else if effective_life >= target * 0.85 {
528 0.5 } else {
530 0.2 }
532}
533
534fn 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; let optimal_range = 5.0; let good_range = 15.0; let temp_diff = (track_temp - optimal_temp).abs();
545
546 if temp_diff <= optimal_range {
547 1.0 } else if temp_diff <= good_range {
549 let normalized = (temp_diff - optimal_range) / (good_range - optimal_range);
550 1.0 - (normalized * 0.4) } else if temp_diff <= good_range * 2.0 {
552 let normalized = (temp_diff - good_range) / good_range;
553 0.6 - (normalized * 0.4) } else {
555 0.1 }
557}
558
559fn 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 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 let base_time = config.circuit.lap_record * 1.03; 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; 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; let grip_bonus = (tire_chars.grip_level - 0.75) * 2.0; 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 PitStopReason::Undercut
642 } else if lap > (config.total_laps * 2) / 3 {
643 PitStopReason::TireDegradation
645 } else {
646 PitStopReason::Opportunistic
647 }
648}
649
650fn is_valid_strategy(pit_stops: &[PitStop], config: &OptimizationConfig) -> bool {
651 if pit_stops.len() < config.min_pit_stops as usize {
653 return false;
654 }
655
656 let mut compounds: Vec<TireCompound> = pit_stops.iter().map(|ps| ps.compound).collect();
658 compounds.push(config.available_compounds[0]); compounds.sort();
660 compounds.dedup();
661
662 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 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 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 += strategy.num_pit_stops() as f32 * 0.1;
712
713 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; }
719 }
720
721 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 let total_fuel_needed = config.fuel_model.fuel_needed_for_laps(
752 config.total_laps,
753 config.starting_fuel,
754 );
755
756 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 for pit_stop in &strategy.pit_stops {
765 if pit_stop.reason == PitStopReason::Undercut {
766 opportunities += 1;
767 }
768 }
769
770 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 assert!(strategy.num_pit_stops() >= 1);
815
816 assert!(strategy.is_valid(config.total_laps));
818
819 assert!(strategy.predicted_race_time > 0.0);
821 assert!(strategy.predicted_race_time < 10000.0); }
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 let early_loss = estimate_time_loss(&config, 10);
842
843 let late_loss = estimate_time_loss(&config, 40);
845
846 assert!(early_loss > 0.0);
848 assert!(late_loss > 0.0);
849
850 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); assert!(comparison.breakdown.pit_loss_difference == 0.0); }
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); assert_eq!(calculate_tire_age(25, &pit_stops), 5); assert_eq!(calculate_tire_age(50, &pit_stops), 30); }
950
951 #[test]
952 fn test_strategy_validation() {
953 let config = create_test_config();
954
955 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 assert!(!is_valid_strategy(&[], &config));
969 }
970
971 #[test]
972 fn test_lap_time_calculation() {
973 let config = create_test_config();
974
975 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 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); }
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 let compound = select_optimal_compound(
1002 &circuit,
1003 &compounds,
1004 40.0, 100.0, 20, °radation,
1008 );
1009
1010 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 let compound = select_optimal_compound(
1028 &circuit,
1029 &compounds,
1030 15.0, 50.0, 15, °radation,
1034 );
1035
1036 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, temperature_factor: 1.0,
1048 driving_style_factor: 1.0,
1049 fuel_load_factor: 1.0,
1050 downforce_factor: 1.0,
1051 };
1052
1053 let compound = select_optimal_compound(
1055 &circuit,
1056 &compounds,
1057 25.0, 110.0, 30, °radation,
1061 );
1062
1063 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 let compound = select_optimal_compound(
1081 &circuit,
1082 &compounds,
1083 25.0,
1084 80.0,
1085 20,
1086 °radation,
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, num_turns: 15,
1100 lap_record: 80.0,
1101 characteristics: TrackCharacteristics {
1102 tire_severity: 1.8, 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 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 assert!(score_c5 >= 0.0 && score_c5 <= 1.0);
1137 assert!(score_c1 >= 0.0 && score_c1 <= 1.0);
1138 }
1139}
1140