diff --git a/utils/analyze_log.py b/utils/analyze_log.py new file mode 100644 index 0000000..c52ae0f --- /dev/null +++ b/utils/analyze_log.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Offline analysis of a log/forecast pair produced by the BrewPi server. + +Usage: + python analyze_log.py [--log-dir ] + +Example: + python analyze_log.py 20260628T184903 Sud-0010 + +Loads logs/_{sud_name}.json (log and forecast) and renders the same +two plots the PyQt GUI shows: the 3-panel realtime strip chart and the +forecast-vs-actual comparison. +""" + +import argparse +import json +import sys +from pathlib import Path + +import matplotlib +matplotlib.use("TkAgg") +import matplotlib.pyplot as plt +from matplotlib.ticker import AutoMinorLocator + + +def _style_axis(ax): + ax.set_facecolor('white') + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + ax.xaxis.set_minor_locator(AutoMinorLocator()) + ax.yaxis.set_minor_locator(AutoMinorLocator()) + ax.tick_params(which='major', direction='out', labelsize='small') + ax.tick_params(which='minor', direction='out', length=2) + ax.grid(which='major', linestyle='-', linewidth=0.5, alpha=0.3) + ax.grid(which='minor', linestyle=':', linewidth=0.5, alpha=0.15) + + +def plot_realtime(samples, name): + """3-panel strip chart mirroring RealtimePlot in the PyQt GUI.""" + t = [s['t'] for s in samples] + temp_ist = [s['temp_ist'] for s in samples] + temp_soll = [s['temp_soll'] for s in samples] + rate_ist = [s['rate_ist'] for s in samples] + rate_soll = [s['rate_soll'] for s in samples] + power_set = [s['power_set'] for s in samples] + power_eff = [s['power_eff'] for s in samples] + + fig, (ax_temp, ax_rate, ax_power) = plt.subplots(3, 1, sharex=True, facecolor='white') + fig.suptitle(name, fontsize='small') + + ax_temp.plot(t, temp_ist, '-b', linewidth=1, label='theta_ist') + ax_temp.plot(t, temp_soll, '-r', linewidth=1, label='theta_soll') + ax_temp.set_ylabel('Temp [°C]') + ax_temp.legend(loc='upper left', fontsize='small') + + ax_rate.plot(t, rate_ist, '-b', linewidth=1, label='heatrate_ist') + ax_rate.plot(t, rate_soll, '-r', linewidth=1, label='heatrate_soll') + ax_rate.set_ylabel('Rate [K/min]') + ax_rate.legend(loc='upper left', fontsize='small') + + ax_power.plot(t, power_set, '-r', linewidth=1, label='power_set') + ax_power.plot(t, power_eff, '-b', linewidth=1, label='power_eff') + ax_power.set_ylabel('Power [W]') + ax_power.set_xlabel('t [s]') + ax_power.legend(loc='upper left', fontsize='small') + + for ax in (ax_temp, ax_rate, ax_power): + _style_axis(ax) + + ax_temp.label_outer() + ax_rate.label_outer() + + fig.tight_layout() + return fig + + +def plot_forecast(forecast, samples, name): + """Forecast-vs-actual comparison mirroring SudForecastPlot in the PyQt GUI.""" + t_min_fc = [t / 60.0 for t in forecast['T']] + theta_fc = forecast['Theta'] + finished = forecast['Finished'] + + t_min_actual = [s['t'] / 60.0 for s in samples] + theta_actual = [s['temp_ist'] for s in samples] + + fig, ax = plt.subplots(1, 1, facecolor='white') + + ax.plot(t_min_actual, theta_actual, '-b', linewidth=1.5, zorder=2, label='actual') + ax.plot(t_min_fc, theta_fc, '-b', linewidth=1.5, alpha=0.5, zorder=2, label='forecast') + + total = t_min_fc[-1] if t_min_fc else 0.0 + suffix = '' if finished else ' so far' + ax.set_title('{} - {:.0f} min{} total'.format(name, total, suffix), fontsize='small') + ax.set_xlabel('t [min]') + ax.set_ylabel('Temp [°C]') + ax.legend(loc='upper left', fontsize='small') + _style_axis(ax) + + fig.tight_layout() + return fig + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('date_time', help='Timestamp part of the filename, e.g. 20260628T184903') + parser.add_argument('sud_name', help='Sud name part of the filename, e.g. Sud-0010') + default_log_dir = Path(__file__).parent.parent / 'logs' + parser.add_argument('--log-dir', default=str(default_log_dir), + help='Directory containing the log files (default: %(default)s)') + args = parser.parse_args() + + log_dir = Path(args.log_dir) + stem = '{}_{}'.format(args.date_time, args.sud_name) + log_path = log_dir / 'log_{}.json'.format(stem) + forecast_path = log_dir / 'forecast_{}.json'.format(stem) + + for path in (log_path, forecast_path): + if not path.exists(): + print('Error: file not found: {}'.format(path), file=sys.stderr) + sys.exit(1) + + with open(log_path) as f: + log = json.load(f) + with open(forecast_path) as f: + forecast = json.load(f) + + name = log.get('Name', stem) + samples = log['Samples'] + + plot_realtime(samples, name) + plot_forecast(forecast, samples, name) + + plt.show() + + +if __name__ == '__main__': + main()