host.match_monitor.chart_generator

Generate match analysis charts as PNG images.

  1"""Generate match analysis charts as PNG images."""
  2
  3import io
  4import logging
  5from typing import List, Tuple, Dict
  6
  7logger = logging.getLogger("match_monitor")
  8
  9try:
 10    import matplotlib
 11    matplotlib.use('Agg')  # non-interactive backend
 12    import matplotlib.pyplot as plt
 13    from matplotlib.patches import Patch
 14    _HAS_MATPLOTLIB = True
 15except ImportError:
 16    _HAS_MATPLOTLIB = False
 17
 18# Mode display config: (color, label) — None color means no band drawn
 19_MODE_COLORS = {
 20    'disabled':   (None, 'Disabled'),
 21    'autonomous': ('#3498db', 'Auto'),
 22    'teleop':     ('#2ecc71', 'Teleop'),
 23    'test':       ('#f39c12', 'Test'),
 24}
 25
 26
 27def _build_mode_spans(mode_transitions: List[dict],
 28                      duration: float) -> List[Tuple[float, float, str]]:
 29    """Convert mode transitions into (start, end, mode_name) spans.
 30
 31    Mode transitions contain entries like:
 32        {'time_sec': 5.0, 'mode': 'enabled', 'value': True}
 33        {'time_sec': 5.0, 'mode': 'autonomous', 'value': True}
 34        {'time_sec': 20.0, 'mode': 'autonomous', 'value': False}
 35        {'time_sec': 20.0, 'mode': 'enabled', 'value': False}
 36
 37    Returns spans like: [(0, 5, 'disabled'), (5, 20, 'autonomous'), (20, 150, 'disabled')]
 38    """
 39    if not mode_transitions:
 40        return [(0, duration, 'disabled')]
 41
 42    # Collect state changes at each timestamp, preserving order within a timestamp
 43    events: List[Tuple[float, str, bool]] = []
 44    for t in mode_transitions:
 45        ts = t['time_sec']
 46        mode = t['mode']
 47        # Support both old format (no 'value' key, implied True) and new format
 48        val = t.get('value', True)
 49        if mode in ('enabled', 'autonomous', 'test'):
 50            events.append((ts, mode, val))
 51
 52    # Sort by timestamp (stable sort preserves order within same timestamp)
 53    events.sort(key=lambda e: e[0])
 54
 55    # Build spans by replaying events in order
 56    spans = []
 57    prev_time = 0.0
 58    prev_mode = 'disabled'
 59    state: Dict[str, bool] = {'enabled': False, 'autonomous': False, 'test': False}
 60
 61    for ts, mode, val in events:
 62        state[mode] = val
 63
 64        # Determine current mode from state
 65        if not state['enabled']:
 66            cur_mode = 'disabled'
 67        elif state['autonomous']:
 68            cur_mode = 'autonomous'
 69        elif state['test']:
 70            cur_mode = 'test'
 71        else:
 72            cur_mode = 'teleop'
 73
 74        # Only emit a span when mode actually changes
 75        if cur_mode != prev_mode:
 76            if ts > prev_time:
 77                spans.append((prev_time, ts, prev_mode))
 78            prev_mode = cur_mode
 79            prev_time = ts
 80
 81    # Final span to end of match
 82    if prev_time < duration:
 83        spans.append((prev_time, duration, prev_mode))
 84
 85    return spans
 86
 87
 88def _find_last_match_block(spans: List[Tuple[float, float, str]],
 89                           gap_tolerance: float = 5.0,
 90                           ) -> Tuple[float, float]:
 91    """Find the last contiguous block of non-disabled modes.
 92
 93    Walks backwards from the end of the spans list to find the last active
 94    block. Disabled gaps shorter than gap_tolerance are bridged (e.g.
 95    the brief disabled blip between auto and teleop in an FRC match).
 96
 97    Returns:
 98        (t_zero, match_end) where t_zero is the start of the first
 99        auto/teleop/test span in the block, and match_end is the end
100        of the last active span.  Falls back to (0, last_span_end) if
101        no active spans exist.
102    """
103    if not spans:
104        return (0.0, 0.0)
105
106    # Walk backwards: find last non-disabled span
107    i = len(spans) - 1
108    while i >= 0 and spans[i][2] == 'disabled':
109        i -= 1
110
111    if i < 0:
112        # All disabled
113        return (0.0, spans[-1][1])
114
115    last_block_end = spans[i][1]
116    last_block_start = spans[i][0]
117
118    # Walk backwards: bridge small disabled gaps
119    i -= 1
120    while i >= 0:
121        start, end, mode = spans[i]
122        if mode == 'disabled':
123            # Check the disabled span's own duration
124            gap_duration = end - start
125            if gap_duration > gap_tolerance:
126                break  # large gap — previous enable cycle
127        else:
128            # Active span — extend block start
129            last_block_start = start
130        i -= 1
131
132    # T=0 at first auto/teleop/test in this block, else block start
133    t_zero = last_block_start
134    for start, _end, mode in spans:
135        if start >= last_block_start and mode in ('autonomous', 'teleop', 'test'):
136            t_zero = start
137            break
138
139    return (t_zero, last_block_end)
140
141
142def voltage_chart(timeseries: List[Tuple[float, float]],
143                  brownout_times: List[float] = None,
144                  mode_transitions: List[dict] = None,
145                  match_duration: float = None) -> bytes:
146    """Generate a battery voltage chart with mode bands and return PNG bytes.
147
148    Args:
149        timeseries: List of (time_sec, voltage) tuples.
150        brownout_times: Optional list of brownout timestamps (seconds from start).
151        mode_transitions: Optional list of {'time_sec': float, 'mode': str} dicts.
152        match_duration: Total match duration in seconds.
153
154    Returns:
155        PNG image bytes, or empty bytes if matplotlib is unavailable or data is empty.
156    """
157    if not _HAS_MATPLOTLIB:
158        logger.debug("matplotlib not installed — skipping voltage chart")
159        return b''
160    if not timeseries:
161        return b''
162
163    duration = match_duration or (max(t for t, _ in timeseries))
164
165    _PRE_MATCH_BUFFER = 10.0   # seconds before match start to show
166    _POST_MATCH_BUFFER = 10.0  # seconds after match end to show
167
168    # Debug: dump mode transitions to help diagnose clipping issues
169    if mode_transitions:
170        has_false = any(not t.get('value', True) for t in mode_transitions)
171        logger.info(f"Chart: {len(mode_transitions)} mode transitions, "
172                    f"has_false_values={has_false}, duration={duration:.1f}")
173        for t in mode_transitions[:30]:
174            logger.info(f"  transition: {t}")
175
176    # Find the last contiguous "match" block
177    t_zero = 0.0
178    match_end = duration
179    if mode_transitions:
180        spans = _build_mode_spans(mode_transitions, duration)
181        t_zero, match_end = _find_last_match_block(spans)
182
183        logger.info(f"Chart: spans={len(spans)}, t_zero={t_zero:.1f}, "
184                    f"match_end={match_end:.1f}")
185        for s in spans:
186            logger.debug(f"  span: {s[0]:.1f}-{s[1]:.1f} {s[2]}")
187
188    clip_start = max(0.0, t_zero - _PRE_MATCH_BUFFER)
189    clip_end = min(duration, match_end + _POST_MATCH_BUFFER)
190
191    # Shift timeseries relative to t_zero, clip to window
192    shifted_ts = [(t - t_zero, v) for t, v in timeseries
193                  if clip_start <= t <= clip_end]
194    if not shifted_ts:
195        return b''
196    times = [t for t, _ in shifted_ts]
197    volts = [v for _, v in shifted_ts]
198    x_min = clip_start - t_zero
199    x_max = clip_end - t_zero
200
201    fig, ax = plt.subplots(figsize=(8, 3), dpi=120)
202
203    # Mode background bands
204    seen_modes = set()
205    if mode_transitions:
206        spans = _build_mode_spans(mode_transitions, duration)
207        for start, end, mode in spans:
208            # Shift and clip spans to visible window
209            s = max(start - t_zero, x_min)
210            e = min(end - t_zero, x_max)
211            if e <= s:
212                continue
213            color, _ = _MODE_COLORS.get(mode, ('#cccccc', mode))
214            if color is not None:
215                ax.axvspan(s, e, alpha=0.15, color=color, linewidth=0)
216                seen_modes.add(mode)
217
218    # Voltage trace
219    ax.plot(times, volts, color='#3498db', linewidth=1.0, label='Battery')
220
221    # Reference lines
222    ax.axhline(y=12.0, color='#2ecc71', linewidth=1.0, linestyle='--',
223               alpha=0.7, label='12V nominal')
224    ax.axhline(y=7.0, color='#e74c3c', linewidth=1.0, linestyle='--',
225               alpha=0.7, label='7V brownout')
226
227    # Brownout markers
228    if brownout_times:
229        for bt in brownout_times:
230            bt_shifted = bt - t_zero
231            if bt_shifted >= clip_start - t_zero:
232                ax.axvline(x=bt_shifted, color='#e74c3c', linewidth=0.8,
233                           linestyle=':', alpha=0.5)
234
235    ax.set_xlabel('Match Time (s)', fontsize=9)
236    ax.set_ylabel('Voltage (V)', fontsize=9)
237    ax.set_title('Battery Voltage', fontsize=10, fontweight='bold')
238    ax.set_xlim(left=x_min, right=x_max)
239    ax.set_ylim(bottom=0, top=14)
240    ax.grid(True, alpha=0.3)
241    ax.tick_params(labelsize=8)
242
243    # Build legend: voltage items on the left, mode patches on the right
244    voltage_legend = ax.legend(loc='lower left', fontsize=7, framealpha=0.8)
245    ax.add_artist(voltage_legend)
246
247    if seen_modes:
248        mode_patches = [
249            Patch(facecolor=_MODE_COLORS[m][0], alpha=0.3, label=_MODE_COLORS[m][1])
250            for m in ('disabled', 'autonomous', 'teleop', 'test')
251            if m in seen_modes
252        ]
253        ax.legend(handles=mode_patches, loc='lower right', fontsize=7,
254                  framealpha=0.8, title='Mode', title_fontsize=7)
255
256    fig.tight_layout()
257
258    buf = io.BytesIO()
259    fig.savefig(buf, format='png', bbox_inches='tight')
260    plt.close(fig)
261    buf.seek(0)
262    return buf.read()
logger = <Logger match_monitor (DEBUG)>
def voltage_chart( timeseries: List[Tuple[float, float]], brownout_times: List[float] = None, mode_transitions: List[dict] = None, match_duration: float = None) -> bytes:
143def voltage_chart(timeseries: List[Tuple[float, float]],
144                  brownout_times: List[float] = None,
145                  mode_transitions: List[dict] = None,
146                  match_duration: float = None) -> bytes:
147    """Generate a battery voltage chart with mode bands and return PNG bytes.
148
149    Args:
150        timeseries: List of (time_sec, voltage) tuples.
151        brownout_times: Optional list of brownout timestamps (seconds from start).
152        mode_transitions: Optional list of {'time_sec': float, 'mode': str} dicts.
153        match_duration: Total match duration in seconds.
154
155    Returns:
156        PNG image bytes, or empty bytes if matplotlib is unavailable or data is empty.
157    """
158    if not _HAS_MATPLOTLIB:
159        logger.debug("matplotlib not installed — skipping voltage chart")
160        return b''
161    if not timeseries:
162        return b''
163
164    duration = match_duration or (max(t for t, _ in timeseries))
165
166    _PRE_MATCH_BUFFER = 10.0   # seconds before match start to show
167    _POST_MATCH_BUFFER = 10.0  # seconds after match end to show
168
169    # Debug: dump mode transitions to help diagnose clipping issues
170    if mode_transitions:
171        has_false = any(not t.get('value', True) for t in mode_transitions)
172        logger.info(f"Chart: {len(mode_transitions)} mode transitions, "
173                    f"has_false_values={has_false}, duration={duration:.1f}")
174        for t in mode_transitions[:30]:
175            logger.info(f"  transition: {t}")
176
177    # Find the last contiguous "match" block
178    t_zero = 0.0
179    match_end = duration
180    if mode_transitions:
181        spans = _build_mode_spans(mode_transitions, duration)
182        t_zero, match_end = _find_last_match_block(spans)
183
184        logger.info(f"Chart: spans={len(spans)}, t_zero={t_zero:.1f}, "
185                    f"match_end={match_end:.1f}")
186        for s in spans:
187            logger.debug(f"  span: {s[0]:.1f}-{s[1]:.1f} {s[2]}")
188
189    clip_start = max(0.0, t_zero - _PRE_MATCH_BUFFER)
190    clip_end = min(duration, match_end + _POST_MATCH_BUFFER)
191
192    # Shift timeseries relative to t_zero, clip to window
193    shifted_ts = [(t - t_zero, v) for t, v in timeseries
194                  if clip_start <= t <= clip_end]
195    if not shifted_ts:
196        return b''
197    times = [t for t, _ in shifted_ts]
198    volts = [v for _, v in shifted_ts]
199    x_min = clip_start - t_zero
200    x_max = clip_end - t_zero
201
202    fig, ax = plt.subplots(figsize=(8, 3), dpi=120)
203
204    # Mode background bands
205    seen_modes = set()
206    if mode_transitions:
207        spans = _build_mode_spans(mode_transitions, duration)
208        for start, end, mode in spans:
209            # Shift and clip spans to visible window
210            s = max(start - t_zero, x_min)
211            e = min(end - t_zero, x_max)
212            if e <= s:
213                continue
214            color, _ = _MODE_COLORS.get(mode, ('#cccccc', mode))
215            if color is not None:
216                ax.axvspan(s, e, alpha=0.15, color=color, linewidth=0)
217                seen_modes.add(mode)
218
219    # Voltage trace
220    ax.plot(times, volts, color='#3498db', linewidth=1.0, label='Battery')
221
222    # Reference lines
223    ax.axhline(y=12.0, color='#2ecc71', linewidth=1.0, linestyle='--',
224               alpha=0.7, label='12V nominal')
225    ax.axhline(y=7.0, color='#e74c3c', linewidth=1.0, linestyle='--',
226               alpha=0.7, label='7V brownout')
227
228    # Brownout markers
229    if brownout_times:
230        for bt in brownout_times:
231            bt_shifted = bt - t_zero
232            if bt_shifted >= clip_start - t_zero:
233                ax.axvline(x=bt_shifted, color='#e74c3c', linewidth=0.8,
234                           linestyle=':', alpha=0.5)
235
236    ax.set_xlabel('Match Time (s)', fontsize=9)
237    ax.set_ylabel('Voltage (V)', fontsize=9)
238    ax.set_title('Battery Voltage', fontsize=10, fontweight='bold')
239    ax.set_xlim(left=x_min, right=x_max)
240    ax.set_ylim(bottom=0, top=14)
241    ax.grid(True, alpha=0.3)
242    ax.tick_params(labelsize=8)
243
244    # Build legend: voltage items on the left, mode patches on the right
245    voltage_legend = ax.legend(loc='lower left', fontsize=7, framealpha=0.8)
246    ax.add_artist(voltage_legend)
247
248    if seen_modes:
249        mode_patches = [
250            Patch(facecolor=_MODE_COLORS[m][0], alpha=0.3, label=_MODE_COLORS[m][1])
251            for m in ('disabled', 'autonomous', 'teleop', 'test')
252            if m in seen_modes
253        ]
254        ax.legend(handles=mode_patches, loc='lower right', fontsize=7,
255                  framealpha=0.8, title='Mode', title_fontsize=7)
256
257    fig.tight_layout()
258
259    buf = io.BytesIO()
260    fig.savefig(buf, format='png', bbox_inches='tight')
261    plt.close(fig)
262    buf.seek(0)
263    return buf.read()

Generate a battery voltage chart with mode bands and return PNG bytes.

Args: timeseries: List of (time_sec, voltage) tuples. brownout_times: Optional list of brownout timestamps (seconds from start). mode_transitions: Optional list of {'time_sec': float, 'mode': str} dicts. match_duration: Total match duration in seconds.

Returns: PNG image bytes, or empty bytes if matplotlib is unavailable or data is empty.