subtr_actor/stats/calculators/
pressure.rs1use 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}