Skip to main content

subtr_actor/stats/calculators/
ball_carry.rs

1use super::*;
2
3#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
4#[ts(export)]
5pub struct BallCarryStats {
6    pub carry_count: u32,
7    pub total_carry_time: f32,
8    pub total_straight_line_distance: f32,
9    pub total_path_distance: f32,
10    pub longest_carry_time: f32,
11    pub furthest_carry_distance: f32,
12    pub fastest_carry_speed: f32,
13    pub carry_speed_sum: f32,
14    pub average_horizontal_gap_sum: f32,
15    pub average_vertical_gap_sum: f32,
16    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
17    pub labeled_event_counts: LabeledCounts,
18}
19
20impl BallCarryStats {
21    fn pct_count_average(&self, value: f32) -> f32 {
22        if self.carry_count == 0 {
23            0.0
24        } else {
25            value / self.carry_count as f32
26        }
27    }
28
29    pub fn average_carry_time(&self) -> f32 {
30        self.pct_count_average(self.total_carry_time)
31    }
32
33    pub fn average_straight_line_distance(&self) -> f32 {
34        self.pct_count_average(self.total_straight_line_distance)
35    }
36
37    pub fn average_path_distance(&self) -> f32 {
38        self.pct_count_average(self.total_path_distance)
39    }
40
41    pub fn average_carry_speed(&self) -> f32 {
42        self.pct_count_average(self.carry_speed_sum)
43    }
44
45    pub fn average_horizontal_gap(&self) -> f32 {
46        self.pct_count_average(self.average_horizontal_gap_sum)
47    }
48
49    pub fn average_vertical_gap(&self) -> f32 {
50        self.pct_count_average(self.average_vertical_gap_sum)
51    }
52
53    fn record_event(&mut self, event: &BallCarryEvent) {
54        self.labeled_event_counts
55            .increment([ball_carry_kind_label(event.kind)]);
56        self.carry_count = self.labeled_event_counts.total();
57    }
58
59    pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
60        self.labeled_event_counts.count_matching(labels)
61    }
62
63    pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
64        LabeledCounts::complete_from_label_sets(
65            &[&BALL_CARRY_KIND_LABELS],
66            &self.labeled_event_counts,
67        )
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
72#[ts(export)]
73pub struct BallCarryEvent {
74    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
75    pub player_id: PlayerId,
76    pub is_team_0: bool,
77    pub kind: BallCarryKind,
78    pub start_frame: usize,
79    pub end_frame: usize,
80    pub start_time: f32,
81    pub end_time: f32,
82    pub duration: f32,
83    pub straight_line_distance: f32,
84    pub path_distance: f32,
85    pub average_horizontal_gap: f32,
86    pub average_vertical_gap: f32,
87    pub average_speed: f32,
88    pub touch_count: u32,
89    pub air_touch_count: u32,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub air_dribble_origin: Option<AirDribbleOrigin>,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
95#[ts(export)]
96#[serde(rename_all = "snake_case")]
97pub enum BallCarryKind {
98    Carry,
99    AirDribble,
100}
101
102const BALL_CARRY_KIND_LABELS: [StatLabel; 2] = [
103    StatLabel::new("kind", "carry"),
104    StatLabel::new("kind", "air_dribble"),
105];
106
107fn ball_carry_kind_label(kind: BallCarryKind) -> StatLabel {
108    match kind {
109        BallCarryKind::Carry => StatLabel::new("kind", "carry"),
110        BallCarryKind::AirDribble => StatLabel::new("kind", "air_dribble"),
111    }
112}
113
114#[derive(Debug, Clone, Default)]
115pub struct BallCarryCalculator {
116    player_stats: HashMap<PlayerId, BallCarryStats>,
117    player_air_dribble_stats: HashMap<PlayerId, AirDribbleStats>,
118    team_zero_stats: BallCarryStats,
119    team_one_stats: BallCarryStats,
120    team_zero_air_dribble_stats: AirDribbleStats,
121    team_one_air_dribble_stats: AirDribbleStats,
122    carry_events: Vec<BallCarryEvent>,
123    processed_control_sequence_count: usize,
124}
125
126impl BallCarryCalculator {
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    pub fn player_stats(&self) -> &HashMap<PlayerId, BallCarryStats> {
132        &self.player_stats
133    }
134
135    pub fn player_air_dribble_stats(&self) -> &HashMap<PlayerId, AirDribbleStats> {
136        &self.player_air_dribble_stats
137    }
138
139    pub fn team_zero_stats(&self) -> &BallCarryStats {
140        &self.team_zero_stats
141    }
142
143    pub fn team_one_stats(&self) -> &BallCarryStats {
144        &self.team_one_stats
145    }
146
147    pub fn team_zero_air_dribble_stats(&self) -> &AirDribbleStats {
148        &self.team_zero_air_dribble_stats
149    }
150
151    pub fn team_one_air_dribble_stats(&self) -> &AirDribbleStats {
152        &self.team_one_air_dribble_stats
153    }
154
155    pub fn carry_events(&self) -> &[BallCarryEvent] {
156        &self.carry_events
157    }
158
159    pub(crate) fn carry_frame_sample(
160        player: &PlayerSample,
161        ball: &BallSample,
162    ) -> Option<ContinuousBallControlSample<BallCarryKind>> {
163        let player_position = player.position()?;
164        let ball_position = ball.position();
165        let horizontal_gap = player_position
166            .truncate()
167            .distance(ball_position.truncate());
168        let vertical_gap = ball_position.z - player_position.z;
169
170        if AirDribblePolicy::is_sample(player_position, ball_position, horizontal_gap, vertical_gap)
171        {
172            return Some(ContinuousBallControlSample {
173                player_position,
174                kind: BallCarryKind::AirDribble,
175                horizontal_gap,
176                vertical_gap,
177                speed: player.speed().unwrap_or(0.0),
178            });
179        }
180
181        if player_is_on_wall(player_position) {
182            return None;
183        }
184
185        if !(BALL_CARRY_MIN_BALL_Z..=BALL_CARRY_MAX_BALL_Z).contains(&ball_position.z) {
186            return None;
187        }
188
189        if horizontal_gap > BALL_CARRY_MAX_HORIZONTAL_GAP {
190            return None;
191        }
192
193        if !(0.0..=BALL_CARRY_MAX_VERTICAL_GAP).contains(&vertical_gap) {
194            return None;
195        }
196
197        Some(ContinuousBallControlSample {
198            player_position,
199            kind: BallCarryKind::Carry,
200            horizontal_gap,
201            vertical_gap,
202            speed: player.speed().unwrap_or(0.0),
203        })
204    }
205
206    pub(crate) fn kind_requires_airborne(kind: BallCarryKind) -> bool {
207        AirDribblePolicy::kind_requires_airborne(kind)
208    }
209
210    pub(crate) fn control_player_statuses(
211        players: &PlayerFrameState,
212    ) -> Vec<ContinuousBallControlPlayerStatus> {
213        players
214            .players
215            .iter()
216            .filter_map(|player| {
217                Some(ContinuousBallControlPlayerStatus {
218                    player_id: player.player_id.clone(),
219                    is_airborne: AirDribblePolicy::is_air_touch_position(player.position()?),
220                })
221            })
222            .collect()
223    }
224
225    pub(crate) fn control_touches(
226        touch_state: &TouchState,
227        players: &PlayerFrameState,
228    ) -> Vec<ContinuousBallControlTouch> {
229        touch_state
230            .touch_events
231            .iter()
232            .filter_map(|touch| {
233                let player_id = touch.player.clone()?;
234                let player = players
235                    .players
236                    .iter()
237                    .find(|player| player.player_id == player_id)?;
238                Some(ContinuousBallControlTouch {
239                    player_id,
240                    is_airborne: AirDribblePolicy::is_air_touch_position(player.position()?),
241                })
242            })
243            .collect()
244    }
245
246    pub(crate) fn min_duration_for_kind(kind: BallCarryKind) -> f32 {
247        match kind {
248            BallCarryKind::Carry => BALL_CARRY_MIN_DURATION,
249            BallCarryKind::AirDribble => AIR_DRIBBLE_MIN_DURATION,
250        }
251    }
252
253    pub(crate) fn control_candidate(
254        ball: &BallFrameState,
255        players: &PlayerFrameState,
256        live_play: bool,
257        touch_state: &TouchState,
258    ) -> Option<ContinuousBallControlCandidate<BallCarryKind>> {
259        if !live_play {
260            return None;
261        }
262        let ball = ball.sample()?;
263        let player_id = touch_state.last_touch_player.as_ref()?;
264        let touch_count = touch_state
265            .touch_events
266            .iter()
267            .filter(|event| event.player.as_ref() == Some(player_id))
268            .count() as u32;
269        players
270            .players
271            .iter()
272            .find(|player| &player.player_id == player_id)
273            .and_then(|player| {
274                Self::carry_frame_sample(player, ball).map(|sample| {
275                    let air_touch_count =
276                        if AirDribblePolicy::is_air_touch_position(sample.player_position) {
277                            touch_count
278                        } else {
279                            0
280                        };
281                    ContinuousBallControlCandidate {
282                        player_id: player.player_id.clone(),
283                        is_team_0: player.is_team_0,
284                        touch_count,
285                        air_touch_count,
286                        sample,
287                    }
288                })
289            })
290    }
291
292    fn event_from_sequence(
293        sequence: CompletedBallControlSequence<BallCarryKind>,
294    ) -> BallCarryEvent {
295        let air_dribble_origin = (sequence.kind == BallCarryKind::AirDribble)
296            .then(|| AirDribblePolicy::origin(sequence.start_position));
297        BallCarryEvent {
298            player_id: sequence.player_id,
299            is_team_0: sequence.is_team_0,
300            kind: sequence.kind,
301            start_frame: sequence.start_frame,
302            end_frame: sequence.end_frame,
303            start_time: sequence.start_time,
304            end_time: sequence.end_time,
305            duration: sequence.duration,
306            straight_line_distance: sequence.straight_line_distance,
307            path_distance: sequence.path_distance,
308            average_horizontal_gap: sequence.average_horizontal_gap,
309            average_vertical_gap: sequence.average_vertical_gap,
310            average_speed: sequence.average_speed,
311            touch_count: sequence.touch_count,
312            air_touch_count: sequence.air_touch_count,
313            air_dribble_origin,
314        }
315    }
316
317    fn record_carry_event(&mut self, event: BallCarryEvent) {
318        match event.kind {
319            BallCarryKind::Carry => {
320                let player_stats = self
321                    .player_stats
322                    .entry(event.player_id.clone())
323                    .or_default();
324                Self::apply_carry_event(player_stats, &event);
325
326                let team_stats = if event.is_team_0 {
327                    &mut self.team_zero_stats
328                } else {
329                    &mut self.team_one_stats
330                };
331                Self::apply_carry_event(team_stats, &event);
332            }
333            BallCarryKind::AirDribble => {
334                let player_stats = self
335                    .player_air_dribble_stats
336                    .entry(event.player_id.clone())
337                    .or_default();
338                AirDribblePolicy::apply_event(player_stats, &event);
339
340                let team_stats = if event.is_team_0 {
341                    &mut self.team_zero_air_dribble_stats
342                } else {
343                    &mut self.team_one_air_dribble_stats
344                };
345                AirDribblePolicy::apply_event(team_stats, &event);
346            }
347        }
348        self.carry_events.push(event);
349    }
350
351    fn apply_carry_event(stats: &mut BallCarryStats, event: &BallCarryEvent) {
352        stats.record_event(event);
353        stats.total_carry_time += event.duration;
354        stats.total_straight_line_distance += event.straight_line_distance;
355        stats.total_path_distance += event.path_distance;
356        stats.longest_carry_time = stats.longest_carry_time.max(event.duration);
357        stats.furthest_carry_distance = stats
358            .furthest_carry_distance
359            .max(event.straight_line_distance);
360        stats.fastest_carry_speed = stats.fastest_carry_speed.max(event.average_speed);
361        stats.carry_speed_sum += event.average_speed;
362        stats.average_horizontal_gap_sum += event.average_horizontal_gap;
363        stats.average_vertical_gap_sum += event.average_vertical_gap;
364    }
365
366    pub fn update(&mut self, control_state: &ContinuousBallControlState) -> SubtrActorResult<()> {
367        for sequence in control_state
368            .completed_sequences
369            .iter()
370            .skip(self.processed_control_sequence_count)
371            .cloned()
372        {
373            if !AirDribblePolicy::is_valid_sequence(&sequence) {
374                continue;
375            }
376            self.record_carry_event(Self::event_from_sequence(sequence));
377        }
378        self.processed_control_sequence_count = control_state.completed_sequences.len();
379        Ok(())
380    }
381}
382
383#[cfg(test)]
384#[path = "ball_carry_tests.rs"]
385mod tests;