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/// Why a territorial-pressure session ended.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
11#[serde(rename_all = "snake_case")]
12#[ts(export)]
13pub enum TerritorialPressureEndReason {
14    Relieved,
15    Stoppage,
16    BallMissing,
17    ReplayEnd,
18}
19
20/// A session of sustained territorial pressure by one team.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
22#[ts(export)]
23pub struct TerritorialPressureEvent {
24    pub start_time: f32,
25    pub start_frame: usize,
26    pub end_time: f32,
27    pub end_frame: usize,
28    pub team_is_team_0: bool,
29    pub duration: f32,
30    pub offensive_half_time: f32,
31    pub offensive_third_time: f32,
32    pub end_reason: TerritorialPressureEndReason,
33}
34
35/// Configuration thresholds for territorial-pressure detection.
36#[derive(Debug, Clone, PartialEq)]
37pub struct TerritorialPressureCalculatorConfig {
38    pub neutral_zone_half_width_y: f32,
39    pub min_establish_seconds: f32,
40    pub min_establish_third_seconds: f32,
41    pub relief_grace_seconds: f32,
42    pub confirmed_relief_grace_seconds: f32,
43}
44
45impl Default for TerritorialPressureCalculatorConfig {
46    fn default() -> Self {
47        Self {
48            neutral_zone_half_width_y: DEFAULT_TERRITORIAL_PRESSURE_NEUTRAL_ZONE_HALF_WIDTH_Y,
49            min_establish_seconds: DEFAULT_TERRITORIAL_PRESSURE_MIN_ESTABLISH_SECONDS,
50            min_establish_third_seconds: DEFAULT_TERRITORIAL_PRESSURE_MIN_ESTABLISH_THIRD_SECONDS,
51            relief_grace_seconds: DEFAULT_TERRITORIAL_PRESSURE_RELIEF_GRACE_SECONDS,
52            confirmed_relief_grace_seconds:
53                DEFAULT_TERRITORIAL_PRESSURE_CONFIRMED_RELIEF_GRACE_SECONDS,
54        }
55    }
56}
57
58/// Tracks territorial-pressure sessions during live play.
59#[derive(Debug, Clone, Default, PartialEq)]
60pub struct TerritorialPressureCalculator {
61    config: TerritorialPressureCalculatorConfig,
62    events: EventStream<TerritorialPressureEvent>,
63    candidate: Option<CandidateTerritorialPressureSession>,
64    active: Option<ActiveTerritorialPressureSession>,
65    last_frame: Option<TerritorialPressureFrameMarker>,
66}
67
68#[derive(Debug, Clone, PartialEq)]
69struct CandidateTerritorialPressureSession {
70    team_is_team_0: bool,
71    start_time: f32,
72    start_frame: usize,
73    duration: f32,
74    offensive_half_time: f32,
75    offensive_third_time: f32,
76}
77
78#[derive(Debug, Clone, PartialEq)]
79struct ActiveTerritorialPressureSession {
80    team_is_team_0: bool,
81    start_time: f32,
82    start_frame: usize,
83    duration: f32,
84    offensive_half_time: f32,
85    offensive_third_time: f32,
86    relief_time: f32,
87    confirmed_relief_time: f32,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq)]
91struct TerritorialPressureFrameMarker {
92    frame_number: usize,
93    time: f32,
94}
95
96impl From<&FrameInfo> for TerritorialPressureFrameMarker {
97    fn from(frame: &FrameInfo) -> Self {
98        Self {
99            frame_number: frame.frame_number,
100            time: frame.time,
101        }
102    }
103}
104
105impl TerritorialPressureCalculator {
106    pub fn new() -> Self {
107        Self::with_config(TerritorialPressureCalculatorConfig::default())
108    }
109
110    pub fn with_config(config: TerritorialPressureCalculatorConfig) -> Self {
111        Self {
112            config,
113            ..Self::default()
114        }
115    }
116
117    pub fn events(&self) -> &[TerritorialPressureEvent] {
118        self.events.all()
119    }
120
121    pub fn new_events(&self) -> &[TerritorialPressureEvent] {
122        self.events.new_events()
123    }
124
125    pub fn projected_events(&self) -> Vec<TerritorialPressureEvent> {
126        let mut events = self.events.all().to_vec();
127        if let (Some(active), Some(frame)) = (&self.active, self.last_frame) {
128            events.push(Self::event_from_active_session(
129                active,
130                frame.frame_number,
131                frame.time,
132                TerritorialPressureEndReason::ReplayEnd,
133            ));
134        }
135        events
136    }
137
138    pub fn config(&self) -> &TerritorialPressureCalculatorConfig {
139        &self.config
140    }
141
142    pub fn finish(&mut self) -> SubtrActorResult<()> {
143        if let Some(frame) = self.last_frame {
144            self.end_active_session_parts(
145                frame.frame_number,
146                frame.time,
147                TerritorialPressureEndReason::ReplayEnd,
148            );
149        }
150        Ok(())
151    }
152
153    fn pressure_team_for_ball_y(&self, ball_y: f32) -> Option<bool> {
154        if ball_y > self.config.neutral_zone_half_width_y {
155            Some(true)
156        } else if ball_y < -self.config.neutral_zone_half_width_y {
157            Some(false)
158        } else {
159            None
160        }
161    }
162
163    fn normalized_ball_y(team_is_team_0: bool, ball_y: f32) -> f32 {
164        if team_is_team_0 { ball_y } else { -ball_y }
165    }
166
167    fn candidate_sample(
168        team_is_team_0: bool,
169        frame: &FrameInfo,
170        normalized_ball_y: f32,
171    ) -> CandidateTerritorialPressureSession {
172        CandidateTerritorialPressureSession {
173            team_is_team_0,
174            start_time: frame.time,
175            start_frame: frame.frame_number,
176            duration: frame.dt,
177            offensive_half_time: if normalized_ball_y > 0.0 {
178                frame.dt
179            } else {
180                0.0
181            },
182            offensive_third_time: if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
183                frame.dt
184            } else {
185                0.0
186            },
187        }
188    }
189
190    fn update_candidate(&mut self, frame: &FrameInfo, ball_y: f32) {
191        let Some(team_is_team_0) = self.pressure_team_for_ball_y(ball_y) else {
192            self.candidate = None;
193            return;
194        };
195        let normalized_ball_y = Self::normalized_ball_y(team_is_team_0, ball_y);
196
197        if self
198            .candidate
199            .as_ref()
200            .is_none_or(|candidate| candidate.team_is_team_0 != team_is_team_0)
201        {
202            self.candidate = Some(Self::candidate_sample(
203                team_is_team_0,
204                frame,
205                normalized_ball_y,
206            ));
207        } else if let Some(candidate) = &mut self.candidate {
208            candidate.duration += frame.dt;
209            if normalized_ball_y > 0.0 {
210                candidate.offensive_half_time += frame.dt;
211            }
212            if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
213                candidate.offensive_third_time += frame.dt;
214            }
215        }
216
217        let should_start = self.candidate.as_ref().is_some_and(|candidate| {
218            candidate.duration >= self.config.min_establish_seconds
219                || candidate.offensive_third_time >= self.config.min_establish_third_seconds
220        });
221        if should_start {
222            let candidate = self
223                .candidate
224                .take()
225                .expect("candidate exists when pressure should start");
226            self.start_session(frame, candidate);
227        }
228    }
229
230    fn start_session(
231        &mut self,
232        _frame: &FrameInfo,
233        candidate: CandidateTerritorialPressureSession,
234    ) {
235        self.active = Some(ActiveTerritorialPressureSession {
236            team_is_team_0: candidate.team_is_team_0,
237            start_time: candidate.start_time,
238            start_frame: candidate.start_frame,
239            duration: candidate.duration,
240            offensive_half_time: candidate.offensive_half_time,
241            offensive_third_time: candidate.offensive_third_time,
242            relief_time: 0.0,
243            confirmed_relief_time: 0.0,
244        });
245    }
246
247    fn update_active_session(
248        &mut self,
249        frame: &FrameInfo,
250        ball_y: f32,
251        possession_state: &PossessionState,
252    ) {
253        let Some(mut active) = self.active.take() else {
254            return;
255        };
256
257        let normalized_ball_y = Self::normalized_ball_y(active.team_is_team_0, ball_y);
258        active.duration += frame.dt;
259        if normalized_ball_y > 0.0 {
260            active.offensive_half_time += frame.dt;
261        }
262        if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
263            active.offensive_third_time += frame.dt;
264        }
265
266        if normalized_ball_y > self.config.neutral_zone_half_width_y {
267            active.relief_time = 0.0;
268            active.confirmed_relief_time = 0.0;
269        } else {
270            active.relief_time += frame.dt;
271            if possession_state.active_team_before_sample == Some(!active.team_is_team_0) {
272                active.confirmed_relief_time += frame.dt;
273            } else {
274                active.confirmed_relief_time = 0.0;
275            }
276        }
277
278        let relieved = active.confirmed_relief_time >= self.config.confirmed_relief_grace_seconds
279            || active.relief_time >= self.config.relief_grace_seconds;
280
281        self.active = Some(active);
282        if relieved {
283            self.end_active_session(frame, TerritorialPressureEndReason::Relieved);
284        }
285    }
286
287    fn end_active_session(&mut self, frame: &FrameInfo, end_reason: TerritorialPressureEndReason) {
288        self.end_active_session_parts(frame.frame_number, frame.time, end_reason);
289    }
290
291    fn event_from_active_session(
292        active: &ActiveTerritorialPressureSession,
293        end_frame: usize,
294        end_time: f32,
295        end_reason: TerritorialPressureEndReason,
296    ) -> TerritorialPressureEvent {
297        TerritorialPressureEvent {
298            start_time: active.start_time,
299            start_frame: active.start_frame,
300            end_time,
301            end_frame,
302            team_is_team_0: active.team_is_team_0,
303            duration: active.duration,
304            offensive_half_time: active.offensive_half_time,
305            offensive_third_time: active.offensive_third_time,
306            end_reason,
307        }
308    }
309
310    fn end_active_session_parts(
311        &mut self,
312        end_frame: usize,
313        end_time: f32,
314        end_reason: TerritorialPressureEndReason,
315    ) {
316        let Some(active) = self.active.take() else {
317            return;
318        };
319        self.events.push(Self::event_from_active_session(
320            &active, end_frame, end_time, end_reason,
321        ));
322    }
323
324    pub fn update(
325        &mut self,
326        frame: &FrameInfo,
327        ball: &BallFrameState,
328        possession_state: &PossessionState,
329        live_play_state: &LivePlayState,
330    ) -> SubtrActorResult<()> {
331        self.events.begin_update();
332        self.last_frame = Some(frame.into());
333        if !live_play_state.is_live_play {
334            self.candidate = None;
335            self.end_active_session(frame, TerritorialPressureEndReason::Stoppage);
336            return Ok(());
337        }
338
339        let Some(ball) = ball.sample() else {
340            self.candidate = None;
341            self.end_active_session(frame, TerritorialPressureEndReason::BallMissing);
342            return Ok(());
343        };
344
345        if self.active.is_some() {
346            self.update_active_session(frame, ball.position().y, possession_state);
347        } else {
348            self.update_candidate(frame, ball.position().y);
349        }
350        Ok(())
351    }
352}
353
354#[cfg(test)]
355#[path = "territorial_pressure_tests.rs"]
356mod tests;