Skip to main content

subtr_actor/stats/calculators/
ball_carry.rs

1use super::*;
2
3/// A completed ball carry or air dribble with path, duration, and touch metrics.
4#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
5#[ts(export)]
6pub struct BallCarryEvent {
7    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
8    pub player_id: PlayerId,
9    pub is_team_0: bool,
10    pub kind: BallCarryKind,
11    pub start_position: [f32; 3],
12    pub end_position: [f32; 3],
13    pub start_frame: usize,
14    pub end_frame: usize,
15    pub start_time: f32,
16    pub end_time: f32,
17    pub duration: f32,
18    pub straight_line_distance: f32,
19    pub path_distance: f32,
20    pub average_horizontal_gap: f32,
21    pub average_vertical_gap: f32,
22    pub average_speed: f32,
23    pub touch_count: u32,
24    pub air_touch_count: u32,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub air_dribble_origin: Option<AirDribbleOrigin>,
27}
28
29/// Whether a ball-control sequence was a ground carry or an air dribble.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
31#[ts(export)]
32#[serde(rename_all = "snake_case")]
33pub enum BallCarryKind {
34    Carry,
35    AirDribble,
36}
37
38/// Detects ground ball carries from continuous ball-control sequences.
39#[derive(Debug, Clone, Default)]
40pub struct BallCarryCalculator {
41    carry_events: EventStream<BallCarryEvent>,
42    processed_control_sequence_count: usize,
43}
44
45impl BallCarryCalculator {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    pub fn carry_events(&self) -> &[BallCarryEvent] {
51        self.carry_events.all()
52    }
53
54    pub fn new_carry_events(&self) -> &[BallCarryEvent] {
55        self.carry_events.new_events()
56    }
57
58    pub(crate) fn carry_frame_sample(
59        player: &PlayerSample,
60        ball: &BallSample,
61    ) -> Option<ContinuousBallControlSample<BallCarryKind>> {
62        let player_position = player.position()?;
63        let ball_position = ball.position();
64        let horizontal_gap = player_position
65            .truncate()
66            .distance(ball_position.truncate());
67        let vertical_gap = ball_position.z - player_position.z;
68
69        if player_is_on_wall(player_position) {
70            return None;
71        }
72
73        if !(BALL_CARRY_MIN_BALL_Z..=BALL_CARRY_MAX_BALL_Z).contains(&ball_position.z) {
74            return None;
75        }
76
77        if horizontal_gap > BALL_CARRY_MAX_HORIZONTAL_GAP {
78            return None;
79        }
80
81        if !(0.0..=BALL_CARRY_MAX_VERTICAL_GAP).contains(&vertical_gap) {
82            return None;
83        }
84
85        Some(ContinuousBallControlSample {
86            player_position,
87            kind: BallCarryKind::Carry,
88            horizontal_gap,
89            vertical_gap,
90            speed: player.speed().unwrap_or(0.0),
91        })
92    }
93
94    pub(crate) fn kind_requires_airborne(_kind: BallCarryKind) -> bool {
95        false
96    }
97
98    pub(crate) fn control_player_statuses(
99        players: &PlayerFrameState,
100    ) -> Vec<ContinuousBallControlPlayerStatus> {
101        players
102            .players
103            .iter()
104            .filter_map(|player| {
105                Some(ContinuousBallControlPlayerStatus {
106                    player_id: player.player_id.clone(),
107                    is_airborne: AirDribblePolicy::is_air_touch_position(player.position()?),
108                })
109            })
110            .collect()
111    }
112
113    pub(crate) fn control_touches(
114        touch_state: &TouchState,
115        players: &PlayerFrameState,
116    ) -> Vec<ContinuousBallControlTouch> {
117        touch_state
118            .touch_events
119            .iter()
120            .filter_map(|touch| {
121                let player_id = touch.player.clone()?;
122                let player = players
123                    .players
124                    .iter()
125                    .find(|player| player.player_id == player_id)?;
126                Some(ContinuousBallControlTouch {
127                    player_id,
128                    is_airborne: AirDribblePolicy::is_air_touch_position(player.position()?),
129                })
130            })
131            .collect()
132    }
133
134    pub(crate) fn min_duration_for_kind(kind: BallCarryKind) -> f32 {
135        match kind {
136            BallCarryKind::Carry => BALL_CARRY_MIN_DURATION,
137            BallCarryKind::AirDribble => AIR_DRIBBLE_MIN_DURATION,
138        }
139    }
140
141    pub(crate) fn control_candidate(
142        ball: &BallFrameState,
143        players: &PlayerFrameState,
144        live_play_state: &LivePlayState,
145        touch_state: &TouchState,
146    ) -> Option<ContinuousBallControlCandidate<BallCarryKind>> {
147        if !live_play_state.is_live_play {
148            return None;
149        }
150        let ball = ball.sample()?;
151        let player_id = touch_state.last_touch_player.as_ref()?;
152        let touch_count = touch_state
153            .touch_events
154            .iter()
155            .filter(|event| event.player.as_ref() == Some(player_id))
156            .count() as u32;
157        players
158            .players
159            .iter()
160            .find(|player| &player.player_id == player_id)
161            .and_then(|player| {
162                Self::carry_frame_sample(player, ball).map(|sample| {
163                    ContinuousBallControlCandidate {
164                        player_id: player.player_id.clone(),
165                        is_team_0: player.is_team_0,
166                        touch_count,
167                        air_touch_count: 0,
168                        sample,
169                    }
170                })
171            })
172    }
173
174    fn event_from_sequence(
175        sequence: CompletedBallControlSequence<BallCarryKind>,
176    ) -> BallCarryEvent {
177        BallCarryEvent {
178            player_id: sequence.player_id,
179            is_team_0: sequence.is_team_0,
180            kind: sequence.kind,
181            start_position: sequence.start_position.to_array(),
182            end_position: sequence.end_position.to_array(),
183            start_frame: sequence.start_frame,
184            end_frame: sequence.end_frame,
185            start_time: sequence.start_time,
186            end_time: sequence.end_time,
187            duration: sequence.duration,
188            straight_line_distance: sequence.straight_line_distance,
189            path_distance: sequence.path_distance,
190            average_horizontal_gap: sequence.average_horizontal_gap,
191            average_vertical_gap: sequence.average_vertical_gap,
192            average_speed: sequence.average_speed,
193            touch_count: sequence.touch_count,
194            air_touch_count: sequence.air_touch_count,
195            air_dribble_origin: None,
196        }
197    }
198
199    fn record_carry_event(&mut self, event: BallCarryEvent) {
200        self.carry_events.push(event);
201    }
202
203    pub fn update(&mut self, control_state: &ContinuousBallControlState) -> SubtrActorResult<()> {
204        self.carry_events.begin_update();
205        for sequence in control_state
206            .completed_sequences
207            .iter()
208            .skip(self.processed_control_sequence_count)
209            .cloned()
210        {
211            self.record_carry_event(Self::event_from_sequence(sequence));
212        }
213        self.processed_control_sequence_count = control_state.completed_sequences.len();
214        Ok(())
215    }
216}
217
218#[cfg(test)]
219#[path = "ball_carry_tests.rs"]
220mod tests;