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