Skip to main content

subtr_actor/stats/calculators/
ball_third.rs

1use super::*;
2
3const DEFAULT_BALL_THIRD_BOUNDARY_Y: f32 = FIELD_ZONE_BOUNDARY_Y;
4
5/// Canonical ball-third classification, shared by the `ball_third` stream and
6/// the possession cross-tab so there is a single definition of the field-third
7/// boundaries.
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9#[allow(clippy::enum_variant_names)]
10pub(crate) enum BallThirdLabel {
11    TeamZeroThird,
12    #[default]
13    NeutralThird,
14    TeamOneThird,
15}
16
17impl BallThirdLabel {
18    pub(crate) fn from_ball(ball: &BallSample) -> Self {
19        Self::from_y(ball.position().y, DEFAULT_BALL_THIRD_BOUNDARY_Y)
20    }
21
22    pub(crate) fn from_y(ball_y: f32, boundary_y: f32) -> Self {
23        if ball_y < -boundary_y {
24            Self::TeamZeroThird
25        } else if ball_y > boundary_y {
26            Self::TeamOneThird
27        } else {
28            Self::NeutralThird
29        }
30    }
31
32    pub(crate) fn as_label_value(self) -> &'static str {
33        match self {
34            Self::TeamZeroThird => "team_zero_third",
35            Self::NeutralThird => "neutral_third",
36            Self::TeamOneThird => "team_one_third",
37        }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
42#[ts(export)]
43pub struct BallThirdEvent {
44    pub time: f32,
45    pub frame: usize,
46    pub end_time: f32,
47    pub end_frame: usize,
48    pub active: bool,
49    pub duration: f32,
50    pub field_third: String,
51}
52
53impl BallThirdEvent {
54    fn absorb_duration(&mut self, frame: &FrameInfo, duration: f32) {
55        self.end_time = frame.time;
56        self.end_frame = frame.frame_number;
57        self.duration += duration;
58    }
59}
60
61#[derive(Debug, Clone, PartialEq)]
62pub struct BallThirdCalculatorConfig {
63    pub boundary_y: f32,
64}
65
66impl Default for BallThirdCalculatorConfig {
67    fn default() -> Self {
68        Self {
69            boundary_y: DEFAULT_BALL_THIRD_BOUNDARY_Y,
70        }
71    }
72}
73
74#[derive(Debug, Clone, Default, PartialEq)]
75pub struct BallThirdCalculator {
76    config: BallThirdCalculatorConfig,
77    events: EventStream<BallThirdEvent>,
78    last_emitted_event_state: Option<BallThirdEventState>,
79    pending_event: Option<PendingBallThirdEvent>,
80}
81
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
83struct BallThirdEventState {
84    active: bool,
85    field_third: BallThirdLabel,
86}
87
88#[derive(Debug, Clone, PartialEq)]
89struct PendingBallThirdEvent {
90    state: BallThirdEventState,
91    event: BallThirdEvent,
92}
93
94impl BallThirdCalculator {
95    pub fn new() -> Self {
96        Self::with_config(BallThirdCalculatorConfig::default())
97    }
98
99    pub fn with_config(config: BallThirdCalculatorConfig) -> Self {
100        Self {
101            config,
102            ..Self::default()
103        }
104    }
105
106    pub fn events(&self) -> &[BallThirdEvent] {
107        self.events.all()
108    }
109
110    pub fn new_events(&self) -> &[BallThirdEvent] {
111        self.events.new_events()
112    }
113
114    pub fn projected_events(&self) -> Vec<BallThirdEvent> {
115        let mut events = self.events.all().to_vec();
116        if let Some(pending) = &self.pending_event {
117            events.push(pending.event.clone());
118        }
119        events
120    }
121
122    pub fn flush_pending_event(&mut self) {
123        let Some(pending) = self.pending_event.take() else {
124            return;
125        };
126        self.events.push(pending.event);
127    }
128
129    /// The event covering the most recently processed frame (in-progress
130    /// pending span, or the last committed event once flushed).
131    pub fn current_event(&self) -> Option<&BallThirdEvent> {
132        self.pending_event
133            .as_ref()
134            .map(|pending| &pending.event)
135            .or_else(|| self.events.all().last())
136    }
137
138    pub fn config(&self) -> &BallThirdCalculatorConfig {
139        &self.config
140    }
141
142    fn emit_event_if_changed(
143        &mut self,
144        frame: &FrameInfo,
145        active: bool,
146        duration: f32,
147        field_third: BallThirdLabel,
148    ) {
149        let event_state = BallThirdEventState {
150            active,
151            field_third,
152        };
153        if self.last_emitted_event_state == Some(event_state) && duration == 0.0 {
154            return;
155        }
156        let event = BallThirdEvent {
157            time: frame.time,
158            frame: frame.frame_number,
159            end_time: frame.time,
160            end_frame: frame.frame_number,
161            active,
162            duration,
163            field_third: field_third.as_label_value().to_owned(),
164        };
165        self.record_event(event_state, frame, event);
166        self.last_emitted_event_state = Some(event_state);
167    }
168
169    fn record_event(
170        &mut self,
171        state: BallThirdEventState,
172        frame: &FrameInfo,
173        event: BallThirdEvent,
174    ) {
175        let Some(pending) = self.pending_event.as_mut() else {
176            self.pending_event = Some(PendingBallThirdEvent { state, event });
177            return;
178        };
179
180        if pending.state == state {
181            pending.event.absorb_duration(frame, event.duration);
182        } else {
183            let previous = self
184                .pending_event
185                .replace(PendingBallThirdEvent { state, event });
186            let Some(previous) = previous else {
187                return;
188            };
189            self.events.push(previous.event);
190        }
191    }
192
193    pub fn update(
194        &mut self,
195        frame: &FrameInfo,
196        ball: &BallFrameState,
197        live_play_state: &LivePlayState,
198    ) -> SubtrActorResult<()> {
199        self.events.begin_update();
200        if !live_play_state.is_live_play {
201            self.emit_event_if_changed(frame, false, 0.0, BallThirdLabel::NeutralThird);
202            return Ok(());
203        }
204        if let Some(ball) = ball.sample() {
205            let third = BallThirdLabel::from_y(ball.position().y, self.config.boundary_y);
206            self.emit_event_if_changed(frame, true, frame.dt, third);
207        } else {
208            self.emit_event_if_changed(frame, false, 0.0, BallThirdLabel::NeutralThird);
209        }
210        Ok(())
211    }
212}