subtr_actor/stats/calculators/
territorial_pressure.rs1use 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)]
11#[serde(rename_all = "snake_case")]
12#[ts(export)]
13pub enum TerritorialPressureEndReason {
14 Relieved,
15 Stoppage,
16 BallMissing,
17 ReplayEnd,
18}
19
20#[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#[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#[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;