File size: 12,242 Bytes
8abcfef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fbeda03
8abcfef
 
 
eecfaf7
90c84a5
8abcfef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eecfaf7
 
 
 
acdeab4
 
 
 
 
 
eecfaf7
8abcfef
 
 
 
 
 
 
eecfaf7
 
 
 
 
8abcfef
 
 
 
 
 
 
eecfaf7
 
 
8abcfef
 
eecfaf7
8abcfef
 
 
eecfaf7
 
 
 
 
8abcfef
 
eecfaf7
8abcfef
 
acdeab4
eecfaf7
acdeab4
8abcfef
eecfaf7
 
 
 
 
8abcfef
eecfaf7
 
 
 
 
 
 
 
 
8abcfef
eecfaf7
 
 
 
 
 
 
 
 
8abcfef
52bd3a5
eecfaf7
 
52bd3a5
eecfaf7
 
 
 
 
 
 
 
 
 
 
 
 
 
8abcfef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
"""
Clock reset identifier module for post-hoc 40β†’25 transition analysis.

This module identifies and classifies 40β†’25 play clock reset events by analyzing
frame data after the initial extraction pass. It complements the real-time
TrackPlayState by catching timeout and special plays that the state machine
may miss or classify differently.

Classification (Class A/B/C):
- Class A (weird_clock): 25 counts down immediately β†’ rejected (false positive)
- Class B (timeout): Timeout indicator changed β†’ tracked as timeout play
- Class C (special): Neither A nor B β†’ special play (punt/FG/XP/injury)
"""

import logging
from typing import Any, Dict, List, Optional, Tuple

from .models import PlayEvent, determine_timeout_team

logger = logging.getLogger(__name__)


# pylint: disable=too-few-public-methods
class ClockResetIdentifier:
    """
    Identifies and classifies 40β†’25 clock reset events from frame data.

    This performs post-hoc analysis on extracted frame data to find timeout
    and special plays by looking for 40β†’25 clock transitions and classifying
    them based on subsequent behavior and timeout indicator changes.
    """

    def __init__(
        self,
        immediate_countdown_window: float = 2.0,
        special_play_extension: float = 10.0,
        timeout_max_duration: float = 15.0,
    ):
        """
        Initialize the clock reset identifier.

        Args:
            immediate_countdown_window: Seconds to check if 25 counts down (Class A filter)
            special_play_extension: Max duration for special plays (Class C)
            timeout_max_duration: Max duration for timeout plays (Class B)
        """
        self.immediate_countdown_window = immediate_countdown_window
        self.special_play_extension = special_play_extension
        self.timeout_max_duration = timeout_max_duration

    def identify(self, frame_data: List[Dict[str, Any]]) -> Tuple[List[PlayEvent], Dict[str, int]]:
        """
        Identify and classify 40β†’25 clock reset events in frame data.

        Scans through frame_data looking for 40β†’25 transitions and classifies each:
        - Class A (weird_clock): 25 counts down immediately β†’ rejected
        - Class B (timeout): Timeout indicator changed β†’ timeout play
        - Class C (special): Neither A nor B β†’ special play

        Args:
            frame_data: List of frame data dicts with clock_value, timestamp,
                       home_timeouts, away_timeouts, etc.

        Returns:
            Tuple of (list of PlayEvent for valid clock resets, stats dict)
        """
        plays: List[PlayEvent] = []
        stats = {"total": 0, "weird_clock": 0, "timeout": 0, "special": 0}

        prev_clock: Optional[int] = None

        for i, frame in enumerate(frame_data):
            clock_value = frame.get("clock_value")
            timestamp: float = frame["timestamp"]

            if clock_value is not None:
                # Identify 40 β†’ 25 transition
                if prev_clock == 40 and clock_value == 25:
                    stats["total"] += 1

                    # Check Class A: 25 immediately counts down (weird clock behavior)
                    is_immediate_countdown = self._check_immediate_countdown(frame_data, i)

                    # Check Class B: timeout indicator changed
                    timeout_team = self._check_timeout_change(frame_data, i)

                    if is_immediate_countdown:
                        # Class A: Weird clock behavior - reject
                        stats["weird_clock"] += 1
                        logger.debug("Clock reset at %.1fs: weird_clock (25 counts down immediately)", timestamp)

                    elif timeout_team:
                        # Class B: Team timeout
                        stats["timeout"] += 1
                        play_end = self._find_play_end(frame_data, i, max_duration=self.timeout_max_duration)
                        play = PlayEvent(
                            play_number=0,
                            start_time=timestamp,
                            end_time=play_end,
                            confidence=0.8,
                            start_method=f"timeout_{timeout_team}",
                            end_method="timeout_end",
                            direct_end_time=play_end,
                            start_clock_value=prev_clock,
                            end_clock_value=25,
                            play_type="timeout",
                        )
                        plays.append(play)
                        logger.debug("Clock reset at %.1fs: timeout (%s team)", timestamp, timeout_team)

                    else:
                        # Class C: Special play (punt/FG/XP/injury)
                        stats["special"] += 1
                        play_end = self._find_play_end(frame_data, i, max_duration=self.special_play_extension)
                        play_duration = play_end - timestamp
                        end_method = "max_duration" if play_duration >= self.special_play_extension - 0.1 else "scorebug_disappeared"
                        play = PlayEvent(
                            play_number=0,
                            start_time=timestamp,
                            end_time=play_end,
                            confidence=0.8,
                            start_method="clock_reset_special",
                            end_method=end_method,
                            direct_end_time=play_end,
                            start_clock_value=prev_clock,
                            end_clock_value=25,
                            play_type="special",
                        )
                        plays.append(play)
                        logger.debug("Clock reset at %.1fs: special play (%.1fs duration)", timestamp, play_end - timestamp)

                prev_clock = clock_value

        return plays, stats

    def _check_immediate_countdown(self, frame_data: List[Dict[str, Any]], frame_idx: int) -> bool:
        """
        Check if 25 immediately starts counting down (Class A filter).

        If the clock shows a value < 25 within the countdown window after
        the reset, this indicates weird clock behavior (false positive).

        Args:
            frame_data: Frame data list
            frame_idx: Index of frame where 40β†’25 reset occurred

        Returns:
            True if 25 counts down immediately (Class A), False otherwise
        """
        reset_timestamp: float = frame_data[frame_idx]["timestamp"]

        for j in range(frame_idx + 1, len(frame_data)):
            frame = frame_data[j]
            elapsed = frame["timestamp"] - reset_timestamp
            if elapsed > self.immediate_countdown_window:
                break
            clock_value = frame.get("clock_value")
            if clock_value is not None and clock_value < 25:
                return True  # 25 counted down - weird clock

        return False

    # Minimum confidence threshold for reliable timeout readings
    MIN_TIMEOUT_CONFIDENCE = 0.5

    # Delay (seconds) after 40β†’25 before checking timeout indicators
    # Must be long enough for the scorebug timeout indicator to update (typically 4-8 seconds)
    TIMEOUT_CHECK_DELAY = 4.0

    # Maximum time (seconds) after 40β†’25 to look for timeout indicator changes
    # Extended to 10s to catch slow-updating indicators (some take 8+ seconds)
    TIMEOUT_CHECK_MAX_DELAY = 10.0

    def _check_timeout_change(self, frame_data: List[Dict[str, Any]], frame_idx: int) -> Optional[str]:
        """
        Check if a timeout indicator changed around the reset (Class B check).

        Compares timeout counts before and after the reset to determine
        if a team timeout was called.

        Validation rules for a valid timeout:
        1. Exactly ONE team's count decreased by exactly 1
        2. Other team's count stayed the same
        3. Both before and after readings have confidence >= MIN_TIMEOUT_CONFIDENCE

        Args:
            frame_data: Frame data list
            frame_idx: Index of frame where 40β†’25 reset occurred

        Returns:
            "home" or "away" if timeout was used, None otherwise
        """
        reset_timestamp: float = frame_data[frame_idx]["timestamp"]

        # Get timeout counts BEFORE reset (look back for high-confidence reading)
        before_home: Optional[int] = None
        before_away: Optional[int] = None
        before_conf: float = 0.0

        for j in range(frame_idx - 1, max(0, frame_idx - 20), -1):
            frame = frame_data[j]
            conf = frame.get("timeout_confidence", 0.0)
            if frame.get("home_timeouts") is not None and conf >= self.MIN_TIMEOUT_CONFIDENCE:
                before_home = frame.get("home_timeouts")
                before_away = frame.get("away_timeouts")
                before_conf = conf
                break

        if before_home is None or before_away is None:
            return None

        # Look forward for timeout change AFTER DELAY (4-10 seconds after reset)
        target_time = reset_timestamp + self.TIMEOUT_CHECK_DELAY
        max_time = reset_timestamp + self.TIMEOUT_CHECK_MAX_DELAY

        after_home: Optional[int] = None
        after_away: Optional[int] = None
        after_conf: float = 0.0

        for j in range(frame_idx + 1, len(frame_data)):
            frame = frame_data[j]
            timestamp: float = frame["timestamp"]

            # Only check after the delay period
            if timestamp < target_time:
                continue

            # Stop if we've gone past the search window
            if timestamp > max_time:
                break

            conf = frame.get("timeout_confidence", 0.0)
            if frame.get("home_timeouts") is not None and conf >= self.MIN_TIMEOUT_CONFIDENCE:
                after_home = frame.get("home_timeouts")
                after_away = frame.get("away_timeouts")
                after_conf = conf
                break

        if after_home is None or after_away is None:
            return None

        # Calculate changes and determine which team called timeout (if any)
        home_change = before_home - after_home  # positive = decrease
        away_change = before_away - after_away  # positive = decrease
        timeout_team = determine_timeout_team(home_change, away_change)

        if timeout_team:
            logger.debug(
                "Timeout detected: %s team (before=(%d,%d) conf=%.2f, after=(%d,%d) conf=%.2f)",
                timeout_team,
                before_home,
                before_away,
                before_conf,
                after_home,
                after_away,
                after_conf,
            )

        return timeout_team

    def _find_play_end(self, frame_data: List[Dict[str, Any]], frame_idx: int, max_duration: float) -> float:
        """
        Find the end time for a clock reset play.

        The play ends when EITHER:
        - Scorebug/clock disappears (cut to commercial/replay)
        - max_duration seconds have elapsed since the reset

        Whichever comes first.

        Args:
            frame_data: Frame data list
            frame_idx: Index of frame where 40β†’25 reset occurred
            max_duration: Maximum play duration from reset

        Returns:
            Play end timestamp
        """
        start_timestamp: float = frame_data[frame_idx]["timestamp"]
        max_end_time = start_timestamp + max_duration

        # Look for scorebug disappearance (but cap at max_duration)
        for j in range(frame_idx + 1, len(frame_data)):
            frame = frame_data[j]
            timestamp: float = frame["timestamp"]

            # If we've exceeded max_duration, end at max_duration
            if timestamp >= max_end_time:
                return max_end_time

            # Check for clock/scorebug disappearance
            clock_available = frame.get("clock_detected", frame.get("scorebug_detected", False))
            if not clock_available:
                return timestamp

        # Default: end at max_duration (or end of data if shorter)
        return min(max_end_time, float(frame_data[-1]["timestamp"]) if frame_data else max_end_time)