""" Matplotlib GUI for SC-F001 logtool. """ from parser import LOG_TYPE_BAT, LOG_TYPE_CRASH, _ts_to_str try: import matplotlib.pyplot as plt import matplotlib.dates as mdates from matplotlib.animation import FuncAnimation import numpy as np _MPL_OK = True except ImportError: _MPL_OK = False def _check_mpl(): if not _MPL_OK: raise ImportError("'matplotlib' and 'numpy' required for GUI mode. Install: pip install matplotlib") def _entries_to_arrays(entries: list) -> dict: """Split entries into typed arrays for plotting.""" fsm = [e for e in entries if 0 <= e.get('entry_type', -1) <= 12] bat = [e for e in entries if e.get('entry_type') == LOG_TYPE_BAT] crash = [e for e in entries if e.get('entry_type') == LOG_TYPE_CRASH] return {'fsm': fsm, 'bat': bat, 'crash': crash} def _ts_arr(entries, key='ts_ms'): import numpy as np return np.array([e.get(key, 0) / 1000.0 for e in entries]) def _val_arr(entries, key): import numpy as np return np.array([e.get(key, float('nan')) for e in entries]) def show_plots(entries: list, title: str = "SC-F001 Log"): _check_mpl() import matplotlib.pyplot as plt import matplotlib.dates as mdates import numpy as np from datetime import datetime arrays = _entries_to_arrays(entries) fsm = arrays['fsm'] bat = arrays['bat'] crash = arrays['crash'] crash_ts = [e.get('ts_ms', 0) / 1000.0 for e in crash] fig, axes = plt.subplots(4, 1, figsize=(14, 10), sharex=True) fig.suptitle(title) def add_crash_lines(ax): for ts in crash_ts: ax.axvline(x=datetime.utcfromtimestamp(ts), color='red', linestyle='--', linewidth=1.0, alpha=0.7) def to_dt(ts_arr): return [datetime.utcfromtimestamp(t) for t in ts_arr] # 1. Battery voltage ax0 = axes[0] ax0.set_ylabel('Battery (V)') all_bat_entries = fsm + bat all_bat_entries.sort(key=lambda e: e.get('ts_ms', 0)) if all_bat_entries: ts = to_dt(_ts_arr(all_bat_entries)) vs = _val_arr(all_bat_entries, 'bat_V') ax0.plot(ts, vs, color='green', linewidth=1) add_crash_lines(ax0) ax0.grid(True, alpha=0.3) # 2. Currents ax1 = axes[1] ax1.set_ylabel('Current (A)') if fsm: ts = to_dt(_ts_arr(fsm)) ax1.plot(ts, _val_arr(fsm, 'drive_A'), label='Drive', linewidth=1) ax1.plot(ts, _val_arr(fsm, 'jack_A'), label='Jack', linewidth=1) ax1.plot(ts, _val_arr(fsm, 'aux_A'), label='Aux', linewidth=1) ax1.legend(fontsize=8, loc='upper right') add_crash_lines(ax1) ax1.grid(True, alpha=0.3) # 3. FSM state ax2 = axes[2] ax2.set_ylabel('FSM State') if fsm: ts = to_dt(_ts_arr(fsm)) states = _val_arr(fsm, 'entry_type') ax2.step(ts, states, where='post', linewidth=1, color='navy') # y-tick labels: use state names from first entry if available state_map = {} for e in fsm: state_map[e['entry_type']] = e.get('state_name', str(e['entry_type'])) yticks = sorted(state_map.keys()) ax2.set_yticks(yticks) ax2.set_yticklabels([state_map[k] for k in yticks], fontsize=7) add_crash_lines(ax2) ax2.grid(True, alpha=0.3) # 4. Thermal accumulators ax3 = axes[3] ax3.set_ylabel('Heat (I²t)') if fsm: ts = to_dt(_ts_arr(fsm)) ax3.plot(ts, _val_arr(fsm, 'drive_heat'), label='Drive', linewidth=1) ax3.plot(ts, _val_arr(fsm, 'jack_heat'), label='Jack', linewidth=1) ax3.plot(ts, _val_arr(fsm, 'aux_heat'), label='Aux', linewidth=1) ax3.legend(fontsize=8, loc='upper right') add_crash_lines(ax3) ax3.grid(True, alpha=0.3) ax3.xaxis.set_major_formatter(mdates.AutoDateFormatter(ax3.xaxis.get_major_locator())) fig.autofmt_xdate() plt.tight_layout() plt.show() def live_plot(url: str, interval_s: float = 2.0): """Live-updating matplotlib plot using FuncAnimation.""" _check_mpl() import matplotlib.pyplot as plt import matplotlib.dates as mdates from matplotlib.animation import FuncAnimation from datetime import datetime import source as src import parser as prs all_entries = [] fig, axes = plt.subplots(4, 1, figsize=(14, 10), sharex=True) fig.suptitle("SC-F001 Live Log") labels = ['Battery (V)', 'Current (A)', 'FSM State', 'Heat (I²t)'] for ax, lbl in zip(axes, labels): ax.set_ylabel(lbl) ax.grid(True, alpha=0.3) lines = { 'bat': axes[0].plot([], [], color='green', linewidth=1)[0], 'drive': axes[1].plot([], [], label='Drive', linewidth=1)[0], 'jack': axes[1].plot([], [], label='Jack', linewidth=1)[0], 'aux': axes[1].plot([], [], label='Aux', linewidth=1)[0], 'state': axes[2].step([], [], where='post', linewidth=1, color='navy')[0], 'drheat': axes[3].plot([], [], label='Drive', linewidth=1)[0], 'jkheat': axes[3].plot([], [], label='Jack', linewidth=1)[0], 'axheat': axes[3].plot([], [], label='Aux', linewidth=1)[0], } axes[1].legend(fontsize=8, loc='upper right') axes[3].legend(fontsize=8, loc='upper right') axes[3].xaxis.set_major_formatter(mdates.AutoDateFormatter(axes[3].xaxis.get_major_locator())) state = {'current_tail': 0, 'first': True} def to_dt(ts_list): return [datetime.utcfromtimestamp(t / 1000.0) for t in ts_list] def update(_frame): try: if state['first']: blob = src.fetch_full(url) meta, tail, head, new_entries = prs.autodetect_and_parse(blob) state['current_tail'] = head or 0 state['first'] = False else: binary, new_head = src.fetch_incremental(url, state['current_tail']) new_entries = prs.parse_entries(binary) if binary else [] state['current_tail'] = new_head all_entries.extend(new_entries) except Exception as exc: print(f"[live_plot] fetch error: {exc}") return fsm = [e for e in all_entries if 0 <= e.get('entry_type', -1) <= 12] bat = [e for e in all_entries if e.get('entry_type') in (LOG_TYPE_BAT,) or 'bat_V' in e] crash = [e for e in all_entries if e.get('entry_type') == LOG_TYPE_CRASH] if fsm: ts = to_dt([e['ts_ms'] for e in fsm]) lines['drive'].set_data(ts, [e.get('drive_A', 0) for e in fsm]) lines['jack'].set_data( ts, [e.get('jack_A', 0) for e in fsm]) lines['aux'].set_data( ts, [e.get('aux_A', 0) for e in fsm]) lines['state'].set_data(ts, [e.get('entry_type', 0) for e in fsm]) lines['drheat'].set_data(ts, [e.get('drive_heat', 0) for e in fsm]) lines['jkheat'].set_data(ts, [e.get('jack_heat', 0) for e in fsm]) lines['axheat'].set_data(ts, [e.get('aux_heat', 0) for e in fsm]) all_bat = sorted( [e for e in all_entries if 'bat_V' in e], key=lambda e: e.get('ts_ms', 0) ) if all_bat: ts = to_dt([e['ts_ms'] for e in all_bat]) lines['bat'].set_data(ts, [e['bat_V'] for e in all_bat]) for ax in axes: ax.relim() ax.autoscale_view() fig.autofmt_xdate() ani = FuncAnimation(fig, update, interval=interval_s * 1000, cache_frame_data=False) plt.tight_layout() plt.show()