Skip to main content

subtr_actor/stats/calculators/
speed_flip.rs

1use super::*;
2
3const SPEED_FLIP_MAX_START_AFTER_KICKOFF_SECONDS: f32 = 1.1;
4const SPEED_FLIP_EVALUATION_SECONDS: f32 = 0.32;
5const SPEED_FLIP_MAX_CANDIDATE_SECONDS: f32 = 0.55;
6const SPEED_FLIP_MAX_GROUND_Z: f32 = 80.0;
7const SPEED_FLIP_KICKOFF_MOTION_SPEED: f32 = 100.0;
8const SPEED_FLIP_MIN_ALIGNMENT: f32 = 0.72;
9const SPEED_FLIP_DODGE_ACCELERATION_SAMPLE_SECONDS: f32 = 0.18;
10const SPEED_FLIP_MIN_FORWARD_DODGE_DELTA: f32 = 80.0;
11const SPEED_FLIP_MIN_FORWARD_DODGE_DELTA_ALIGNMENT: f32 = 0.35;
12const SPEED_FLIP_MIN_CONFIDENCE: f32 = 0.45;
13const SPEED_FLIP_HIGH_CONFIDENCE: f32 = 0.75;
14
15#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
16#[ts(export)]
17pub struct SpeedFlipEvent {
18    pub time: f32,
19    pub frame: usize,
20    pub resolved_time: f32,
21    pub resolved_frame: usize,
22    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
23    pub player: PlayerId,
24    pub is_team_0: bool,
25    pub time_since_kickoff_start: f32,
26    pub start_position: [f32; 3],
27    pub end_position: [f32; 3],
28    pub start_speed: f32,
29    pub max_speed: f32,
30    pub best_alignment: f32,
31    pub diagonal_score: f32,
32    pub cancel_score: f32,
33    pub speed_score: f32,
34    pub confidence: f32,
35}
36
37#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
38#[ts(export)]
39pub struct SpeedFlipStats {
40    pub count: u32,
41    pub high_confidence_count: u32,
42    pub is_last_speed_flip: bool,
43    pub last_speed_flip_time: Option<f32>,
44    pub last_speed_flip_frame: Option<usize>,
45    pub time_since_last_speed_flip: Option<f32>,
46    pub frames_since_last_speed_flip: Option<usize>,
47    pub last_quality: Option<f32>,
48    pub best_quality: f32,
49    pub cumulative_quality: f32,
50    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
51    pub labeled_event_counts: LabeledCounts,
52}
53
54impl SpeedFlipStats {
55    pub fn average_quality(&self) -> f32 {
56        if self.count == 0 {
57            0.0
58        } else {
59            self.cumulative_quality / self.count as f32
60        }
61    }
62
63    fn record_event(&mut self, event: &SpeedFlipEvent) {
64        self.labeled_event_counts.increment([confidence_band_label(
65            event.confidence >= SPEED_FLIP_HIGH_CONFIDENCE,
66        )]);
67        self.sync_legacy_counts();
68        self.last_speed_flip_time = Some(event.time);
69        self.last_speed_flip_frame = Some(event.frame);
70        self.last_quality = Some(event.confidence);
71        self.best_quality = self.best_quality.max(event.confidence);
72        self.cumulative_quality += event.confidence;
73    }
74
75    pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
76        self.labeled_event_counts.count_matching(labels)
77    }
78
79    pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
80        LabeledCounts::complete_from_label_sets(
81            &[&CONFIDENCE_BAND_LABELS],
82            &self.labeled_event_counts,
83        )
84    }
85
86    fn sync_legacy_counts(&mut self) {
87        self.count = self.labeled_event_counts.total();
88        self.high_confidence_count = self.event_count_with_labels(&[confidence_band_label(true)]);
89    }
90}
91
92#[derive(Debug, Clone, PartialEq)]
93struct ActiveSpeedFlipCandidate {
94    is_team_0: bool,
95    is_kickoff: bool,
96    kickoff_start_time: Option<f32>,
97    start_time: f32,
98    start_frame: usize,
99    start_position: [f32; 3],
100    end_position: [f32; 3],
101    start_velocity_xy: glam::Vec2,
102    start_forward_xy: glam::Vec2,
103    start_speed: f32,
104    max_speed: f32,
105    best_alignment: f32,
106    best_boost_alignment: f32,
107    boost_alignment_sample_count: u32,
108    best_dodge_forward_delta: f32,
109    best_dodge_delta_alignment: f32,
110    dodge_acceleration_sample_count: u32,
111    best_diagonal_score: f32,
112    min_forward_z: f32,
113    latest_forward_z: f32,
114    latest_time: f32,
115    latest_frame: usize,
116}
117
118#[derive(Debug, Clone, Default, PartialEq)]
119pub struct SpeedFlipCalculator {
120    player_stats: HashMap<PlayerId, SpeedFlipStats>,
121    events: Vec<SpeedFlipEvent>,
122    active_candidates: HashMap<PlayerId, ActiveSpeedFlipCandidate>,
123    previous_dodge_active: HashMap<PlayerId, bool>,
124    kickoff_approach_active_last_frame: bool,
125    current_kickoff_start_time: Option<f32>,
126    current_last_speed_flip_player: Option<PlayerId>,
127}
128
129impl SpeedFlipCalculator {
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    pub fn player_stats(&self) -> &HashMap<PlayerId, SpeedFlipStats> {
135        &self.player_stats
136    }
137
138    pub fn events(&self) -> &[SpeedFlipEvent] {
139        &self.events
140    }
141
142    fn kickoff_approach_active(gameplay: &GameplayState) -> bool {
143        gameplay.ball_has_been_hit == Some(false)
144    }
145
146    fn player_by_id<'a>(
147        players: &'a PlayerFrameState,
148        player_id: &PlayerId,
149    ) -> Option<&'a PlayerSample> {
150        players
151            .players
152            .iter()
153            .find(|player| &player.player_id == player_id)
154    }
155
156    fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
157        if max_value <= min_value {
158            return 0.0;
159        }
160
161        ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
162    }
163
164    fn diagonal_score(local_angular_velocity: glam::Vec3) -> f32 {
165        let pitch_rate = local_angular_velocity.y.abs();
166        let side_spin = local_angular_velocity
167            .x
168            .abs()
169            .max(local_angular_velocity.z.abs());
170        if pitch_rate <= f32::EPSILON || side_spin <= f32::EPSILON {
171            return 0.0;
172        }
173
174        let pitch_score = Self::normalize_score(pitch_rate, 35.0, 180.0);
175        let side_score = Self::normalize_score(side_spin, 60.0, 260.0);
176        let balance = pitch_rate.min(side_spin) / pitch_rate.max(side_spin);
177        let balance_score = Self::normalize_score(balance, 0.18, 0.65);
178
179        (pitch_score * side_score).sqrt() * (0.75 + 0.25 * balance_score)
180    }
181
182    fn forward_speed_alignment(player: &PlayerSample) -> Option<f32> {
183        let velocity = player.velocity()?;
184        let rigid_body = player.rigid_body.as_ref()?;
185        let velocity_xy = velocity.truncate().normalize_or_zero();
186        if velocity_xy.length_squared() <= f32::EPSILON {
187            return None;
188        }
189
190        let forward_xy = (quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
191            .truncate()
192            .normalize_or_zero();
193        if forward_xy.length_squared() <= f32::EPSILON {
194            return None;
195        }
196
197        Some(forward_xy.dot(velocity_xy))
198    }
199
200    fn forward_xy(player: &PlayerSample) -> Option<glam::Vec2> {
201        let rigid_body = player.rigid_body.as_ref()?;
202        let forward_xy = (quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
203            .truncate()
204            .normalize_or_zero();
205        (forward_xy.length_squared() > f32::EPSILON).then_some(forward_xy)
206    }
207
208    fn boost_alignment(player: &PlayerSample) -> Option<f32> {
209        player
210            .boost_active
211            .then(|| Self::forward_speed_alignment(player))
212            .flatten()
213    }
214
215    fn candidate_alignment(
216        _ball: &BallFrameState,
217        player: &PlayerSample,
218        _is_kickoff: bool,
219    ) -> Option<f32> {
220        Self::forward_speed_alignment(player)
221    }
222
223    fn apply_event(&mut self, event: SpeedFlipEvent) {
224        for stats in self.player_stats.values_mut() {
225            stats.is_last_speed_flip = false;
226        }
227
228        let stats = self.player_stats.entry(event.player.clone()).or_default();
229        stats.record_event(&event);
230        stats.is_last_speed_flip = true;
231        stats.time_since_last_speed_flip = Some(0.0);
232        stats.frames_since_last_speed_flip = Some(0);
233
234        self.current_last_speed_flip_player = Some(event.player.clone());
235        self.events.push(event);
236    }
237
238    fn begin_sample(&mut self, frame: &FrameInfo) {
239        for stats in self.player_stats.values_mut() {
240            stats.is_last_speed_flip = false;
241            stats.time_since_last_speed_flip = stats
242                .last_speed_flip_time
243                .map(|time| (frame.time - time).max(0.0));
244            stats.frames_since_last_speed_flip = stats
245                .last_speed_flip_frame
246                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
247        }
248
249        if let Some(player_id) = self.current_last_speed_flip_player.as_ref() {
250            if let Some(stats) = self.player_stats.get_mut(player_id) {
251                stats.is_last_speed_flip = true;
252            }
253        }
254    }
255
256    fn reset_kickoff_state(&mut self) {
257        self.active_candidates.clear();
258        self.current_kickoff_start_time = None;
259    }
260
261    fn kickoff_motion_started(players: &PlayerFrameState) -> bool {
262        players.players.iter().any(|player| {
263            player.dodge_active
264                || player
265                    .speed()
266                    .is_some_and(|speed| speed >= SPEED_FLIP_KICKOFF_MOTION_SPEED)
267        })
268    }
269
270    fn update_kickoff_start_time(
271        &mut self,
272        frame: &FrameInfo,
273        kickoff_approach_active: bool,
274        players: &PlayerFrameState,
275    ) {
276        if !kickoff_approach_active {
277            self.current_kickoff_start_time = None;
278            return;
279        }
280
281        if self.current_kickoff_start_time.is_none() && Self::kickoff_motion_started(players) {
282            self.current_kickoff_start_time = Some(frame.time);
283        }
284    }
285
286    fn maybe_start_candidate(
287        &mut self,
288        frame: &FrameInfo,
289        gameplay: &GameplayState,
290        ball: &BallFrameState,
291        player: &PlayerSample,
292        _live_play: bool,
293    ) {
294        let was_dodge_active = self
295            .previous_dodge_active
296            .insert(player.player_id.clone(), player.dodge_active)
297            .unwrap_or(false);
298        if !player.dodge_active || was_dodge_active {
299            return;
300        }
301
302        let is_kickoff = Self::kickoff_approach_active(gameplay);
303        let kickoff_start_time = if is_kickoff {
304            let Some(kickoff_start_time) = self.current_kickoff_start_time else {
305                return;
306            };
307            if frame.time - kickoff_start_time > SPEED_FLIP_MAX_START_AFTER_KICKOFF_SECONDS {
308                return;
309            }
310            Some(kickoff_start_time)
311        } else {
312            None
313        };
314
315        let Some(rigid_body) = player.rigid_body.as_ref() else {
316            return;
317        };
318        let Some(player_position) = player.position() else {
319            return;
320        };
321        if player_position.z > SPEED_FLIP_MAX_GROUND_Z {
322            return;
323        }
324
325        let start_speed = player.speed().unwrap_or(0.0);
326
327        let Some(best_alignment) = Self::candidate_alignment(ball, player, is_kickoff) else {
328            return;
329        };
330        if best_alignment < SPEED_FLIP_MIN_ALIGNMENT {
331            return;
332        }
333        let Some(start_velocity_xy) = player.velocity().map(|velocity| velocity.truncate()) else {
334            return;
335        };
336        let Some(start_forward_xy) = Self::forward_xy(player) else {
337            return;
338        };
339
340        let rotation = quat_to_glam(&rigid_body.rotation);
341        let local_angular_velocity = rigid_body
342            .angular_velocity
343            .as_ref()
344            .map(vec_to_glam)
345            .map(|angular_velocity| rotation.inverse() * angular_velocity)
346            .unwrap_or(glam::Vec3::ZERO);
347        let best_diagonal_score = Self::diagonal_score(local_angular_velocity);
348        let forward_z = (rotation * glam::Vec3::X).z;
349
350        self.active_candidates.insert(
351            player.player_id.clone(),
352            ActiveSpeedFlipCandidate {
353                is_team_0: player.is_team_0,
354                is_kickoff,
355                kickoff_start_time,
356                start_time: frame.time,
357                start_frame: frame.frame_number,
358                start_position: player_position.to_array(),
359                end_position: player_position.to_array(),
360                start_velocity_xy,
361                start_forward_xy,
362                start_speed,
363                max_speed: start_speed,
364                best_alignment,
365                best_boost_alignment: Self::boost_alignment(player).unwrap_or(best_alignment),
366                boost_alignment_sample_count: u32::from(player.boost_active),
367                best_dodge_forward_delta: 0.0,
368                best_dodge_delta_alignment: -1.0,
369                dodge_acceleration_sample_count: 0,
370                best_diagonal_score,
371                min_forward_z: forward_z,
372                latest_forward_z: forward_z,
373                latest_time: frame.time,
374                latest_frame: frame.frame_number,
375            },
376        );
377    }
378
379    fn update_candidate(
380        candidate: &mut ActiveSpeedFlipCandidate,
381        frame: &FrameInfo,
382        ball: &BallFrameState,
383        player: &PlayerSample,
384    ) {
385        let Some(rigid_body) = player.rigid_body.as_ref() else {
386            return;
387        };
388
389        if let Some(player_position) = player.position() {
390            candidate.end_position = player_position.to_array();
391        }
392        candidate.max_speed = candidate.max_speed.max(player.speed().unwrap_or(0.0));
393        if let Some(alignment) = Self::candidate_alignment(ball, player, candidate.is_kickoff) {
394            candidate.best_alignment = candidate.best_alignment.max(alignment);
395        }
396        if let Some(boost_alignment) = Self::boost_alignment(player) {
397            candidate.best_boost_alignment = candidate.best_boost_alignment.max(boost_alignment);
398            candidate.boost_alignment_sample_count += 1;
399        }
400        if frame.time > candidate.start_time
401            && frame.time - candidate.start_time <= SPEED_FLIP_DODGE_ACCELERATION_SAMPLE_SECONDS
402        {
403            if let Some(velocity) = player.velocity() {
404                let velocity_delta = velocity.truncate() - candidate.start_velocity_xy;
405                let delta_length = velocity_delta.length();
406                if delta_length > f32::EPSILON {
407                    let forward_delta = velocity_delta.dot(candidate.start_forward_xy);
408                    candidate.best_dodge_forward_delta =
409                        candidate.best_dodge_forward_delta.max(forward_delta);
410                    candidate.best_dodge_delta_alignment = candidate
411                        .best_dodge_delta_alignment
412                        .max(forward_delta / delta_length);
413                    candidate.dodge_acceleration_sample_count += 1;
414                }
415            }
416        }
417
418        let rotation = quat_to_glam(&rigid_body.rotation);
419        let local_angular_velocity = rigid_body
420            .angular_velocity
421            .as_ref()
422            .map(vec_to_glam)
423            .map(|angular_velocity| rotation.inverse() * angular_velocity)
424            .unwrap_or(glam::Vec3::ZERO);
425        candidate.best_diagonal_score = candidate
426            .best_diagonal_score
427            .max(Self::diagonal_score(local_angular_velocity));
428
429        let forward_z = (rotation * glam::Vec3::X).z;
430        candidate.min_forward_z = candidate.min_forward_z.min(forward_z);
431        candidate.latest_forward_z = forward_z;
432        candidate.latest_time = frame.time;
433        candidate.latest_frame = frame.frame_number;
434    }
435
436    fn candidate_event(
437        player_id: &PlayerId,
438        candidate: ActiveSpeedFlipCandidate,
439    ) -> Option<SpeedFlipEvent> {
440        let time_since_kickoff_start = candidate
441            .kickoff_start_time
442            .map(|kickoff_start_time| (candidate.start_time - kickoff_start_time).max(0.0))
443            .unwrap_or(0.0);
444        let timeliness_score = if candidate.is_kickoff {
445            1.0 - Self::normalize_score(time_since_kickoff_start, 0.55, 1.1)
446        } else {
447            1.0
448        };
449        let cancel_recovery = candidate.latest_forward_z - candidate.min_forward_z;
450        let level_recovery_score =
451            1.0 - Self::normalize_score(candidate.latest_forward_z.abs(), 0.05, 0.55);
452        let cancel_score = 0.25 * Self::normalize_score(-candidate.min_forward_z, 0.05, 0.35)
453            + 0.35 * Self::normalize_score(cancel_recovery, 0.08, 0.5)
454            + 0.40 * level_recovery_score;
455        let speed_score = 0.55 * Self::normalize_score(candidate.max_speed, 1450.0, 1900.0)
456            + 0.45
457                * Self::normalize_score(candidate.max_speed - candidate.start_speed, 180.0, 650.0);
458        let alignment_score = Self::normalize_score(candidate.best_alignment, 0.78, 0.98);
459        if candidate.boost_alignment_sample_count == 0 {
460            return None;
461        }
462        if candidate.dodge_acceleration_sample_count == 0
463            || candidate.best_dodge_forward_delta < SPEED_FLIP_MIN_FORWARD_DODGE_DELTA
464            || candidate.best_dodge_delta_alignment < SPEED_FLIP_MIN_FORWARD_DODGE_DELTA_ALIGNMENT
465        {
466            return None;
467        }
468        let boost_alignment_score =
469            Self::normalize_score(candidate.best_boost_alignment, 0.82, 0.99);
470        let confidence = 0.30 * candidate.best_diagonal_score
471            + 0.30 * cancel_score
472            + 0.15 * speed_score
473            + 0.15 * alignment_score
474            + 0.05 * boost_alignment_score
475            + 0.05 * timeliness_score;
476
477        if boost_alignment_score < 0.25 {
478            return None;
479        }
480        if cancel_score < 0.35 || confidence < SPEED_FLIP_MIN_CONFIDENCE {
481            return None;
482        }
483
484        Some(SpeedFlipEvent {
485            time: candidate.start_time,
486            frame: candidate.start_frame,
487            resolved_time: candidate.latest_time,
488            resolved_frame: candidate.latest_frame,
489            player: player_id.clone(),
490            is_team_0: candidate.is_team_0,
491            time_since_kickoff_start,
492            start_position: candidate.start_position,
493            end_position: candidate.end_position,
494            start_speed: candidate.start_speed,
495            max_speed: candidate.max_speed,
496            best_alignment: candidate.best_alignment,
497            diagonal_score: candidate.best_diagonal_score,
498            cancel_score,
499            speed_score,
500            confidence,
501        })
502    }
503
504    fn finalize_candidates(&mut self, frame: &FrameInfo, force_all: bool) {
505        let mut finished_candidates = Vec::new();
506
507        for (player_id, candidate) in &self.active_candidates {
508            let duration = frame.time - candidate.start_time;
509            if force_all || duration >= SPEED_FLIP_EVALUATION_SECONDS {
510                finished_candidates.push((
511                    candidate.start_time,
512                    candidate.start_frame,
513                    format!("{player_id:?}"),
514                    player_id.clone(),
515                ));
516            }
517        }
518
519        finished_candidates.sort_by(|left, right| {
520            left.0
521                .total_cmp(&right.0)
522                .then_with(|| left.1.cmp(&right.1))
523                .then_with(|| left.2.cmp(&right.2))
524        });
525
526        for (_, _, _, player_id) in finished_candidates {
527            let Some(candidate) = self.active_candidates.remove(&player_id) else {
528                continue;
529            };
530            if let Some(event) = Self::candidate_event(&player_id, candidate) {
531                self.apply_event(event);
532            }
533        }
534    }
535
536    pub fn update_parts(
537        &mut self,
538        frame: &FrameInfo,
539        gameplay: &GameplayState,
540        ball: &BallFrameState,
541        players: &PlayerFrameState,
542        live_play: bool,
543    ) -> SubtrActorResult<()> {
544        let kickoff_approach_active = Self::kickoff_approach_active(gameplay);
545        if !live_play && !kickoff_approach_active {
546            self.active_candidates.clear();
547            self.current_kickoff_start_time = None;
548            self.kickoff_approach_active_last_frame = false;
549            return Ok(());
550        }
551
552        self.begin_sample(frame);
553
554        if kickoff_approach_active && !self.kickoff_approach_active_last_frame {
555            self.reset_kickoff_state();
556        }
557
558        self.update_kickoff_start_time(frame, kickoff_approach_active, players);
559
560        for player in &players.players {
561            self.maybe_start_candidate(frame, gameplay, ball, player, live_play);
562        }
563
564        for (player_id, candidate) in &mut self.active_candidates {
565            let Some(player) = Self::player_by_id(players, player_id) else {
566                continue;
567            };
568            Self::update_candidate(candidate, frame, ball, player);
569        }
570
571        self.finalize_candidates(frame, false);
572
573        self.active_candidates.retain(|_, candidate| {
574            frame.time - candidate.start_time <= SPEED_FLIP_MAX_CANDIDATE_SECONDS
575        });
576
577        if !kickoff_approach_active {
578            self.current_kickoff_start_time = None;
579        }
580
581        self.kickoff_approach_active_last_frame = kickoff_approach_active;
582        Ok(())
583    }
584
585    pub fn finalize_parts(&mut self, frame: &FrameInfo) {
586        self.finalize_candidates(frame, true);
587    }
588}
589
590#[cfg(test)]
591#[path = "speed_flip_tests.rs"]
592mod tests;