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, 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}