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.50;
5const SPEED_FLIP_MAX_CANDIDATE_SECONDS: f32 = 0.70;
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_DIAGONAL_SCORE: f32 = 0.35;
11const SPEED_FLIP_MIN_UP_ROTATION_DEGREES: f32 = 90.0;
12/// A speed flip's air-roll inverts the car but recovers before it goes fully over
13/// — the up-vector sweeps past horizontal but stops short of a full end-over-end
14/// flip. Confounders (full barrel rolls / uncancelled flips) carry all the way to
15/// ~180°. Measured over the extended evaluation window so the roll plays out.
16/// Tuned on the labelled speed-flip / confounder fixtures (≈170° is the knee).
17const SPEED_FLIP_MAX_UP_ROTATION_DEGREES: f32 = 170.0;
18const SPEED_FLIP_MAX_CANCELLED_FORWARD_ROTATION_DEGREES: f32 = 45.0;
19const SPEED_FLIP_MIN_BOOST_ALIGNMENT: f32 = 0.80;
20const SPEED_FLIP_MIN_CONFIDENCE: f32 = 0.45;
21const BOOST_ACCELERATION_UU_PER_SECOND_SQUARED: f32 = 991.6667;
22
23/// A ground-started diagonal dodge/cancel acceleration pattern, primarily for kickoff speed flips.
24#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
25#[ts(export)]
26pub struct SpeedFlipEvent {
27    pub time: f32,
28    pub frame: usize,
29    pub resolved_time: f32,
30    pub resolved_frame: usize,
31    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
32    pub player: PlayerId,
33    pub is_team_0: bool,
34    pub time_since_kickoff_start: f32,
35    pub start_position: [f32; 3],
36    pub end_position: [f32; 3],
37    pub start_speed: f32,
38    pub max_speed: f32,
39    pub best_alignment: f32,
40    #[serde(default)]
41    pub initial_boost_alignment: f32,
42    #[serde(default)]
43    pub best_boost_alignment: f32,
44    #[serde(default)]
45    pub boost_alignment_sample_count: u32,
46    #[serde(default)]
47    pub dodge_delay_after_ground_leave_seconds: f32,
48    pub diagonal_score: f32,
49    #[serde(default)]
50    pub estimated_dodge_impulse_magnitude: f32,
51    #[serde(default)]
52    pub estimated_dodge_impulse_forward_component: f32,
53    #[serde(default)]
54    pub estimated_dodge_impulse_side_component: f32,
55    #[serde(default)]
56    pub estimated_dodge_impulse_up_component: f32,
57    pub cancel_score: f32,
58    pub speed_score: f32,
59    pub confidence: f32,
60}
61
62#[derive(Debug, Clone, PartialEq)]
63struct ActiveSpeedFlipCandidate {
64    is_team_0: bool,
65    is_kickoff: bool,
66    kickoff_start_time: Option<f32>,
67    start_time: f32,
68    start_frame: usize,
69    start_position: [f32; 3],
70    end_position: [f32; 3],
71    start_velocity: glam::Vec3,
72    start_velocity_xy: glam::Vec2,
73    start_forward_xy: glam::Vec2,
74    local_forward: glam::Vec3,
75    local_right: glam::Vec3,
76    local_up: glam::Vec3,
77    start_speed: f32,
78    max_speed: f32,
79    best_alignment: f32,
80    initial_boost_alignment: Option<f32>,
81    best_boost_alignment: f32,
82    boost_alignment_sample_count: u32,
83    dodge_delay_after_ground_leave_seconds: f32,
84    dodge_boost_compensation: glam::Vec3,
85    best_dodge_forward_delta: f32,
86    best_dodge_delta_alignment: f32,
87    best_estimated_dodge_impulse_magnitude: f32,
88    best_estimated_dodge_impulse_forward_component: f32,
89    best_estimated_dodge_impulse_side_component: f32,
90    best_estimated_dodge_impulse_up_component: f32,
91    dodge_acceleration_sample_count: u32,
92    /// Replicated dodge torque (car-relative flip axis) captured at the dodge,
93    /// `None` on inputs that do not replicate it. Preferred over
94    /// `best_diagonal_score` (the onset-angular-velocity fallback) for the
95    /// diagonal-dodge signal — see [`SpeedFlipCalculator::diagonal_score_from_torque`].
96    dodge_torque: Option<glam::Vec3>,
97    best_diagonal_score: f32,
98    max_forward_rotation_degrees: f32,
99    max_up_rotation_degrees: f32,
100    min_forward_z: f32,
101    latest_forward_z: f32,
102    latest_time: f32,
103    latest_frame: usize,
104}
105
106impl InFlightItem for ActiveSpeedFlipCandidate {
107    fn recognition(&self) -> Recognition {
108        // Speculative: a candidate can be pruned (stale), discarded at a
109        // boundary, or evaluate to no speed flip before emitting.
110        Recognition::speculative(self.start_time, self.start_frame)
111    }
112
113    fn on_boundary(&mut self, _boundary: Boundary) -> Disposition {
114        Disposition::Discard
115    }
116}
117
118/// Detects speed flips from gameplay/ball/player state.
119#[derive(Debug, Clone, Default, PartialEq)]
120pub struct SpeedFlipCalculator {
121    events: EventStream<SpeedFlipEvent>,
122    active_candidates: KeyedInFlightLedger<PlayerId, ActiveSpeedFlipCandidate>,
123    previous_dodge_active: HashMap<PlayerId, bool>,
124    last_ground_contacts: HashMap<PlayerId, f32>,
125    kickoff_approach_active_last_frame: bool,
126    kickoff_window_open: bool,
127    current_kickoff_start_time: Option<f32>,
128}
129
130impl SpeedFlipCalculator {
131    pub fn new() -> Self {
132        Self::default()
133    }
134
135    pub fn events(&self) -> &[SpeedFlipEvent] {
136        self.events.all()
137    }
138
139    pub fn new_events(&self) -> &[SpeedFlipEvent] {
140        self.events.new_events()
141    }
142
143    /// Whether we are inside a kickoff approach window.
144    ///
145    /// The opening kickoff of a match reports `ball_has_been_hit == None` (not
146    /// `Some(false)`) between the countdown ending and the first touch, so a gate
147    /// of `== Some(false)` alone never fires on it — the speed flip was simply
148    /// never evaluated as a kickoff. (The kickoff stats machinery works around
149    /// the same engine quirk; see `kickoff.rs::observe_movement_start`.) Track
150    /// the approach as an explicit window instead: open it on the countdown (or
151    /// the `Some(false)` signal used by every subsequent kickoff) and hold it
152    /// open through the `None` go→touch window until the ball is actually hit.
153    fn update_kickoff_window(&mut self, gameplay: &GameplayState) -> bool {
154        self.kickoff_window_open =
155            if gameplay.kickoff_countdown_active() || gameplay.ball_has_been_hit == Some(false) {
156                true
157            } else if gameplay.ball_has_been_hit == Some(true) {
158                false
159            } else {
160                // `None` with no countdown: either the opening go→touch window (keep
161                // whatever the countdown opened) or pre-match idle (stays closed).
162                self.kickoff_window_open
163            };
164        self.kickoff_window_open
165    }
166
167    fn player_by_id<'a>(
168        players: &'a PlayerFrameState,
169        player_id: &PlayerId,
170    ) -> Option<&'a PlayerSample> {
171        players
172            .players
173            .iter()
174            .find(|player| &player.player_id == player_id)
175    }
176
177    fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
178        if max_value <= min_value {
179            return 0.0;
180        }
181
182        ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
183    }
184
185    fn diagonal_score(local_angular_velocity: glam::Vec3) -> f32 {
186        let pitch_rate = local_angular_velocity.y.abs();
187        let side_spin = local_angular_velocity
188            .x
189            .abs()
190            .max(local_angular_velocity.z.abs());
191        if pitch_rate <= f32::EPSILON || side_spin <= f32::EPSILON {
192            return 0.0;
193        }
194
195        let pitch_score = Self::normalize_score(pitch_rate, 35.0, 180.0);
196        let side_score = Self::normalize_score(side_spin, 60.0, 260.0);
197        let balance = pitch_rate.min(side_spin) / pitch_rate.max(side_spin);
198        let balance_score = Self::normalize_score(balance, 0.18, 0.65);
199
200        (pitch_score * side_score).sqrt() * (0.75 + 0.25 * balance_score)
201    }
202
203    /// Diagonal-dodge score from the replicated dodge torque — the car-relative
204    /// flip axis, where `y` is the forward/back (pitch) component and `x` the
205    /// side (roll) component. A speed flip is a *forward-diagonal* dodge, so this
206    /// rewards a torque that is both forward (`y > 0`) and sideways (`|x|`),
207    /// peaking at a 45° forward-diagonal and falling to 0 for a pure-forward
208    /// dodge, a pure-side flip, or a backflip (`y <= 0`).
209    ///
210    /// This is the clean replacement for [`diagonal_score`], which reads the
211    /// onset *angular velocity*: the dodge-active byte lags the real flip by an
212    /// inconsistent amount, so the angular velocity at detection is contaminated
213    /// by how far the car has already rotated (and any air-roll). The dodge
214    /// torque is the exact commanded direction, set once at the dodge and
215    /// car-relative, so it needs no frame-timing luck. Used when available;
216    /// inputs that do not replicate dodge torque fall back to [`diagonal_score`].
217    fn diagonal_score_from_torque(torque: glam::Vec3) -> f32 {
218        let forward = torque.y;
219        let side = torque.x.abs();
220        if forward <= 0.0 {
221            return 0.0;
222        }
223        let magnitude = glam::Vec2::new(forward, side).length();
224        if magnitude <= f32::EPSILON {
225            return 0.0;
226        }
227        // forward * side / magnitude^2 peaks at 0.5 (a perfect 45° diagonal);
228        // scale so a clean forward-diagonal dodge scores ~1.0.
229        (2.0 * forward * side / (magnitude * magnitude)).clamp(0.0, 1.0)
230    }
231
232    fn forward_speed_alignment(player: &PlayerSample) -> Option<f32> {
233        let velocity = player.velocity()?;
234        let rigid_body = player.rigid_body.as_ref()?;
235        let velocity_xy = velocity.truncate().normalize_or_zero();
236        if velocity_xy.length_squared() <= f32::EPSILON {
237            return None;
238        }
239
240        let forward_xy = (quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
241            .truncate()
242            .normalize_or_zero();
243        if forward_xy.length_squared() <= f32::EPSILON {
244            return None;
245        }
246
247        Some(forward_xy.dot(velocity_xy))
248    }
249
250    fn forward_xy(player: &PlayerSample) -> Option<glam::Vec2> {
251        let rigid_body = player.rigid_body.as_ref()?;
252        let forward_xy = (quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
253            .truncate()
254            .normalize_or_zero();
255        (forward_xy.length_squared() > f32::EPSILON).then_some(forward_xy)
256    }
257
258    fn boost_alignment(player: &PlayerSample) -> Option<f32> {
259        player
260            .boost_active
261            .then(|| Self::forward_speed_alignment(player))
262            .flatten()
263    }
264
265    fn update_ground_contacts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
266        for player in &players.players {
267            if player
268                .position()
269                .is_some_and(|position| position.z <= PLAYER_GROUND_Z_THRESHOLD)
270            {
271                self.last_ground_contacts
272                    .insert(player.player_id.clone(), frame.time);
273            }
274        }
275
276        self.last_ground_contacts
277            .retain(|_, ground_contact_time| frame.time - *ground_contact_time <= 2.0);
278    }
279
280    fn candidate_alignment(
281        _ball: &BallFrameState,
282        player: &PlayerSample,
283        _is_kickoff: bool,
284    ) -> Option<f32> {
285        Self::forward_speed_alignment(player)
286    }
287
288    fn apply_event(&mut self, event: SpeedFlipEvent) {
289        self.events.push(event);
290    }
291
292    fn reset_kickoff_state(&mut self) {
293        self.active_candidates.clear();
294        self.current_kickoff_start_time = None;
295    }
296
297    fn kickoff_motion_started(players: &PlayerFrameState) -> bool {
298        players.players.iter().any(|player| {
299            player.dodge_active
300                || player
301                    .speed()
302                    .is_some_and(|speed| speed >= SPEED_FLIP_KICKOFF_MOTION_SPEED)
303        })
304    }
305
306    fn update_kickoff_start_time(
307        &mut self,
308        frame: &FrameInfo,
309        kickoff_approach_active: bool,
310        players: &PlayerFrameState,
311    ) {
312        if !kickoff_approach_active {
313            self.current_kickoff_start_time = None;
314            return;
315        }
316
317        if self.current_kickoff_start_time.is_none() && Self::kickoff_motion_started(players) {
318            self.current_kickoff_start_time = Some(frame.time);
319        }
320    }
321
322    fn maybe_start_candidate(
323        &mut self,
324        frame: &FrameInfo,
325        kickoff_approach_active: bool,
326        ball: &BallFrameState,
327        player: &PlayerSample,
328        _live_play_state: &LivePlayState,
329    ) {
330        let was_dodge_active = self
331            .previous_dodge_active
332            .insert(player.player_id.clone(), player.dodge_active)
333            .unwrap_or(false);
334        if !player.dodge_active || was_dodge_active {
335            return;
336        }
337
338        let is_kickoff = kickoff_approach_active;
339        let kickoff_start_time = if is_kickoff {
340            let Some(kickoff_start_time) = self.current_kickoff_start_time else {
341                return;
342            };
343            if frame.time - kickoff_start_time > SPEED_FLIP_MAX_START_AFTER_KICKOFF_SECONDS {
344                return;
345            }
346            Some(kickoff_start_time)
347        } else {
348            None
349        };
350
351        let Some(rigid_body) = player.rigid_body.as_ref() else {
352            return;
353        };
354        let Some(player_position) = player.position() else {
355            return;
356        };
357        if player_position.z > SPEED_FLIP_MAX_GROUND_Z {
358            return;
359        }
360
361        let start_speed = player.speed().unwrap_or(0.0);
362        let Some(best_alignment) = Self::candidate_alignment(ball, player, is_kickoff) else {
363            return;
364        };
365        if best_alignment < SPEED_FLIP_MIN_ALIGNMENT {
366            return;
367        }
368        let Some(start_velocity) = player.velocity() else {
369            return;
370        };
371        let start_velocity_xy = start_velocity.truncate();
372        let Some(start_forward_xy) = Self::forward_xy(player) else {
373            return;
374        };
375        let dodge_delay_after_ground_leave_seconds = self
376            .last_ground_contacts
377            .get(&player.player_id)
378            .map(|ground_contact_time| (frame.time - *ground_contact_time).max(0.0))
379            .unwrap_or(0.0);
380
381        let rotation = quat_to_glam(&rigid_body.rotation);
382        let local_angular_velocity = rigid_body
383            .angular_velocity
384            .as_ref()
385            .map(vec_to_glam)
386            .map(|angular_velocity| rotation.inverse() * angular_velocity)
387            .unwrap_or(glam::Vec3::ZERO);
388        let best_diagonal_score = Self::diagonal_score(local_angular_velocity);
389        let forward_z = (rotation * glam::Vec3::X).z;
390        let initial_boost_alignment = Self::boost_alignment(player);
391
392        self.active_candidates.arm(
393            player.player_id.clone(),
394            ActiveSpeedFlipCandidate {
395                is_team_0: player.is_team_0,
396                is_kickoff,
397                kickoff_start_time,
398                start_time: frame.time,
399                start_frame: frame.frame_number,
400                start_position: player_position.to_array(),
401                end_position: player_position.to_array(),
402                start_velocity,
403                start_velocity_xy,
404                start_forward_xy,
405                local_forward: rotation * glam::Vec3::X,
406                local_right: rotation * glam::Vec3::Y,
407                local_up: rotation * glam::Vec3::Z,
408                start_speed,
409                max_speed: start_speed,
410                best_alignment,
411                initial_boost_alignment,
412                best_boost_alignment: initial_boost_alignment.unwrap_or(best_alignment),
413                boost_alignment_sample_count: u32::from(initial_boost_alignment.is_some()),
414                dodge_delay_after_ground_leave_seconds,
415                dodge_boost_compensation: glam::Vec3::ZERO,
416                best_dodge_forward_delta: 0.0,
417                best_dodge_delta_alignment: -1.0,
418                best_estimated_dodge_impulse_magnitude: 0.0,
419                best_estimated_dodge_impulse_forward_component: -1.0,
420                best_estimated_dodge_impulse_side_component: 0.0,
421                best_estimated_dodge_impulse_up_component: 1.0,
422                dodge_acceleration_sample_count: 0,
423                dodge_torque: player.dodge_torque,
424                best_diagonal_score,
425                max_forward_rotation_degrees: 0.0,
426                max_up_rotation_degrees: 0.0,
427                min_forward_z: forward_z,
428                latest_forward_z: forward_z,
429                latest_time: frame.time,
430                latest_frame: frame.frame_number,
431            },
432        );
433    }
434
435    fn update_candidate(
436        candidate: &mut ActiveSpeedFlipCandidate,
437        frame: &FrameInfo,
438        ball: &BallFrameState,
439        player: &PlayerSample,
440    ) {
441        let Some(rigid_body) = player.rigid_body.as_ref() else {
442            return;
443        };
444
445        if let Some(player_position) = player.position() {
446            candidate.end_position = player_position.to_array();
447        }
448        candidate.max_speed = candidate.max_speed.max(player.speed().unwrap_or(0.0));
449        if let Some(alignment) = Self::candidate_alignment(ball, player, candidate.is_kickoff) {
450            candidate.best_alignment = candidate.best_alignment.max(alignment);
451        }
452        if let Some(boost_alignment) = Self::boost_alignment(player) {
453            if candidate.initial_boost_alignment.is_none() {
454                candidate.initial_boost_alignment = Some(boost_alignment);
455            }
456            candidate.best_boost_alignment = candidate.best_boost_alignment.max(boost_alignment);
457            candidate.boost_alignment_sample_count += 1;
458        }
459        if frame.time > candidate.start_time
460            && frame.time - candidate.start_time <= SPEED_FLIP_DODGE_ACCELERATION_SAMPLE_SECONDS
461        {
462            if let Some(velocity) = player.velocity() {
463                if player.boost_active {
464                    candidate.dodge_boost_compensation += candidate.local_forward
465                        * BOOST_ACCELERATION_UU_PER_SECOND_SQUARED
466                        * frame.dt;
467                }
468                let velocity_delta = velocity.truncate() - candidate.start_velocity_xy;
469                let delta_length = velocity_delta.length();
470                if delta_length > f32::EPSILON {
471                    let forward_delta = velocity_delta.dot(candidate.start_forward_xy);
472                    candidate.best_dodge_forward_delta =
473                        candidate.best_dodge_forward_delta.max(forward_delta);
474                    candidate.best_dodge_delta_alignment = candidate
475                        .best_dodge_delta_alignment
476                        .max(forward_delta / delta_length);
477                    candidate.dodge_acceleration_sample_count += 1;
478                }
479
480                let estimated_delta =
481                    velocity - candidate.start_velocity - candidate.dodge_boost_compensation;
482                let estimated_horizontal_magnitude = estimated_delta.truncate().length();
483                if estimated_horizontal_magnitude > f32::EPSILON {
484                    let estimated_magnitude = estimated_delta.length();
485                    let estimated_direction = estimated_delta / estimated_magnitude;
486                    let forward_component = estimated_direction.dot(candidate.local_forward);
487                    if estimated_horizontal_magnitude
488                        > candidate.best_estimated_dodge_impulse_magnitude
489                    {
490                        candidate.best_estimated_dodge_impulse_magnitude =
491                            estimated_horizontal_magnitude;
492                        candidate.best_estimated_dodge_impulse_forward_component =
493                            forward_component;
494                        candidate.best_estimated_dodge_impulse_side_component =
495                            estimated_direction.dot(candidate.local_right);
496                        candidate.best_estimated_dodge_impulse_up_component =
497                            estimated_direction.dot(candidate.local_up);
498                    }
499                }
500            }
501        }
502
503        let rotation = quat_to_glam(&rigid_body.rotation);
504        let local_angular_velocity = rigid_body
505            .angular_velocity
506            .as_ref()
507            .map(vec_to_glam)
508            .map(|angular_velocity| rotation.inverse() * angular_velocity)
509            .unwrap_or(glam::Vec3::ZERO);
510        candidate.best_diagonal_score = candidate
511            .best_diagonal_score
512            .max(Self::diagonal_score(local_angular_velocity));
513
514        let current_forward = rotation * glam::Vec3::X;
515        let current_up = rotation * glam::Vec3::Z;
516        candidate.max_forward_rotation_degrees = candidate.max_forward_rotation_degrees.max(
517            candidate
518                .local_forward
519                .angle_between(current_forward)
520                .to_degrees(),
521        );
522        candidate.max_up_rotation_degrees = candidate
523            .max_up_rotation_degrees
524            .max(candidate.local_up.angle_between(current_up).to_degrees());
525
526        let forward_z = (rotation * glam::Vec3::X).z;
527        candidate.min_forward_z = candidate.min_forward_z.min(forward_z);
528        candidate.latest_forward_z = forward_z;
529        candidate.latest_time = frame.time;
530        candidate.latest_frame = frame.frame_number;
531    }
532
533    fn candidate_event(
534        player_id: &PlayerId,
535        candidate: ActiveSpeedFlipCandidate,
536    ) -> Option<SpeedFlipEvent> {
537        let time_since_kickoff_start = candidate
538            .kickoff_start_time
539            .map(|kickoff_start_time| (candidate.start_time - kickoff_start_time).max(0.0))
540            .unwrap_or(0.0);
541        let timeliness_score = if candidate.is_kickoff {
542            1.0 - Self::normalize_score(time_since_kickoff_start, 0.55, 1.1)
543        } else {
544            1.0
545        };
546        let cancel_recovery = candidate.latest_forward_z - candidate.min_forward_z;
547        let level_recovery_score =
548            1.0 - Self::normalize_score(candidate.latest_forward_z.abs(), 0.05, 0.55);
549        let cancel_score = 0.25 * Self::normalize_score(-candidate.min_forward_z, 0.05, 0.35)
550            + 0.35 * Self::normalize_score(cancel_recovery, 0.08, 0.5)
551            + 0.40 * level_recovery_score;
552        let speed_score = 0.55 * Self::normalize_score(candidate.max_speed, 1450.0, 1900.0)
553            + 0.45
554                * Self::normalize_score(candidate.max_speed - candidate.start_speed, 180.0, 650.0);
555        let alignment_score = Self::normalize_score(candidate.best_alignment, 0.78, 0.98);
556        if candidate.boost_alignment_sample_count == 0 {
557            return None;
558        }
559        // The defining trait of a speed flip is the *cancel*: the dodge is a
560        // diagonal flip whose nose is levelled back out instead of pitching all
561        // the way over (`max_forward_rotation` stays small). This is what
562        // separates a real speed flip from an ordinary full diagonal flip, which
563        // can score just as high on raw diagonal angular velocity. We do NOT gate
564        // on how long after the jump the dodge fires: real speed flips vary a lot
565        // there (two pros on the same kickoff flipped 0.07s vs 0.22s after
566        // leaving the ground), so a timing ceiling only produces false negatives.
567        // `dodge_delay_after_ground_leave_seconds` is still reported for review.
568        // Prefer the clean dodge-torque direction; fall back to the onset
569        // angular velocity only when the input does not replicate dodge torque.
570        let diagonal_score = candidate
571            .dodge_torque
572            .map(Self::diagonal_score_from_torque)
573            .unwrap_or(candidate.best_diagonal_score);
574        let has_cancelled_diagonal_dodge = candidate.max_forward_rotation_degrees
575            <= SPEED_FLIP_MAX_CANCELLED_FORWARD_ROTATION_DEGREES
576            && diagonal_score >= SPEED_FLIP_MIN_DIAGONAL_SCORE;
577        // Inverts (≥ MIN) but does not go fully end-over-end (≤ MAX): the
578        // air-roll signature of a speed flip, vs a full barrel roll / flip.
579        if candidate.max_up_rotation_degrees < SPEED_FLIP_MIN_UP_ROTATION_DEGREES
580            || candidate.max_up_rotation_degrees > SPEED_FLIP_MAX_UP_ROTATION_DEGREES
581        {
582            return None;
583        }
584        // Detection is rotation-only. The velocity-impulse estimate is corrupted
585        // by boost compensation — on real speed flips it frequently reads
586        // *backward* (the all-speed-flip recall fixture has the detector miss
587        // every flip when gated on it), so any gate on the impulse direction
588        // destroys recall. The cancelled diagonal-dodge signature carries
589        // detection; the impulse is kept on the event as reported metadata only.
590        if !has_cancelled_diagonal_dodge {
591            return None;
592        }
593        let boost_alignment_score =
594            Self::normalize_score(candidate.best_boost_alignment, 0.82, 0.99);
595        let confidence = 0.30 * diagonal_score
596            + 0.30 * cancel_score
597            + 0.15 * speed_score
598            + 0.15 * alignment_score
599            + 0.05 * boost_alignment_score
600            + 0.05 * timeliness_score;
601
602        // Mid-flip the car is yawed off its velocity axis, so raw alignment of
603        // a boost-through speed flip tops out around ~0.82 inside the short
604        // evaluation window; gate on the raw alignment rather than the score.
605        if candidate.best_boost_alignment < SPEED_FLIP_MIN_BOOST_ALIGNMENT {
606            return None;
607        }
608        if confidence < SPEED_FLIP_MIN_CONFIDENCE {
609            return None;
610        }
611
612        Some(SpeedFlipEvent {
613            time: candidate.start_time,
614            frame: candidate.start_frame,
615            resolved_time: candidate.latest_time,
616            resolved_frame: candidate.latest_frame,
617            player: player_id.clone(),
618            is_team_0: candidate.is_team_0,
619            time_since_kickoff_start,
620            start_position: candidate.start_position,
621            end_position: candidate.end_position,
622            start_speed: candidate.start_speed,
623            max_speed: candidate.max_speed,
624            best_alignment: candidate.best_alignment,
625            initial_boost_alignment: candidate
626                .initial_boost_alignment
627                .unwrap_or(candidate.best_boost_alignment),
628            best_boost_alignment: candidate.best_boost_alignment,
629            boost_alignment_sample_count: candidate.boost_alignment_sample_count,
630            dodge_delay_after_ground_leave_seconds: candidate
631                .dodge_delay_after_ground_leave_seconds,
632            diagonal_score,
633            estimated_dodge_impulse_magnitude: candidate.best_estimated_dodge_impulse_magnitude,
634            estimated_dodge_impulse_forward_component: candidate
635                .best_estimated_dodge_impulse_forward_component,
636            estimated_dodge_impulse_side_component: candidate
637                .best_estimated_dodge_impulse_side_component,
638            estimated_dodge_impulse_up_component: candidate
639                .best_estimated_dodge_impulse_up_component,
640            cancel_score,
641            speed_score,
642            confidence,
643        })
644    }
645
646    fn finalize_candidates(&mut self, frame: &FrameInfo, force_all: bool) {
647        let mut finished_candidates = Vec::new();
648
649        for (player_id, candidate) in self.active_candidates.iter() {
650            let duration = frame.time - candidate.start_time;
651            if force_all || duration >= SPEED_FLIP_EVALUATION_SECONDS {
652                finished_candidates.push((
653                    candidate.start_time,
654                    candidate.start_frame,
655                    format!("{player_id:?}"),
656                    player_id.clone(),
657                ));
658            }
659        }
660
661        finished_candidates.sort_by(|left, right| {
662            left.0
663                .total_cmp(&right.0)
664                .then_with(|| left.1.cmp(&right.1))
665                .then_with(|| left.2.cmp(&right.2))
666        });
667
668        for (_, _, _, player_id) in finished_candidates {
669            let Some(candidate) = self
670                .active_candidates
671                .finalize(&player_id, FinalizeReason::Completed)
672            else {
673                continue;
674            };
675            if let Some(event) = Self::candidate_event(&player_id, candidate) {
676                self.apply_event(event);
677            }
678        }
679    }
680
681    pub fn update_parts(
682        &mut self,
683        frame: &FrameInfo,
684        gameplay: &GameplayState,
685        ball: &BallFrameState,
686        players: &PlayerFrameState,
687        live_play_state: &LivePlayState,
688    ) -> SubtrActorResult<()> {
689        self.events.begin_update();
690        let kickoff_approach_active = self.update_kickoff_window(gameplay);
691        if !live_play_state.is_live_play && !kickoff_approach_active {
692            self.active_candidates
693                .apply_boundary(Boundary::LivePlayEnded);
694            self.current_kickoff_start_time = None;
695            self.kickoff_approach_active_last_frame = false;
696            self.last_ground_contacts.clear();
697            return Ok(());
698        }
699
700        if kickoff_approach_active && !self.kickoff_approach_active_last_frame {
701            self.reset_kickoff_state();
702        }
703
704        self.update_kickoff_start_time(frame, kickoff_approach_active, players);
705        self.update_ground_contacts(frame, players);
706
707        for player in &players.players {
708            self.maybe_start_candidate(
709                frame,
710                kickoff_approach_active,
711                ball,
712                player,
713                live_play_state,
714            );
715        }
716
717        for (player_id, candidate) in self.active_candidates.iter_mut() {
718            let Some(player) = Self::player_by_id(players, player_id) else {
719                continue;
720            };
721            Self::update_candidate(candidate, frame, ball, player);
722        }
723
724        self.finalize_candidates(frame, false);
725
726        self.active_candidates.retain(|_, candidate| {
727            frame.time - candidate.start_time <= SPEED_FLIP_MAX_CANDIDATE_SECONDS
728        });
729
730        if !kickoff_approach_active {
731            self.current_kickoff_start_time = None;
732        }
733
734        self.kickoff_approach_active_last_frame = kickoff_approach_active;
735        Ok(())
736    }
737
738    pub fn finalize_parts(&mut self, frame: &FrameInfo) {
739        self.finalize_candidates(frame, true);
740    }
741}
742
743#[cfg(test)]
744#[path = "speed_flip_tests.rs"]
745mod tests;