host.match_monitor.analyzer

Parse .wpilog files and extract match statistics.

  1"""Parse .wpilog files and extract match statistics."""
  2
  3import logging
  4import mmap
  5import re
  6from dataclasses import dataclass, field, asdict
  7from datetime import datetime, timezone
  8from pathlib import Path
  9from typing import Optional, List, Dict, TYPE_CHECKING
 10
 11if TYPE_CHECKING:
 12    from .ds_log_reader import DsLogData
 13
 14from wpiutil.log import DataLogReader
 15
 16logger = logging.getLogger("match_monitor")
 17
 18# Patterns to match entry names (case-insensitive)
 19VOLTAGE_PATTERNS = [
 20    re.compile(r'battery\s*voltage', re.IGNORECASE),
 21]
 22# Rail voltage patterns — tracked separately as named rails
 23RAIL_VOLTAGE_PATTERNS = {
 24    '3_3v': re.compile(r'rail3v3/voltage', re.IGNORECASE),
 25    '5v': re.compile(r'rail5v/voltage', re.IGNORECASE),
 26    '6v': re.compile(r'rail6v/voltage', re.IGNORECASE),
 27}
 28BROWNOUT_PATTERNS = [
 29    re.compile(r'brownout', re.IGNORECASE),
 30    re.compile(r'browned?\s*out', re.IGNORECASE),
 31]
 32DS_CONNECTED_PATTERNS = [
 33    re.compile(r'DS:ds', re.IGNORECASE),
 34    re.compile(r'ds\s*attached', re.IGNORECASE),
 35    re.compile(r'ds\s*connected', re.IGNORECASE),
 36]
 37MODE_PATTERNS = {
 38    'enabled': re.compile(r'DS:enabled', re.IGNORECASE),
 39    'autonomous': re.compile(r'DS:autonomous', re.IGNORECASE),
 40    'test': re.compile(r'DS:test', re.IGNORECASE),
 41}
 42CONFIG_HASH_PATTERNS = [
 43    re.compile(r'controller.*hash', re.IGNORECASE),
 44    re.compile(r'config.*hash', re.IGNORECASE),
 45]
 46SYSTEM_TIME_PATTERNS = [
 47    re.compile(r'^systemTime$'),
 48    re.compile(r'system.*time', re.IGNORECASE),
 49]
 50
 51
 52@dataclass
 53class MatchStats:
 54    """Data class holding extracted match statistics."""
 55    brownout_count: int = 0
 56    brownout_timestamps_sec: List[float] = field(default_factory=list)
 57    disconnect_count: int = 0
 58    disconnect_timestamps_sec: List[float] = field(default_factory=list)
 59    start_voltage: Optional[float] = None
 60    end_voltage: Optional[float] = None
 61    avg_voltage: Optional[float] = None
 62    min_voltage: Optional[float] = None
 63    max_voltage: Optional[float] = None
 64    voltage_samples: int = 0
 65    match_duration_seconds: Optional[float] = None
 66    controller_config_hash: Optional[str] = None
 67    mode_transitions: List[dict] = field(default_factory=list)
 68    entry_names: List[str] = field(default_factory=list)
 69    # Voltage timeseries: [(time_sec_from_start, voltage), ...]
 70    voltage_timeseries: List[tuple] = field(default_factory=list)
 71    # Rail voltages: {rail_name: {avg, min, max, samples}}
 72    rail_voltages: Dict[str, dict] = field(default_factory=dict)
 73    # Wall clock time of the first wpilog record (derived from systemTime entry)
 74    wpilog_wall_start: Optional[datetime] = field(default=None, compare=False)
 75    # DS log data attached after collection (not part of wpilog analysis)
 76    ds_log: Optional['DsLogData'] = field(default=None, compare=False)
 77
 78    def to_dict(self) -> dict:
 79        """Convert to a JSON-serializable dictionary."""
 80        d = asdict(self)
 81        # Remove non-serializable / large fields handled separately
 82        d.pop('wpilog_wall_start', None)
 83        d.pop('ds_log', None)
 84        d.pop('voltage_timeseries', None)
 85        # Group voltage fields
 86        d['voltage'] = {
 87            'start': d.pop('start_voltage'),
 88            'end': d.pop('end_voltage'),
 89            'average': d.pop('avg_voltage'),
 90            'min': d.pop('min_voltage'),
 91            'max': d.pop('max_voltage'),
 92            'samples': d.pop('voltage_samples'),
 93        }
 94        if self.wpilog_wall_start:
 95            d['wpilog_wall_start'] = self.wpilog_wall_start.isoformat()
 96        if self.ds_log is not None:
 97            d['ds_log'] = self.ds_log.to_dict()
 98        return d
 99
100
101def _match_any(name: str, patterns: list) -> bool:
102    return any(p.search(name) for p in patterns)
103
104
105class WpilogAnalyzer:
106    """Parses .wpilog files to extract match statistics."""
107
108    def analyze(self, wpilog_path: Path) -> MatchStats:
109        """Analyze a single .wpilog file and return match statistics."""
110        stats = MatchStats()
111
112        with open(wpilog_path, 'rb') as f:
113            mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
114            reader = DataLogReader(mm)
115
116            if not reader.isValid():
117                logger.warning(f"Invalid wpilog file: {wpilog_path.name}")
118                return stats
119
120            # First pass: discover entry names and their IDs
121            entries: Dict[int, dict] = {}  # entry_id -> {name, type}
122            voltage_ids = set()
123            brownout_ids = set()
124            ds_connected_ids = set()
125            mode_ids: Dict[str, set] = {k: set() for k in MODE_PATTERNS}
126            config_hash_ids = set()
127            system_time_ids = set()
128            # Rail voltage entry IDs: rail_name -> set of entry IDs
129            rail_voltage_ids: Dict[str, set] = {k: set() for k in RAIL_VOLTAGE_PATTERNS}
130
131            for record in reader:
132                if record.isStart():
133                    data = record.getStartData()
134                    entries[data.entry] = {
135                        'name': data.name, 'type': data.type
136                    }
137                    stats.entry_names.append(data.name)
138
139                    if _match_any(data.name, VOLTAGE_PATTERNS):
140                        voltage_ids.add(data.entry)
141                    if _match_any(data.name, BROWNOUT_PATTERNS):
142                        brownout_ids.add(data.entry)
143                    if _match_any(data.name, DS_CONNECTED_PATTERNS):
144                        ds_connected_ids.add(data.entry)
145                    for mode_name, pattern in MODE_PATTERNS.items():
146                        if pattern.search(data.name):
147                            mode_ids[mode_name].add(data.entry)
148                    if _match_any(data.name, CONFIG_HASH_PATTERNS):
149                        config_hash_ids.add(data.entry)
150                    if _match_any(data.name, SYSTEM_TIME_PATTERNS):
151                        system_time_ids.add(data.entry)
152                    for rail_name, pattern in RAIL_VOLTAGE_PATTERNS.items():
153                        if pattern.search(data.name):
154                            rail_voltage_ids[rail_name].add(data.entry)
155
156            # Log discovered entries
157            logger.info(f"Analyzing {wpilog_path.name}: "
158                        f"{len(entries)} entries, "
159                        f"voltage={len(voltage_ids)}, "
160                        f"brownout={len(brownout_ids)}, "
161                        f"ds_connected={len(ds_connected_ids)}")
162
163            # Second pass: extract data
164            voltage_sum = 0.0
165            voltage_count = 0
166            first_timestamp = None
167            last_timestamp = None
168            prev_brownout = False
169            prev_ds_connected = True
170            mode_state: Dict[str, bool] = {}
171            # Rail voltage accumulators: {rail: [sum, count, min, max]}
172            rail_accum: Dict[str, list] = {k: [0.0, 0, None, None] for k in RAIL_VOLTAGE_PATTERNS}
173
174            mm.seek(0)
175            reader2 = DataLogReader(mm)
176
177            for record in reader2:
178                if record.isControl():
179                    continue
180
181                entry_info = entries.get(record.getEntry())
182                if entry_info is None:
183                    continue
184
185                ts_sec = record.getTimestamp() / 1_000_000.0
186                if first_timestamp is None:
187                    first_timestamp = ts_sec
188                last_timestamp = ts_sec
189
190                try:
191                    # Voltage (skip 0.0 — default NT value before first real reading)
192                    if record.getEntry() in voltage_ids:
193                        v = self._get_numeric(record, entry_info['type'])
194                        if v is not None and v > 0.0:
195                            if stats.start_voltage is None:
196                                stats.start_voltage = v
197                            stats.end_voltage = v
198                            voltage_sum += v
199                            voltage_count += 1
200                            if stats.min_voltage is None or v < stats.min_voltage:
201                                stats.min_voltage = v
202                            if stats.max_voltage is None or v > stats.max_voltage:
203                                stats.max_voltage = v
204                            stats.voltage_timeseries.append((
205                                round(ts_sec - (first_timestamp or 0), 3), v
206                            ))
207
208                    # Brownout
209                    if record.getEntry() in brownout_ids:
210                        val = record.getBoolean()
211                        if val and not prev_brownout:
212                            stats.brownout_count += 1
213                            stats.brownout_timestamps_sec.append(
214                                round(ts_sec - (first_timestamp or 0), 3)
215                            )
216                        prev_brownout = val
217
218                    # DS Connected
219                    if record.getEntry() in ds_connected_ids:
220                        val = record.getBoolean()
221                        if not val and prev_ds_connected:
222                            stats.disconnect_count += 1
223                            stats.disconnect_timestamps_sec.append(
224                                round(ts_sec - (first_timestamp or 0), 3)
225                            )
226                        prev_ds_connected = val
227
228                    # Mode transitions (record both True and False)
229                    for mode_name, ids in mode_ids.items():
230                        if record.getEntry() in ids:
231                            val = record.getBoolean()
232                            prev = mode_state.get(mode_name)
233                            if prev is None or val != prev:
234                                mode_state[mode_name] = val
235                                stats.mode_transitions.append({
236                                    'time_sec': round(
237                                        ts_sec - (first_timestamp or 0), 3
238                                    ),
239                                    'mode': mode_name,
240                                    'value': val,
241                                })
242
243                    # Rail voltages
244                    for rail_name, ids in rail_voltage_ids.items():
245                        if record.getEntry() in ids:
246                            v = self._get_numeric(record, entry_info['type'])
247                            if v is not None:
248                                acc = rail_accum[rail_name]
249                                acc[0] += v
250                                acc[1] += 1
251                                if acc[2] is None or v < acc[2]:
252                                    acc[2] = v
253                                if acc[3] is None or v > acc[3]:
254                                    acc[3] = v
255
256                    # Controller config hash
257                    if record.getEntry() in config_hash_ids:
258                        stats.controller_config_hash = record.getString()
259
260                    # System time — wall clock offset for DS log correlation
261                    if record.getEntry() in system_time_ids and stats.wpilog_wall_start is None:
262                        try:
263                            entry_type = entries[record.getEntry()]['type']
264                            if entry_type == 'int64':
265                                # Microseconds since Unix epoch
266                                wall_us = record.getInteger()
267                                wall_sec = wall_us / 1_000_000.0
268                                stats.wpilog_wall_start = datetime.fromtimestamp(
269                                    wall_sec, tz=timezone.utc)
270                            elif entry_type == 'string':
271                                raw = record.getString()
272                                for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S'):
273                                    try:
274                                        stats.wpilog_wall_start = datetime.strptime(
275                                            raw[:19], fmt).replace(tzinfo=timezone.utc)
276                                        break
277                                    except ValueError:
278                                        pass  # try next format
279                        except Exception as e:
280                            logger.debug("Could not parse system time entry: %s", e)
281
282                except Exception as e:
283                    logger.debug("Skipping malformed record: %s", e)
284
285            # Finalize
286            if voltage_count > 0:
287                stats.avg_voltage = round(voltage_sum / voltage_count, 3)
288                stats.voltage_samples = voltage_count
289                if stats.start_voltage is not None:
290                    stats.start_voltage = round(stats.start_voltage, 3)
291                if stats.end_voltage is not None:
292                    stats.end_voltage = round(stats.end_voltage, 3)
293                if stats.min_voltage is not None:
294                    stats.min_voltage = round(stats.min_voltage, 3)
295                if stats.max_voltage is not None:
296                    stats.max_voltage = round(stats.max_voltage, 3)
297
298            if first_timestamp is not None and last_timestamp is not None:
299                stats.match_duration_seconds = round(
300                    last_timestamp - first_timestamp, 3
301                )
302
303            # Finalize rail voltages
304            for rail_name, acc in rail_accum.items():
305                if acc[1] > 0:
306                    stats.rail_voltages[rail_name] = {
307                        'avg': round(acc[0] / acc[1], 3),
308                        'min': round(acc[2], 3),
309                        'max': round(acc[3], 3),
310                        'samples': acc[1],
311                    }
312
313            mm.close()
314
315        return stats
316
317    def analyze_directory(self, match_dir: Path) -> MatchStats:
318        """Analyze all .wpilog files in a directory and merge results."""
319        wpilog_files = sorted(match_dir.glob('*.wpilog'))
320        if not wpilog_files:
321            logger.info(f"No .wpilog files found in {match_dir}")
322            return MatchStats()
323
324        all_stats = []
325        for f in wpilog_files:
326            try:
327                s = self.analyze(f)
328                all_stats.append(s)
329            except Exception:
330                logger.exception(f"Failed to analyze {f.name}")
331
332        if not all_stats:
333            return MatchStats()
334        if len(all_stats) == 1:
335            return all_stats[0]
336        return self._merge(all_stats)
337
338    def _merge(self, stats_list: List[MatchStats]) -> MatchStats:
339        """Merge stats from multiple .wpilog files."""
340        merged = MatchStats()
341        total_voltage_sum = 0.0
342        total_voltage_count = 0
343
344        for s in stats_list:
345            merged.brownout_count += s.brownout_count
346            merged.brownout_timestamps_sec.extend(s.brownout_timestamps_sec)
347            merged.disconnect_count += s.disconnect_count
348            merged.disconnect_timestamps_sec.extend(s.disconnect_timestamps_sec)
349            merged.mode_transitions.extend(s.mode_transitions)
350            merged.entry_names.extend(s.entry_names)
351
352            if s.start_voltage is not None and merged.start_voltage is None:
353                merged.start_voltage = s.start_voltage
354            if s.end_voltage is not None:
355                merged.end_voltage = s.end_voltage
356            if s.min_voltage is not None:
357                if merged.min_voltage is None or s.min_voltage < merged.min_voltage:
358                    merged.min_voltage = s.min_voltage
359            if s.max_voltage is not None:
360                if merged.max_voltage is None or s.max_voltage > merged.max_voltage:
361                    merged.max_voltage = s.max_voltage
362            if s.voltage_samples > 0 and s.avg_voltage is not None:
363                total_voltage_sum += s.avg_voltage * s.voltage_samples
364                total_voltage_count += s.voltage_samples
365            if s.match_duration_seconds is not None:
366                if (merged.match_duration_seconds is None
367                        or s.match_duration_seconds > merged.match_duration_seconds):
368                    merged.match_duration_seconds = s.match_duration_seconds
369            if s.controller_config_hash is not None:
370                merged.controller_config_hash = s.controller_config_hash
371
372        if total_voltage_count > 0:
373            merged.avg_voltage = round(total_voltage_sum / total_voltage_count, 3)
374            merged.voltage_samples = total_voltage_count
375
376        merged.mode_transitions.sort(key=lambda t: t['time_sec'])
377        merged.entry_names = sorted(set(merged.entry_names))
378        return merged
379
380    @staticmethod
381    def _get_numeric(record, type_name: str):
382        if type_name == 'double':
383            return record.getDouble()
384        elif type_name == 'float':
385            return record.getFloat()
386        elif type_name == 'int64':
387            return float(record.getInteger())
388        return None
logger = <Logger match_monitor (DEBUG)>
VOLTAGE_PATTERNS = [re.compile('battery\\s*voltage', re.IGNORECASE)]
RAIL_VOLTAGE_PATTERNS = {'3_3v': re.compile('rail3v3/voltage', re.IGNORECASE), '5v': re.compile('rail5v/voltage', re.IGNORECASE), '6v': re.compile('rail6v/voltage', re.IGNORECASE)}
BROWNOUT_PATTERNS = [re.compile('brownout', re.IGNORECASE), re.compile('browned?\\s*out', re.IGNORECASE)]
DS_CONNECTED_PATTERNS = [re.compile('DS:ds', re.IGNORECASE), re.compile('ds\\s*attached', re.IGNORECASE), re.compile('ds\\s*connected', re.IGNORECASE)]
MODE_PATTERNS = {'enabled': re.compile('DS:enabled', re.IGNORECASE), 'autonomous': re.compile('DS:autonomous', re.IGNORECASE), 'test': re.compile('DS:test', re.IGNORECASE)}
CONFIG_HASH_PATTERNS = [re.compile('controller.*hash', re.IGNORECASE), re.compile('config.*hash', re.IGNORECASE)]
SYSTEM_TIME_PATTERNS = [re.compile('^systemTime$'), re.compile('system.*time', re.IGNORECASE)]
@dataclass
class MatchStats:
53@dataclass
54class MatchStats:
55    """Data class holding extracted match statistics."""
56    brownout_count: int = 0
57    brownout_timestamps_sec: List[float] = field(default_factory=list)
58    disconnect_count: int = 0
59    disconnect_timestamps_sec: List[float] = field(default_factory=list)
60    start_voltage: Optional[float] = None
61    end_voltage: Optional[float] = None
62    avg_voltage: Optional[float] = None
63    min_voltage: Optional[float] = None
64    max_voltage: Optional[float] = None
65    voltage_samples: int = 0
66    match_duration_seconds: Optional[float] = None
67    controller_config_hash: Optional[str] = None
68    mode_transitions: List[dict] = field(default_factory=list)
69    entry_names: List[str] = field(default_factory=list)
70    # Voltage timeseries: [(time_sec_from_start, voltage), ...]
71    voltage_timeseries: List[tuple] = field(default_factory=list)
72    # Rail voltages: {rail_name: {avg, min, max, samples}}
73    rail_voltages: Dict[str, dict] = field(default_factory=dict)
74    # Wall clock time of the first wpilog record (derived from systemTime entry)
75    wpilog_wall_start: Optional[datetime] = field(default=None, compare=False)
76    # DS log data attached after collection (not part of wpilog analysis)
77    ds_log: Optional['DsLogData'] = field(default=None, compare=False)
78
79    def to_dict(self) -> dict:
80        """Convert to a JSON-serializable dictionary."""
81        d = asdict(self)
82        # Remove non-serializable / large fields handled separately
83        d.pop('wpilog_wall_start', None)
84        d.pop('ds_log', None)
85        d.pop('voltage_timeseries', None)
86        # Group voltage fields
87        d['voltage'] = {
88            'start': d.pop('start_voltage'),
89            'end': d.pop('end_voltage'),
90            'average': d.pop('avg_voltage'),
91            'min': d.pop('min_voltage'),
92            'max': d.pop('max_voltage'),
93            'samples': d.pop('voltage_samples'),
94        }
95        if self.wpilog_wall_start:
96            d['wpilog_wall_start'] = self.wpilog_wall_start.isoformat()
97        if self.ds_log is not None:
98            d['ds_log'] = self.ds_log.to_dict()
99        return d

Data class holding extracted match statistics.

MatchStats( brownout_count: int = 0, brownout_timestamps_sec: List[float] = <factory>, disconnect_count: int = 0, disconnect_timestamps_sec: List[float] = <factory>, start_voltage: float | None = None, end_voltage: float | None = None, avg_voltage: float | None = None, min_voltage: float | None = None, max_voltage: float | None = None, voltage_samples: int = 0, match_duration_seconds: float | None = None, controller_config_hash: str | None = None, mode_transitions: List[dict] = <factory>, entry_names: List[str] = <factory>, voltage_timeseries: List[tuple] = <factory>, rail_voltages: Dict[str, dict] = <factory>, wpilog_wall_start: datetime.datetime | None = None, ds_log: host.match_monitor.ds_log_reader.DsLogData | None = None)
brownout_count: int = 0
brownout_timestamps_sec: List[float]
disconnect_count: int = 0
disconnect_timestamps_sec: List[float]
start_voltage: float | None = None
end_voltage: float | None = None
avg_voltage: float | None = None
min_voltage: float | None = None
max_voltage: float | None = None
voltage_samples: int = 0
match_duration_seconds: float | None = None
controller_config_hash: str | None = None
mode_transitions: List[dict]
entry_names: List[str]
voltage_timeseries: List[tuple]
rail_voltages: Dict[str, dict]
wpilog_wall_start: datetime.datetime | None = None
def to_dict(self) -> dict:
79    def to_dict(self) -> dict:
80        """Convert to a JSON-serializable dictionary."""
81        d = asdict(self)
82        # Remove non-serializable / large fields handled separately
83        d.pop('wpilog_wall_start', None)
84        d.pop('ds_log', None)
85        d.pop('voltage_timeseries', None)
86        # Group voltage fields
87        d['voltage'] = {
88            'start': d.pop('start_voltage'),
89            'end': d.pop('end_voltage'),
90            'average': d.pop('avg_voltage'),
91            'min': d.pop('min_voltage'),
92            'max': d.pop('max_voltage'),
93            'samples': d.pop('voltage_samples'),
94        }
95        if self.wpilog_wall_start:
96            d['wpilog_wall_start'] = self.wpilog_wall_start.isoformat()
97        if self.ds_log is not None:
98            d['ds_log'] = self.ds_log.to_dict()
99        return d

Convert to a JSON-serializable dictionary.

class WpilogAnalyzer:
106class WpilogAnalyzer:
107    """Parses .wpilog files to extract match statistics."""
108
109    def analyze(self, wpilog_path: Path) -> MatchStats:
110        """Analyze a single .wpilog file and return match statistics."""
111        stats = MatchStats()
112
113        with open(wpilog_path, 'rb') as f:
114            mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
115            reader = DataLogReader(mm)
116
117            if not reader.isValid():
118                logger.warning(f"Invalid wpilog file: {wpilog_path.name}")
119                return stats
120
121            # First pass: discover entry names and their IDs
122            entries: Dict[int, dict] = {}  # entry_id -> {name, type}
123            voltage_ids = set()
124            brownout_ids = set()
125            ds_connected_ids = set()
126            mode_ids: Dict[str, set] = {k: set() for k in MODE_PATTERNS}
127            config_hash_ids = set()
128            system_time_ids = set()
129            # Rail voltage entry IDs: rail_name -> set of entry IDs
130            rail_voltage_ids: Dict[str, set] = {k: set() for k in RAIL_VOLTAGE_PATTERNS}
131
132            for record in reader:
133                if record.isStart():
134                    data = record.getStartData()
135                    entries[data.entry] = {
136                        'name': data.name, 'type': data.type
137                    }
138                    stats.entry_names.append(data.name)
139
140                    if _match_any(data.name, VOLTAGE_PATTERNS):
141                        voltage_ids.add(data.entry)
142                    if _match_any(data.name, BROWNOUT_PATTERNS):
143                        brownout_ids.add(data.entry)
144                    if _match_any(data.name, DS_CONNECTED_PATTERNS):
145                        ds_connected_ids.add(data.entry)
146                    for mode_name, pattern in MODE_PATTERNS.items():
147                        if pattern.search(data.name):
148                            mode_ids[mode_name].add(data.entry)
149                    if _match_any(data.name, CONFIG_HASH_PATTERNS):
150                        config_hash_ids.add(data.entry)
151                    if _match_any(data.name, SYSTEM_TIME_PATTERNS):
152                        system_time_ids.add(data.entry)
153                    for rail_name, pattern in RAIL_VOLTAGE_PATTERNS.items():
154                        if pattern.search(data.name):
155                            rail_voltage_ids[rail_name].add(data.entry)
156
157            # Log discovered entries
158            logger.info(f"Analyzing {wpilog_path.name}: "
159                        f"{len(entries)} entries, "
160                        f"voltage={len(voltage_ids)}, "
161                        f"brownout={len(brownout_ids)}, "
162                        f"ds_connected={len(ds_connected_ids)}")
163
164            # Second pass: extract data
165            voltage_sum = 0.0
166            voltage_count = 0
167            first_timestamp = None
168            last_timestamp = None
169            prev_brownout = False
170            prev_ds_connected = True
171            mode_state: Dict[str, bool] = {}
172            # Rail voltage accumulators: {rail: [sum, count, min, max]}
173            rail_accum: Dict[str, list] = {k: [0.0, 0, None, None] for k in RAIL_VOLTAGE_PATTERNS}
174
175            mm.seek(0)
176            reader2 = DataLogReader(mm)
177
178            for record in reader2:
179                if record.isControl():
180                    continue
181
182                entry_info = entries.get(record.getEntry())
183                if entry_info is None:
184                    continue
185
186                ts_sec = record.getTimestamp() / 1_000_000.0
187                if first_timestamp is None:
188                    first_timestamp = ts_sec
189                last_timestamp = ts_sec
190
191                try:
192                    # Voltage (skip 0.0 — default NT value before first real reading)
193                    if record.getEntry() in voltage_ids:
194                        v = self._get_numeric(record, entry_info['type'])
195                        if v is not None and v > 0.0:
196                            if stats.start_voltage is None:
197                                stats.start_voltage = v
198                            stats.end_voltage = v
199                            voltage_sum += v
200                            voltage_count += 1
201                            if stats.min_voltage is None or v < stats.min_voltage:
202                                stats.min_voltage = v
203                            if stats.max_voltage is None or v > stats.max_voltage:
204                                stats.max_voltage = v
205                            stats.voltage_timeseries.append((
206                                round(ts_sec - (first_timestamp or 0), 3), v
207                            ))
208
209                    # Brownout
210                    if record.getEntry() in brownout_ids:
211                        val = record.getBoolean()
212                        if val and not prev_brownout:
213                            stats.brownout_count += 1
214                            stats.brownout_timestamps_sec.append(
215                                round(ts_sec - (first_timestamp or 0), 3)
216                            )
217                        prev_brownout = val
218
219                    # DS Connected
220                    if record.getEntry() in ds_connected_ids:
221                        val = record.getBoolean()
222                        if not val and prev_ds_connected:
223                            stats.disconnect_count += 1
224                            stats.disconnect_timestamps_sec.append(
225                                round(ts_sec - (first_timestamp or 0), 3)
226                            )
227                        prev_ds_connected = val
228
229                    # Mode transitions (record both True and False)
230                    for mode_name, ids in mode_ids.items():
231                        if record.getEntry() in ids:
232                            val = record.getBoolean()
233                            prev = mode_state.get(mode_name)
234                            if prev is None or val != prev:
235                                mode_state[mode_name] = val
236                                stats.mode_transitions.append({
237                                    'time_sec': round(
238                                        ts_sec - (first_timestamp or 0), 3
239                                    ),
240                                    'mode': mode_name,
241                                    'value': val,
242                                })
243
244                    # Rail voltages
245                    for rail_name, ids in rail_voltage_ids.items():
246                        if record.getEntry() in ids:
247                            v = self._get_numeric(record, entry_info['type'])
248                            if v is not None:
249                                acc = rail_accum[rail_name]
250                                acc[0] += v
251                                acc[1] += 1
252                                if acc[2] is None or v < acc[2]:
253                                    acc[2] = v
254                                if acc[3] is None or v > acc[3]:
255                                    acc[3] = v
256
257                    # Controller config hash
258                    if record.getEntry() in config_hash_ids:
259                        stats.controller_config_hash = record.getString()
260
261                    # System time — wall clock offset for DS log correlation
262                    if record.getEntry() in system_time_ids and stats.wpilog_wall_start is None:
263                        try:
264                            entry_type = entries[record.getEntry()]['type']
265                            if entry_type == 'int64':
266                                # Microseconds since Unix epoch
267                                wall_us = record.getInteger()
268                                wall_sec = wall_us / 1_000_000.0
269                                stats.wpilog_wall_start = datetime.fromtimestamp(
270                                    wall_sec, tz=timezone.utc)
271                            elif entry_type == 'string':
272                                raw = record.getString()
273                                for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S'):
274                                    try:
275                                        stats.wpilog_wall_start = datetime.strptime(
276                                            raw[:19], fmt).replace(tzinfo=timezone.utc)
277                                        break
278                                    except ValueError:
279                                        pass  # try next format
280                        except Exception as e:
281                            logger.debug("Could not parse system time entry: %s", e)
282
283                except Exception as e:
284                    logger.debug("Skipping malformed record: %s", e)
285
286            # Finalize
287            if voltage_count > 0:
288                stats.avg_voltage = round(voltage_sum / voltage_count, 3)
289                stats.voltage_samples = voltage_count
290                if stats.start_voltage is not None:
291                    stats.start_voltage = round(stats.start_voltage, 3)
292                if stats.end_voltage is not None:
293                    stats.end_voltage = round(stats.end_voltage, 3)
294                if stats.min_voltage is not None:
295                    stats.min_voltage = round(stats.min_voltage, 3)
296                if stats.max_voltage is not None:
297                    stats.max_voltage = round(stats.max_voltage, 3)
298
299            if first_timestamp is not None and last_timestamp is not None:
300                stats.match_duration_seconds = round(
301                    last_timestamp - first_timestamp, 3
302                )
303
304            # Finalize rail voltages
305            for rail_name, acc in rail_accum.items():
306                if acc[1] > 0:
307                    stats.rail_voltages[rail_name] = {
308                        'avg': round(acc[0] / acc[1], 3),
309                        'min': round(acc[2], 3),
310                        'max': round(acc[3], 3),
311                        'samples': acc[1],
312                    }
313
314            mm.close()
315
316        return stats
317
318    def analyze_directory(self, match_dir: Path) -> MatchStats:
319        """Analyze all .wpilog files in a directory and merge results."""
320        wpilog_files = sorted(match_dir.glob('*.wpilog'))
321        if not wpilog_files:
322            logger.info(f"No .wpilog files found in {match_dir}")
323            return MatchStats()
324
325        all_stats = []
326        for f in wpilog_files:
327            try:
328                s = self.analyze(f)
329                all_stats.append(s)
330            except Exception:
331                logger.exception(f"Failed to analyze {f.name}")
332
333        if not all_stats:
334            return MatchStats()
335        if len(all_stats) == 1:
336            return all_stats[0]
337        return self._merge(all_stats)
338
339    def _merge(self, stats_list: List[MatchStats]) -> MatchStats:
340        """Merge stats from multiple .wpilog files."""
341        merged = MatchStats()
342        total_voltage_sum = 0.0
343        total_voltage_count = 0
344
345        for s in stats_list:
346            merged.brownout_count += s.brownout_count
347            merged.brownout_timestamps_sec.extend(s.brownout_timestamps_sec)
348            merged.disconnect_count += s.disconnect_count
349            merged.disconnect_timestamps_sec.extend(s.disconnect_timestamps_sec)
350            merged.mode_transitions.extend(s.mode_transitions)
351            merged.entry_names.extend(s.entry_names)
352
353            if s.start_voltage is not None and merged.start_voltage is None:
354                merged.start_voltage = s.start_voltage
355            if s.end_voltage is not None:
356                merged.end_voltage = s.end_voltage
357            if s.min_voltage is not None:
358                if merged.min_voltage is None or s.min_voltage < merged.min_voltage:
359                    merged.min_voltage = s.min_voltage
360            if s.max_voltage is not None:
361                if merged.max_voltage is None or s.max_voltage > merged.max_voltage:
362                    merged.max_voltage = s.max_voltage
363            if s.voltage_samples > 0 and s.avg_voltage is not None:
364                total_voltage_sum += s.avg_voltage * s.voltage_samples
365                total_voltage_count += s.voltage_samples
366            if s.match_duration_seconds is not None:
367                if (merged.match_duration_seconds is None
368                        or s.match_duration_seconds > merged.match_duration_seconds):
369                    merged.match_duration_seconds = s.match_duration_seconds
370            if s.controller_config_hash is not None:
371                merged.controller_config_hash = s.controller_config_hash
372
373        if total_voltage_count > 0:
374            merged.avg_voltage = round(total_voltage_sum / total_voltage_count, 3)
375            merged.voltage_samples = total_voltage_count
376
377        merged.mode_transitions.sort(key=lambda t: t['time_sec'])
378        merged.entry_names = sorted(set(merged.entry_names))
379        return merged
380
381    @staticmethod
382    def _get_numeric(record, type_name: str):
383        if type_name == 'double':
384            return record.getDouble()
385        elif type_name == 'float':
386            return record.getFloat()
387        elif type_name == 'int64':
388            return float(record.getInteger())
389        return None

Parses .wpilog files to extract match statistics.

def analyze( self, wpilog_path: pathlib.Path) -> MatchStats:
109    def analyze(self, wpilog_path: Path) -> MatchStats:
110        """Analyze a single .wpilog file and return match statistics."""
111        stats = MatchStats()
112
113        with open(wpilog_path, 'rb') as f:
114            mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
115            reader = DataLogReader(mm)
116
117            if not reader.isValid():
118                logger.warning(f"Invalid wpilog file: {wpilog_path.name}")
119                return stats
120
121            # First pass: discover entry names and their IDs
122            entries: Dict[int, dict] = {}  # entry_id -> {name, type}
123            voltage_ids = set()
124            brownout_ids = set()
125            ds_connected_ids = set()
126            mode_ids: Dict[str, set] = {k: set() for k in MODE_PATTERNS}
127            config_hash_ids = set()
128            system_time_ids = set()
129            # Rail voltage entry IDs: rail_name -> set of entry IDs
130            rail_voltage_ids: Dict[str, set] = {k: set() for k in RAIL_VOLTAGE_PATTERNS}
131
132            for record in reader:
133                if record.isStart():
134                    data = record.getStartData()
135                    entries[data.entry] = {
136                        'name': data.name, 'type': data.type
137                    }
138                    stats.entry_names.append(data.name)
139
140                    if _match_any(data.name, VOLTAGE_PATTERNS):
141                        voltage_ids.add(data.entry)
142                    if _match_any(data.name, BROWNOUT_PATTERNS):
143                        brownout_ids.add(data.entry)
144                    if _match_any(data.name, DS_CONNECTED_PATTERNS):
145                        ds_connected_ids.add(data.entry)
146                    for mode_name, pattern in MODE_PATTERNS.items():
147                        if pattern.search(data.name):
148                            mode_ids[mode_name].add(data.entry)
149                    if _match_any(data.name, CONFIG_HASH_PATTERNS):
150                        config_hash_ids.add(data.entry)
151                    if _match_any(data.name, SYSTEM_TIME_PATTERNS):
152                        system_time_ids.add(data.entry)
153                    for rail_name, pattern in RAIL_VOLTAGE_PATTERNS.items():
154                        if pattern.search(data.name):
155                            rail_voltage_ids[rail_name].add(data.entry)
156
157            # Log discovered entries
158            logger.info(f"Analyzing {wpilog_path.name}: "
159                        f"{len(entries)} entries, "
160                        f"voltage={len(voltage_ids)}, "
161                        f"brownout={len(brownout_ids)}, "
162                        f"ds_connected={len(ds_connected_ids)}")
163
164            # Second pass: extract data
165            voltage_sum = 0.0
166            voltage_count = 0
167            first_timestamp = None
168            last_timestamp = None
169            prev_brownout = False
170            prev_ds_connected = True
171            mode_state: Dict[str, bool] = {}
172            # Rail voltage accumulators: {rail: [sum, count, min, max]}
173            rail_accum: Dict[str, list] = {k: [0.0, 0, None, None] for k in RAIL_VOLTAGE_PATTERNS}
174
175            mm.seek(0)
176            reader2 = DataLogReader(mm)
177
178            for record in reader2:
179                if record.isControl():
180                    continue
181
182                entry_info = entries.get(record.getEntry())
183                if entry_info is None:
184                    continue
185
186                ts_sec = record.getTimestamp() / 1_000_000.0
187                if first_timestamp is None:
188                    first_timestamp = ts_sec
189                last_timestamp = ts_sec
190
191                try:
192                    # Voltage (skip 0.0 — default NT value before first real reading)
193                    if record.getEntry() in voltage_ids:
194                        v = self._get_numeric(record, entry_info['type'])
195                        if v is not None and v > 0.0:
196                            if stats.start_voltage is None:
197                                stats.start_voltage = v
198                            stats.end_voltage = v
199                            voltage_sum += v
200                            voltage_count += 1
201                            if stats.min_voltage is None or v < stats.min_voltage:
202                                stats.min_voltage = v
203                            if stats.max_voltage is None or v > stats.max_voltage:
204                                stats.max_voltage = v
205                            stats.voltage_timeseries.append((
206                                round(ts_sec - (first_timestamp or 0), 3), v
207                            ))
208
209                    # Brownout
210                    if record.getEntry() in brownout_ids:
211                        val = record.getBoolean()
212                        if val and not prev_brownout:
213                            stats.brownout_count += 1
214                            stats.brownout_timestamps_sec.append(
215                                round(ts_sec - (first_timestamp or 0), 3)
216                            )
217                        prev_brownout = val
218
219                    # DS Connected
220                    if record.getEntry() in ds_connected_ids:
221                        val = record.getBoolean()
222                        if not val and prev_ds_connected:
223                            stats.disconnect_count += 1
224                            stats.disconnect_timestamps_sec.append(
225                                round(ts_sec - (first_timestamp or 0), 3)
226                            )
227                        prev_ds_connected = val
228
229                    # Mode transitions (record both True and False)
230                    for mode_name, ids in mode_ids.items():
231                        if record.getEntry() in ids:
232                            val = record.getBoolean()
233                            prev = mode_state.get(mode_name)
234                            if prev is None or val != prev:
235                                mode_state[mode_name] = val
236                                stats.mode_transitions.append({
237                                    'time_sec': round(
238                                        ts_sec - (first_timestamp or 0), 3
239                                    ),
240                                    'mode': mode_name,
241                                    'value': val,
242                                })
243
244                    # Rail voltages
245                    for rail_name, ids in rail_voltage_ids.items():
246                        if record.getEntry() in ids:
247                            v = self._get_numeric(record, entry_info['type'])
248                            if v is not None:
249                                acc = rail_accum[rail_name]
250                                acc[0] += v
251                                acc[1] += 1
252                                if acc[2] is None or v < acc[2]:
253                                    acc[2] = v
254                                if acc[3] is None or v > acc[3]:
255                                    acc[3] = v
256
257                    # Controller config hash
258                    if record.getEntry() in config_hash_ids:
259                        stats.controller_config_hash = record.getString()
260
261                    # System time — wall clock offset for DS log correlation
262                    if record.getEntry() in system_time_ids and stats.wpilog_wall_start is None:
263                        try:
264                            entry_type = entries[record.getEntry()]['type']
265                            if entry_type == 'int64':
266                                # Microseconds since Unix epoch
267                                wall_us = record.getInteger()
268                                wall_sec = wall_us / 1_000_000.0
269                                stats.wpilog_wall_start = datetime.fromtimestamp(
270                                    wall_sec, tz=timezone.utc)
271                            elif entry_type == 'string':
272                                raw = record.getString()
273                                for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S'):
274                                    try:
275                                        stats.wpilog_wall_start = datetime.strptime(
276                                            raw[:19], fmt).replace(tzinfo=timezone.utc)
277                                        break
278                                    except ValueError:
279                                        pass  # try next format
280                        except Exception as e:
281                            logger.debug("Could not parse system time entry: %s", e)
282
283                except Exception as e:
284                    logger.debug("Skipping malformed record: %s", e)
285
286            # Finalize
287            if voltage_count > 0:
288                stats.avg_voltage = round(voltage_sum / voltage_count, 3)
289                stats.voltage_samples = voltage_count
290                if stats.start_voltage is not None:
291                    stats.start_voltage = round(stats.start_voltage, 3)
292                if stats.end_voltage is not None:
293                    stats.end_voltage = round(stats.end_voltage, 3)
294                if stats.min_voltage is not None:
295                    stats.min_voltage = round(stats.min_voltage, 3)
296                if stats.max_voltage is not None:
297                    stats.max_voltage = round(stats.max_voltage, 3)
298
299            if first_timestamp is not None and last_timestamp is not None:
300                stats.match_duration_seconds = round(
301                    last_timestamp - first_timestamp, 3
302                )
303
304            # Finalize rail voltages
305            for rail_name, acc in rail_accum.items():
306                if acc[1] > 0:
307                    stats.rail_voltages[rail_name] = {
308                        'avg': round(acc[0] / acc[1], 3),
309                        'min': round(acc[2], 3),
310                        'max': round(acc[3], 3),
311                        'samples': acc[1],
312                    }
313
314            mm.close()
315
316        return stats

Analyze a single .wpilog file and return match statistics.

def analyze_directory(self, match_dir: pathlib.Path) -> MatchStats:
318    def analyze_directory(self, match_dir: Path) -> MatchStats:
319        """Analyze all .wpilog files in a directory and merge results."""
320        wpilog_files = sorted(match_dir.glob('*.wpilog'))
321        if not wpilog_files:
322            logger.info(f"No .wpilog files found in {match_dir}")
323            return MatchStats()
324
325        all_stats = []
326        for f in wpilog_files:
327            try:
328                s = self.analyze(f)
329                all_stats.append(s)
330            except Exception:
331                logger.exception(f"Failed to analyze {f.name}")
332
333        if not all_stats:
334            return MatchStats()
335        if len(all_stats) == 1:
336            return all_stats[0]
337        return self._merge(all_stats)

Analyze all .wpilog files in a directory and merge results.