Skip to main content

subtr_actor/stats/calculators/
territorial_pressure.rs

1use super::*;
2
3const DEFAULT_TERRITORIAL_PRESSURE_NEUTRAL_ZONE_HALF_WIDTH_Y: f32 = 200.0;
4const DEFAULT_TERRITORIAL_PRESSURE_MIN_ESTABLISH_SECONDS: f32 = 2.0;
5const DEFAULT_TERRITORIAL_PRESSURE_MIN_ESTABLISH_THIRD_SECONDS: f32 = 0.75;
6const DEFAULT_TERRITORIAL_PRESSURE_RELIEF_GRACE_SECONDS: f32 = 3.0;
7const DEFAULT_TERRITORIAL_PRESSURE_CONFIRMED_RELIEF_GRACE_SECONDS: f32 = 1.25;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
10#[serde(rename_all = "snake_case")]
11#[ts(export)]
12pub enum TerritorialPressureEndReason {
13    Relieved,
14    Stoppage,
15    BallMissing,
16    ReplayEnd,
17}
18
19#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
20pub struct TerritorialPressureStats {
21    pub tracked_time: f32,
22    pub team_zero_session_count: u32,
23    pub team_one_session_count: u32,
24    pub team_zero_session_time: f32,
25    pub team_one_session_time: f32,
26    pub team_zero_offensive_half_time: f32,
27    pub team_one_offensive_half_time: f32,
28    pub team_zero_offensive_third_time: f32,
29    pub team_one_offensive_third_time: f32,
30    pub team_zero_longest_session_time: f32,
31    pub team_one_longest_session_time: f32,
32    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
33    pub labeled_session_counts: LabeledCounts,
34    #[serde(default, skip_serializing_if = "LabeledFloatSums::is_empty")]
35    pub labeled_time: LabeledFloatSums,
36}
37
38impl TerritorialPressureStats {
39    pub fn for_team(&self, is_team_zero: bool) -> TerritorialPressureTeamStats {
40        let (
41            session_count,
42            opponent_session_count,
43            session_time,
44            opponent_session_time,
45            offensive_half_time,
46            offensive_third_time,
47            longest_session_time,
48            opponent_longest_session_time,
49        ) = if is_team_zero {
50            (
51                self.team_zero_session_count,
52                self.team_one_session_count,
53                self.team_zero_session_time,
54                self.team_one_session_time,
55                self.team_zero_offensive_half_time,
56                self.team_zero_offensive_third_time,
57                self.team_zero_longest_session_time,
58                self.team_one_longest_session_time,
59            )
60        } else {
61            (
62                self.team_one_session_count,
63                self.team_zero_session_count,
64                self.team_one_session_time,
65                self.team_zero_session_time,
66                self.team_one_offensive_half_time,
67                self.team_one_offensive_third_time,
68                self.team_one_longest_session_time,
69                self.team_zero_longest_session_time,
70            )
71        };
72
73        let average_session_time = if session_count == 0 {
74            0.0
75        } else {
76            session_time / session_count as f32
77        };
78
79        TerritorialPressureTeamStats {
80            tracked_time: self.tracked_time,
81            session_count,
82            opponent_session_count,
83            session_time,
84            opponent_session_time,
85            offensive_half_time,
86            offensive_third_time,
87            longest_session_time,
88            opponent_longest_session_time,
89            average_session_time,
90        }
91    }
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
95#[ts(export)]
96pub struct TerritorialPressureTeamStats {
97    pub tracked_time: f32,
98    pub session_count: u32,
99    pub opponent_session_count: u32,
100    pub session_time: f32,
101    pub opponent_session_time: f32,
102    pub offensive_half_time: f32,
103    pub offensive_third_time: f32,
104    pub longest_session_time: f32,
105    pub opponent_longest_session_time: f32,
106    pub average_session_time: f32,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
110#[ts(export)]
111pub struct TerritorialPressureEvent {
112    pub start_time: f32,
113    pub start_frame: usize,
114    pub end_time: f32,
115    pub end_frame: usize,
116    pub team_is_team_0: bool,
117    pub duration: f32,
118    pub offensive_half_time: f32,
119    pub offensive_third_time: f32,
120    pub end_reason: TerritorialPressureEndReason,
121}
122
123#[derive(Debug, Clone, PartialEq)]
124pub struct TerritorialPressureCalculatorConfig {
125    pub neutral_zone_half_width_y: f32,
126    pub min_establish_seconds: f32,
127    pub min_establish_third_seconds: f32,
128    pub relief_grace_seconds: f32,
129    pub confirmed_relief_grace_seconds: f32,
130}
131
132impl Default for TerritorialPressureCalculatorConfig {
133    fn default() -> Self {
134        Self {
135            neutral_zone_half_width_y: DEFAULT_TERRITORIAL_PRESSURE_NEUTRAL_ZONE_HALF_WIDTH_Y,
136            min_establish_seconds: DEFAULT_TERRITORIAL_PRESSURE_MIN_ESTABLISH_SECONDS,
137            min_establish_third_seconds: DEFAULT_TERRITORIAL_PRESSURE_MIN_ESTABLISH_THIRD_SECONDS,
138            relief_grace_seconds: DEFAULT_TERRITORIAL_PRESSURE_RELIEF_GRACE_SECONDS,
139            confirmed_relief_grace_seconds:
140                DEFAULT_TERRITORIAL_PRESSURE_CONFIRMED_RELIEF_GRACE_SECONDS,
141        }
142    }
143}
144
145#[derive(Debug, Clone, Default, PartialEq)]
146pub struct TerritorialPressureCalculator {
147    config: TerritorialPressureCalculatorConfig,
148    stats: TerritorialPressureStats,
149    events: Vec<TerritorialPressureEvent>,
150    candidate: Option<CandidateTerritorialPressureSession>,
151    active: Option<ActiveTerritorialPressureSession>,
152    last_frame: Option<TerritorialPressureFrameMarker>,
153}
154
155#[derive(Debug, Clone, PartialEq)]
156struct CandidateTerritorialPressureSession {
157    team_is_team_0: bool,
158    start_time: f32,
159    start_frame: usize,
160    duration: f32,
161    offensive_half_time: f32,
162    offensive_third_time: f32,
163}
164
165#[derive(Debug, Clone, PartialEq)]
166struct ActiveTerritorialPressureSession {
167    team_is_team_0: bool,
168    start_time: f32,
169    start_frame: usize,
170    duration: f32,
171    offensive_half_time: f32,
172    offensive_third_time: f32,
173    relief_time: f32,
174    confirmed_relief_time: f32,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq)]
178struct TerritorialPressureFrameMarker {
179    frame_number: usize,
180    time: f32,
181}
182
183impl From<&FrameInfo> for TerritorialPressureFrameMarker {
184    fn from(frame: &FrameInfo) -> Self {
185        Self {
186            frame_number: frame.frame_number,
187            time: frame.time,
188        }
189    }
190}
191
192impl TerritorialPressureCalculator {
193    pub fn new() -> Self {
194        Self::with_config(TerritorialPressureCalculatorConfig::default())
195    }
196
197    pub fn with_config(config: TerritorialPressureCalculatorConfig) -> Self {
198        Self {
199            config,
200            ..Self::default()
201        }
202    }
203
204    pub fn stats(&self) -> &TerritorialPressureStats {
205        &self.stats
206    }
207
208    pub fn events(&self) -> &[TerritorialPressureEvent] {
209        &self.events
210    }
211
212    pub fn config(&self) -> &TerritorialPressureCalculatorConfig {
213        &self.config
214    }
215
216    pub fn finish(&mut self) -> SubtrActorResult<()> {
217        if let Some(frame) = self.last_frame {
218            self.end_active_session_parts(
219                frame.frame_number,
220                frame.time,
221                TerritorialPressureEndReason::ReplayEnd,
222            );
223        }
224        Ok(())
225    }
226
227    fn pressure_team_for_ball_y(&self, ball_y: f32) -> Option<bool> {
228        if ball_y > self.config.neutral_zone_half_width_y {
229            Some(true)
230        } else if ball_y < -self.config.neutral_zone_half_width_y {
231            Some(false)
232        } else {
233            None
234        }
235    }
236
237    fn normalized_ball_y(team_is_team_0: bool, ball_y: f32) -> f32 {
238        if team_is_team_0 {
239            ball_y
240        } else {
241            -ball_y
242        }
243    }
244
245    fn pressure_team_label(team_is_team_0: bool) -> StatLabel {
246        StatLabel::new(
247            "pressure_team",
248            if team_is_team_0 {
249                "team_zero"
250            } else {
251                "team_one"
252            },
253        )
254    }
255
256    fn territory_label(normalized_ball_y: f32) -> StatLabel {
257        if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
258            StatLabel::new("territory", "offensive_third")
259        } else if normalized_ball_y > 0.0 {
260            StatLabel::new("territory", "offensive_half")
261        } else {
262            StatLabel::new("territory", "relief")
263        }
264    }
265
266    fn add_session_count(&mut self, team_is_team_0: bool) {
267        if team_is_team_0 {
268            self.stats.team_zero_session_count += 1;
269        } else {
270            self.stats.team_one_session_count += 1;
271        }
272        self.stats
273            .labeled_session_counts
274            .increment([Self::pressure_team_label(team_is_team_0)]);
275    }
276
277    fn add_session_time(&mut self, team_is_team_0: bool, normalized_ball_y: f32, dt: f32) {
278        if team_is_team_0 {
279            self.stats.team_zero_session_time += dt;
280            if normalized_ball_y > 0.0 {
281                self.stats.team_zero_offensive_half_time += dt;
282            }
283            if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
284                self.stats.team_zero_offensive_third_time += dt;
285            }
286        } else {
287            self.stats.team_one_session_time += dt;
288            if normalized_ball_y > 0.0 {
289                self.stats.team_one_offensive_half_time += dt;
290            }
291            if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
292                self.stats.team_one_offensive_third_time += dt;
293            }
294        }
295
296        self.stats.labeled_time.add(
297            [
298                Self::pressure_team_label(team_is_team_0),
299                Self::territory_label(normalized_ball_y),
300            ],
301            dt,
302        );
303    }
304
305    fn update_longest_session_time(&mut self, team_is_team_0: bool, duration: f32) {
306        if team_is_team_0 {
307            self.stats.team_zero_longest_session_time =
308                self.stats.team_zero_longest_session_time.max(duration);
309        } else {
310            self.stats.team_one_longest_session_time =
311                self.stats.team_one_longest_session_time.max(duration);
312        }
313    }
314
315    fn candidate_sample(
316        team_is_team_0: bool,
317        frame: &FrameInfo,
318        normalized_ball_y: f32,
319    ) -> CandidateTerritorialPressureSession {
320        CandidateTerritorialPressureSession {
321            team_is_team_0,
322            start_time: frame.time,
323            start_frame: frame.frame_number,
324            duration: frame.dt,
325            offensive_half_time: if normalized_ball_y > 0.0 {
326                frame.dt
327            } else {
328                0.0
329            },
330            offensive_third_time: if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
331                frame.dt
332            } else {
333                0.0
334            },
335        }
336    }
337
338    fn update_candidate(&mut self, frame: &FrameInfo, ball_y: f32) {
339        let Some(team_is_team_0) = self.pressure_team_for_ball_y(ball_y) else {
340            self.candidate = None;
341            return;
342        };
343        let normalized_ball_y = Self::normalized_ball_y(team_is_team_0, ball_y);
344
345        if self
346            .candidate
347            .as_ref()
348            .is_none_or(|candidate| candidate.team_is_team_0 != team_is_team_0)
349        {
350            self.candidate = Some(Self::candidate_sample(
351                team_is_team_0,
352                frame,
353                normalized_ball_y,
354            ));
355        } else if let Some(candidate) = &mut self.candidate {
356            candidate.duration += frame.dt;
357            if normalized_ball_y > 0.0 {
358                candidate.offensive_half_time += frame.dt;
359            }
360            if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
361                candidate.offensive_third_time += frame.dt;
362            }
363        }
364
365        let should_start = self.candidate.as_ref().is_some_and(|candidate| {
366            candidate.duration >= self.config.min_establish_seconds
367                || candidate.offensive_third_time >= self.config.min_establish_third_seconds
368        });
369        if should_start {
370            let candidate = self
371                .candidate
372                .take()
373                .expect("candidate exists when pressure should start");
374            self.start_session(candidate);
375        }
376    }
377
378    fn start_session(&mut self, candidate: CandidateTerritorialPressureSession) {
379        self.add_session_count(candidate.team_is_team_0);
380        self.add_session_time(
381            candidate.team_is_team_0,
382            1.0,
383            candidate.offensive_half_time - candidate.offensive_third_time,
384        );
385        self.add_session_time(
386            candidate.team_is_team_0,
387            FIELD_ZONE_BOUNDARY_Y + 1.0,
388            candidate.offensive_third_time,
389        );
390        self.update_longest_session_time(candidate.team_is_team_0, candidate.duration);
391        self.active = Some(ActiveTerritorialPressureSession {
392            team_is_team_0: candidate.team_is_team_0,
393            start_time: candidate.start_time,
394            start_frame: candidate.start_frame,
395            duration: candidate.duration,
396            offensive_half_time: candidate.offensive_half_time,
397            offensive_third_time: candidate.offensive_third_time,
398            relief_time: 0.0,
399            confirmed_relief_time: 0.0,
400        });
401    }
402
403    fn update_active_session(
404        &mut self,
405        frame: &FrameInfo,
406        ball_y: f32,
407        possession_state: &PossessionState,
408    ) {
409        let Some(mut active) = self.active.take() else {
410            return;
411        };
412
413        let normalized_ball_y = Self::normalized_ball_y(active.team_is_team_0, ball_y);
414        active.duration += frame.dt;
415        if normalized_ball_y > 0.0 {
416            active.offensive_half_time += frame.dt;
417        }
418        if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
419            active.offensive_third_time += frame.dt;
420        }
421        self.add_session_time(active.team_is_team_0, normalized_ball_y, frame.dt);
422        self.update_longest_session_time(active.team_is_team_0, active.duration);
423
424        if normalized_ball_y > self.config.neutral_zone_half_width_y {
425            active.relief_time = 0.0;
426            active.confirmed_relief_time = 0.0;
427        } else {
428            active.relief_time += frame.dt;
429            if possession_state.active_team_before_sample == Some(!active.team_is_team_0) {
430                active.confirmed_relief_time += frame.dt;
431            } else {
432                active.confirmed_relief_time = 0.0;
433            }
434        }
435
436        let relieved = active.confirmed_relief_time >= self.config.confirmed_relief_grace_seconds
437            || active.relief_time >= self.config.relief_grace_seconds;
438
439        self.active = Some(active);
440        if relieved {
441            self.end_active_session(frame, TerritorialPressureEndReason::Relieved);
442        }
443    }
444
445    fn end_active_session(&mut self, frame: &FrameInfo, end_reason: TerritorialPressureEndReason) {
446        self.end_active_session_parts(frame.frame_number, frame.time, end_reason);
447    }
448
449    fn end_active_session_parts(
450        &mut self,
451        end_frame: usize,
452        end_time: f32,
453        end_reason: TerritorialPressureEndReason,
454    ) {
455        let Some(active) = self.active.take() else {
456            return;
457        };
458        self.events.push(TerritorialPressureEvent {
459            start_time: active.start_time,
460            start_frame: active.start_frame,
461            end_time,
462            end_frame,
463            team_is_team_0: active.team_is_team_0,
464            duration: active.duration,
465            offensive_half_time: active.offensive_half_time,
466            offensive_third_time: active.offensive_third_time,
467            end_reason,
468        });
469    }
470
471    pub fn update(
472        &mut self,
473        frame: &FrameInfo,
474        ball: &BallFrameState,
475        possession_state: &PossessionState,
476        live_play_state: &LivePlayState,
477    ) -> SubtrActorResult<()> {
478        self.last_frame = Some(frame.into());
479        if !live_play_state.is_live_play {
480            self.candidate = None;
481            self.end_active_session(frame, TerritorialPressureEndReason::Stoppage);
482            return Ok(());
483        }
484
485        let Some(ball) = ball.sample() else {
486            self.candidate = None;
487            self.end_active_session(frame, TerritorialPressureEndReason::BallMissing);
488            return Ok(());
489        };
490
491        self.stats.tracked_time += frame.dt;
492        if self.active.is_some() {
493            self.update_active_session(frame, ball.position().y, possession_state);
494        } else {
495            self.update_candidate(frame, ball.position().y);
496        }
497        Ok(())
498    }
499}
500
501#[cfg(test)]
502#[path = "territorial_pressure_tests.rs"]
503mod tests;