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;