Skip to main content

subtr_actor/stats/calculators/
pressure.rs

1use super::*;
2
3const DEFAULT_PRESSURE_NEUTRAL_ZONE_HALF_WIDTH_Y: f32 = 200.0;
4
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
6enum PressureHalfLabel {
7    TeamZeroSide,
8    TeamOneSide,
9    #[default]
10    Neutral,
11}
12
13impl PressureHalfLabel {
14    fn as_label_value(self) -> &'static str {
15        match self {
16            Self::TeamZeroSide => "team_zero_side",
17            Self::TeamOneSide => "team_one_side",
18            Self::Neutral => "neutral",
19        }
20    }
21
22    fn as_label(self) -> StatLabel {
23        StatLabel::new("field_half", self.as_label_value())
24    }
25}
26
27#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
28pub struct PressureStats {
29    pub tracked_time: f32,
30    pub team_zero_side_time: f32,
31    pub team_one_side_time: f32,
32    pub neutral_time: f32,
33    #[serde(default, skip_serializing_if = "LabeledFloatSums::is_empty")]
34    pub labeled_time: LabeledFloatSums,
35}
36
37impl PressureStats {
38    pub fn team_zero_side_pct(&self) -> f32 {
39        if self.tracked_time == 0.0 {
40            0.0
41        } else {
42            self.team_zero_side_time * 100.0 / self.tracked_time
43        }
44    }
45
46    pub fn team_one_side_pct(&self) -> f32 {
47        if self.tracked_time == 0.0 {
48            0.0
49        } else {
50            self.team_one_side_time * 100.0 / self.tracked_time
51        }
52    }
53
54    pub fn neutral_pct(&self) -> f32 {
55        if self.tracked_time == 0.0 {
56            0.0
57        } else {
58            self.neutral_time * 100.0 / self.tracked_time
59        }
60    }
61
62    pub fn time_with_labels(&self, labels: &[StatLabel]) -> f32 {
63        self.labeled_time.sum_matching(labels)
64    }
65
66    pub fn for_team(&self, is_team_zero: bool) -> PressureTeamStats {
67        let (defensive_half_time, offensive_half_time) = if is_team_zero {
68            (self.team_zero_side_time, self.team_one_side_time)
69        } else {
70            (self.team_one_side_time, self.team_zero_side_time)
71        };
72
73        let mut labeled_time = LabeledFloatSums::default();
74        for entry in &self.labeled_time.entries {
75            labeled_time.add(
76                entry
77                    .labels
78                    .iter()
79                    .map(|label| team_relative_pressure_label(label, is_team_zero)),
80                entry.value,
81            );
82        }
83
84        PressureTeamStats {
85            tracked_time: self.tracked_time,
86            defensive_half_time,
87            offensive_half_time,
88            neutral_time: self.neutral_time,
89            labeled_time,
90        }
91    }
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
95#[ts(export)]
96pub struct PressureTeamStats {
97    pub tracked_time: f32,
98    pub defensive_half_time: f32,
99    pub offensive_half_time: f32,
100    pub neutral_time: f32,
101    #[serde(default, skip_serializing_if = "LabeledFloatSums::is_empty")]
102    pub labeled_time: LabeledFloatSums,
103}
104
105#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
106#[ts(export)]
107pub struct PressureEvent {
108    pub time: f32,
109    pub frame: usize,
110    pub active: bool,
111    pub field_half: String,
112}
113
114fn team_relative_pressure_label(label: &StatLabel, is_team_zero: bool) -> StatLabel {
115    match (label.key, label.value) {
116        ("field_half", "team_zero_side") => StatLabel::new(
117            "field_half",
118            if is_team_zero {
119                "defensive_half"
120            } else {
121                "offensive_half"
122            },
123        ),
124        ("field_half", "team_one_side") => StatLabel::new(
125            "field_half",
126            if is_team_zero {
127                "offensive_half"
128            } else {
129                "defensive_half"
130            },
131        ),
132        _ => label.clone(),
133    }
134}
135
136#[derive(Debug, Clone, PartialEq)]
137pub struct PressureCalculatorConfig {
138    pub neutral_zone_half_width_y: f32,
139}
140
141impl Default for PressureCalculatorConfig {
142    fn default() -> Self {
143        Self {
144            neutral_zone_half_width_y: DEFAULT_PRESSURE_NEUTRAL_ZONE_HALF_WIDTH_Y,
145        }
146    }
147}
148
149#[derive(Debug, Clone, Default, PartialEq)]
150pub struct PressureCalculator {
151    config: PressureCalculatorConfig,
152    stats: PressureStats,
153    events: Vec<PressureEvent>,
154    last_emitted_event_state: Option<PressureEventState>,
155}
156
157#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
158struct PressureEventState {
159    active: bool,
160    field_half: PressureHalfLabel,
161}
162
163impl PressureCalculator {
164    pub fn new() -> Self {
165        Self::with_config(PressureCalculatorConfig::default())
166    }
167
168    pub fn with_config(config: PressureCalculatorConfig) -> Self {
169        Self {
170            config,
171            ..Self::default()
172        }
173    }
174
175    pub fn stats(&self) -> &PressureStats {
176        &self.stats
177    }
178
179    pub fn events(&self) -> &[PressureEvent] {
180        &self.events
181    }
182
183    pub fn config(&self) -> &PressureCalculatorConfig {
184        &self.config
185    }
186
187    pub fn team_zero_side_duration(&self) -> f32 {
188        self.stats.team_zero_side_time
189    }
190
191    pub fn team_one_side_duration(&self) -> f32 {
192        self.stats.team_one_side_time
193    }
194
195    pub fn neutral_duration(&self) -> f32 {
196        self.stats.neutral_time
197    }
198
199    pub fn total_tracked_duration(&self) -> f32 {
200        self.stats.tracked_time
201    }
202
203    pub fn team_zero_side_pct(&self) -> f32 {
204        self.stats.team_zero_side_pct()
205    }
206
207    pub fn team_one_side_pct(&self) -> f32 {
208        self.stats.team_one_side_pct()
209    }
210
211    pub fn neutral_pct(&self) -> f32 {
212        self.stats.neutral_pct()
213    }
214
215    fn apply_pressure_time(stats: &mut PressureStats, half: PressureHalfLabel, dt: f32) {
216        match half {
217            PressureHalfLabel::TeamZeroSide => stats.team_zero_side_time += dt,
218            PressureHalfLabel::TeamOneSide => stats.team_one_side_time += dt,
219            PressureHalfLabel::Neutral => stats.neutral_time += dt,
220        }
221        stats.labeled_time.add([half.as_label()], dt);
222    }
223
224    fn emit_event_if_changed(
225        &mut self,
226        frame: &FrameInfo,
227        active: bool,
228        field_half: PressureHalfLabel,
229    ) {
230        let event_state = PressureEventState { active, field_half };
231        if self.last_emitted_event_state == Some(event_state) {
232            return;
233        }
234        self.events.push(PressureEvent {
235            time: frame.time,
236            frame: frame.frame_number,
237            active,
238            field_half: field_half.as_label_value().to_owned(),
239        });
240        self.last_emitted_event_state = Some(event_state);
241    }
242
243    pub fn update(
244        &mut self,
245        frame: &FrameInfo,
246        ball: &BallFrameState,
247        live_play_state: &LivePlayState,
248    ) -> SubtrActorResult<()> {
249        if !live_play_state.is_live_play {
250            self.emit_event_if_changed(frame, false, PressureHalfLabel::Neutral);
251            return Ok(());
252        }
253        if let Some(ball) = ball.sample() {
254            self.stats.tracked_time += frame.dt;
255            let ball_y = ball.position().y;
256            let half = if ball_y.abs() <= self.config.neutral_zone_half_width_y {
257                PressureHalfLabel::Neutral
258            } else if ball_y < 0.0 {
259                PressureHalfLabel::TeamZeroSide
260            } else {
261                PressureHalfLabel::TeamOneSide
262            };
263            Self::apply_pressure_time(&mut self.stats, half, frame.dt);
264            self.emit_event_if_changed(frame, true, half);
265        } else {
266            self.emit_event_if_changed(frame, false, PressureHalfLabel::Neutral);
267        }
268        Ok(())
269    }
270}