Renders the same two plots the PyQt GUI shows (realtime 3-panel strip chart and forecast-vs-actual comparison) from a log/forecast JSON pair, specified by date_time and sud_name on the command line. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSo8R6GjoBQdStB3j67zSs
138 lines
4.7 KiB
Python
138 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Offline analysis of a log/forecast pair produced by the BrewPi server.
|
|
|
|
Usage:
|
|
python analyze_log.py <date_time> <sud_name> [--log-dir <dir>]
|
|
|
|
Example:
|
|
python analyze_log.py 20260628T184903 Sud-0010
|
|
|
|
Loads logs/<date_time>_{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()
|