Files
brewpi/utils/analyze_log.py
T
jensandClaude Sonnet 4.6 9be0e49a63 analyze_log: accept a direct log path as single argument
The two-argument form (date_time sud_name) is unchanged.  When called
with a single argument it is treated as a direct path to any log_*.json
(e.g. a server-session log without a Sud name); the forecast panel is
shown only if a matching forecast_*.json exists alongside it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:11:31 +02:00

149 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""Offline analysis of a log (and optional forecast) produced by the BrewPi server.
Usage:
python analyze_log.py <date_time> <sud_name> [--log-dir <dir>]
python analyze_log.py <path/to/log_*.json>
The two-argument form loads logs/<date_time>_<sud_name>.json and its paired
forecast file. The single-argument form accepts a direct path to any
log_*.json (e.g. a server-session log without a Sud name); the forecast panel
is shown only if a matching forecast_*.json exists alongside it.
Renders the 3-panel realtime strip chart and, when available, 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_or_path',
help='Timestamp part of the filename (e.g. 20260628T184903) '
'or a direct path to a log_*.json file')
parser.add_argument('sud_name', nargs='?', default=None,
help='Sud name part of the filename (e.g. Sud-0010); '
'omit when passing a direct file path')
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()
if args.sud_name is None:
log_path = Path(args.date_time_or_path)
stem = log_path.stem[len('log_'):] if log_path.stem.startswith('log_') else log_path.stem
forecast_path = log_path.parent / 'forecast_{}.json'.format(stem)
else:
log_dir = Path(args.log_dir)
stem = '{}_{}'.format(args.date_time_or_path, args.sud_name)
log_path = log_dir / 'log_{}.json'.format(stem)
forecast_path = log_dir / 'forecast_{}.json'.format(stem)
if not log_path.exists():
print('Error: file not found: {}'.format(log_path), file=sys.stderr)
sys.exit(1)
with open(log_path) as f:
log = json.load(f)
name = log.get('Name', stem)
samples = log['Samples']
plot_realtime(samples, name)
if forecast_path.exists():
with open(forecast_path) as f:
forecast = json.load(f)
plot_forecast(forecast, samples, name)
plt.show()
if __name__ == '__main__':
main()