subtr_actor/stats/calculators/
possession.rs1use super::*;
2
3const PENDING_TURNOVER_CONFIRMATION_WINDOW_SECONDS: f32 = 1.25;
4const LOOSE_BALL_TIMEOUT_SECONDS: f32 = 3.0;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7enum PossessionStateLabel {
8 TeamZero,
9 TeamOne,
10 Neutral,
11}
12
13impl PossessionStateLabel {
14 fn as_label(self) -> StatLabel {
15 let value = match self {
16 Self::TeamZero => "team_zero",
17 Self::TeamOne => "team_one",
18 Self::Neutral => "neutral",
19 };
20 StatLabel::new("possession_state", value)
21 }
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25#[allow(clippy::enum_variant_names)]
26enum FieldThirdLabel {
27 TeamZeroThird,
28 NeutralThird,
29 TeamOneThird,
30}
31
32impl FieldThirdLabel {
33 fn from_ball(ball: &BallSample) -> Self {
34 let ball_y = ball.position().y;
35 if ball_y < -FIELD_ZONE_BOUNDARY_Y {
36 Self::TeamZeroThird
37 } else if ball_y > FIELD_ZONE_BOUNDARY_Y {
38 Self::TeamOneThird
39 } else {
40 Self::NeutralThird
41 }
42 }
43
44 fn as_label(self) -> StatLabel {
45 let value = match self {
46 Self::TeamZeroThird => "team_zero_third",
47 Self::NeutralThird => "neutral_third",
48 Self::TeamOneThird => "team_one_third",
49 };
50 StatLabel::new("field_third", value)
51 }
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
55pub struct PossessionStats {
56 pub tracked_time: f32,
57 pub team_zero_time: f32,
58 pub team_one_time: f32,
59 pub neutral_time: f32,
60 #[serde(default, skip_serializing_if = "LabeledFloatSums::is_empty")]
61 pub labeled_time: LabeledFloatSums,
62}
63
64impl PossessionStats {
65 pub fn team_zero_pct(&self) -> f32 {
66 if self.tracked_time == 0.0 {
67 0.0
68 } else {
69 self.team_zero_time * 100.0 / self.tracked_time
70 }
71 }
72
73 pub fn team_one_pct(&self) -> f32 {
74 if self.tracked_time == 0.0 {
75 0.0
76 } else {
77 self.team_one_time * 100.0 / self.tracked_time
78 }
79 }
80
81 pub fn neutral_pct(&self) -> f32 {
82 if self.tracked_time == 0.0 {
83 0.0
84 } else {
85 self.neutral_time * 100.0 / self.tracked_time
86 }
87 }
88
89 pub fn time_with_labels(&self, labels: &[StatLabel]) -> f32 {
90 self.labeled_time.sum_matching(labels)
91 }
92
93 pub fn for_team(&self, is_team_zero: bool) -> PossessionTeamStats {
94 let (possession_time, opponent_possession_time) = if is_team_zero {
95 (self.team_zero_time, self.team_one_time)
96 } else {
97 (self.team_one_time, self.team_zero_time)
98 };
99
100 let mut labeled_time = LabeledFloatSums::default();
101 for entry in &self.labeled_time.entries {
102 labeled_time.add(
103 entry
104 .labels
105 .iter()
106 .map(|label| team_relative_possession_label(label, is_team_zero)),
107 entry.value,
108 );
109 }
110
111 PossessionTeamStats {
112 tracked_time: self.tracked_time,
113 possession_time,
114 opponent_possession_time,
115 neutral_time: self.neutral_time,
116 labeled_time,
117 }
118 }
119}
120
121#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
122#[ts(export)]
123pub struct PossessionTeamStats {
124 pub tracked_time: f32,
125 pub possession_time: f32,
126 pub opponent_possession_time: f32,
127 pub neutral_time: f32,
128 #[serde(default, skip_serializing_if = "LabeledFloatSums::is_empty")]
129 pub labeled_time: LabeledFloatSums,
130}
131
132fn team_relative_possession_label(label: &StatLabel, is_team_zero: bool) -> StatLabel {
133 match (label.key, label.value) {
134 ("possession_state", "team_zero") => StatLabel::new(
135 "possession_state",
136 if is_team_zero { "own" } else { "opponent" },
137 ),
138 ("possession_state", "team_one") => StatLabel::new(
139 "possession_state",
140 if is_team_zero { "opponent" } else { "own" },
141 ),
142 ("field_third", "team_zero_third") => StatLabel::new(
143 "field_third",
144 if is_team_zero {
145 "defensive_third"
146 } else {
147 "offensive_third"
148 },
149 ),
150 ("field_third", "team_one_third") => StatLabel::new(
151 "field_third",
152 if is_team_zero {
153 "offensive_third"
154 } else {
155 "defensive_third"
156 },
157 ),
158 _ => label.clone(),
159 }
160}
161
162#[derive(Debug, Clone, Default, PartialEq)]
163pub(crate) struct PossessionTracker {
164 current_team_is_team_0: Option<bool>,
165 current_player: Option<PlayerId>,
166 last_possession_touch_time: Option<f32>,
167 pending_turnover_team_is_team_0: Option<bool>,
168 pending_turnover_touch_time: Option<f32>,
169}
170
171impl PossessionTracker {
172 fn clear_pending_turnover(&mut self) {
173 self.pending_turnover_team_is_team_0 = None;
174 self.pending_turnover_touch_time = None;
175 }
176
177 pub(crate) fn reset(&mut self) {
178 self.current_team_is_team_0 = None;
179 self.current_player = None;
180 self.last_possession_touch_time = None;
181 self.clear_pending_turnover();
182 }
183
184 fn expire_pending_turnover(&mut self, time: f32) {
185 let Some(pending_time) = self.pending_turnover_touch_time else {
186 return;
187 };
188 if time - pending_time < PENDING_TURNOVER_CONFIRMATION_WINDOW_SECONDS {
189 return;
190 }
191
192 self.current_team_is_team_0 = None;
193 self.current_player = None;
194 self.last_possession_touch_time = None;
195 self.clear_pending_turnover();
196 }
197
198 fn expire_loose_ball(&mut self, time: f32) {
199 if self.pending_turnover_team_is_team_0.is_some() {
200 return;
201 }
202 let Some(last_touch_time) = self.last_possession_touch_time else {
203 return;
204 };
205 if time - last_touch_time < LOOSE_BALL_TIMEOUT_SECONDS {
206 return;
207 }
208
209 self.current_team_is_team_0 = None;
210 self.current_player = None;
211 self.last_possession_touch_time = None;
212 }
213
214 fn register_single_team_touch(&mut self, team_is_team_0: bool, time: f32) {
215 if self.current_team_is_team_0 == Some(team_is_team_0) {
216 self.last_possession_touch_time = Some(time);
217 self.clear_pending_turnover();
218 return;
219 }
220
221 if self.current_team_is_team_0.is_none() {
222 self.current_team_is_team_0 = Some(team_is_team_0);
223 self.last_possession_touch_time = Some(time);
224 self.clear_pending_turnover();
225 return;
226 }
227
228 if self.pending_turnover_team_is_team_0 == Some(team_is_team_0) {
229 self.current_team_is_team_0 = Some(team_is_team_0);
230 self.last_possession_touch_time = Some(time);
231 self.clear_pending_turnover();
232 return;
233 }
234
235 self.pending_turnover_team_is_team_0 = Some(team_is_team_0);
236 self.pending_turnover_touch_time = Some(time);
237 }
238
239 fn register_contested_touch(&mut self, time: f32) {
240 let Some(current_team_is_team_0) = self.current_team_is_team_0 else {
241 self.clear_pending_turnover();
242 return;
243 };
244
245 self.last_possession_touch_time = Some(time);
246 self.pending_turnover_team_is_team_0 = Some(!current_team_is_team_0);
247 self.pending_turnover_touch_time = Some(time);
248 }
249
250 fn update_player_control(
251 &mut self,
252 active_team_before_sample: Option<bool>,
253 touched_team_zero_player: Option<&PlayerId>,
254 touched_team_one_player: Option<&PlayerId>,
255 ) {
256 let Some(current_team_is_team_0) = self.current_team_is_team_0 else {
257 self.current_player = None;
258 return;
259 };
260
261 if self.pending_turnover_team_is_team_0.is_some() {
262 self.current_player = None;
263 return;
264 }
265
266 let controlling_touch_player = if current_team_is_team_0 {
267 touched_team_zero_player
268 } else {
269 touched_team_one_player
270 };
271 if let Some(player) = controlling_touch_player {
272 self.current_player = Some(player.clone());
273 return;
274 }
275
276 if active_team_before_sample != self.current_team_is_team_0 {
277 self.current_player = None;
278 }
279 }
280
281 pub(crate) fn update(&mut self, time: f32, touch_events: &[TouchEvent]) -> PossessionState {
282 self.expire_pending_turnover(time);
283 self.expire_loose_ball(time);
284
285 let active_team_before_sample = self.current_team_is_team_0;
286 let active_player_before_sample = self.current_player.clone();
287 let touched_team_zero = touch_events.iter().any(|touch| touch.team_is_team_0);
288 let touched_team_one = touch_events.iter().any(|touch| !touch.team_is_team_0);
289 let touched_team_zero_player = touch_events
290 .iter()
291 .rev()
292 .find(|touch| touch.team_is_team_0)
293 .and_then(|touch| touch.player.clone());
294 let touched_team_one_player = touch_events
295 .iter()
296 .rev()
297 .find(|touch| !touch.team_is_team_0)
298 .and_then(|touch| touch.player.clone());
299
300 match (touched_team_zero, touched_team_one) {
301 (true, true) => self.register_contested_touch(time),
302 (true, false) => self.register_single_team_touch(true, time),
303 (false, true) => self.register_single_team_touch(false, time),
304 (false, false) => {}
305 }
306 self.update_player_control(
307 active_team_before_sample,
308 touched_team_zero_player.as_ref(),
309 touched_team_one_player.as_ref(),
310 );
311
312 PossessionState {
313 active_team_before_sample,
314 current_team_is_team_0: self.current_team_is_team_0,
315 active_player_before_sample,
316 current_player: self.current_player.clone(),
317 }
318 }
319}
320
321#[derive(Debug, Clone, Default, PartialEq)]
322pub struct PossessionCalculator {
323 stats: PossessionStats,
324 tracker: PossessionTracker,
325}
326
327impl PossessionCalculator {
328 pub fn new() -> Self {
329 Self::default()
330 }
331
332 pub fn stats(&self) -> &PossessionStats {
333 &self.stats
334 }
335
336 fn apply_possession_time(
337 stats: &mut PossessionStats,
338 state: PossessionStateLabel,
339 field_third: Option<FieldThirdLabel>,
340 dt: f32,
341 ) {
342 match state {
343 PossessionStateLabel::TeamZero => stats.team_zero_time += dt,
344 PossessionStateLabel::TeamOne => stats.team_one_time += dt,
345 PossessionStateLabel::Neutral => stats.neutral_time += dt,
346 }
347 if let Some(field_third) = field_third {
348 stats
349 .labeled_time
350 .add([state.as_label(), field_third.as_label()], dt);
351 } else {
352 stats.labeled_time.add([state.as_label()], dt);
353 }
354 }
355
356 pub fn update(
357 &mut self,
358 frame: &FrameInfo,
359 ball: &BallFrameState,
360 possession_state: &PossessionState,
361 live_play_state: &LivePlayState,
362 ) -> SubtrActorResult<()> {
363 if live_play_state.is_live_play {
364 self.stats.tracked_time += frame.dt;
365 let field_third = ball.sample().map(FieldThirdLabel::from_ball);
366 if let Some(possession_team_is_team_0) = possession_state.active_team_before_sample {
367 let state = if possession_team_is_team_0 {
368 PossessionStateLabel::TeamZero
369 } else {
370 PossessionStateLabel::TeamOne
371 };
372 Self::apply_possession_time(&mut self.stats, state, field_third, frame.dt);
373 } else {
374 Self::apply_possession_time(
375 &mut self.stats,
376 PossessionStateLabel::Neutral,
377 field_third,
378 frame.dt,
379 );
380 }
381 }
382 Ok(())
383 }
384}