Skip to main content

subtr_actor/stats/calculators/
ball_half.rs

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