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>
149 lines
5.4 KiB
Python
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()
|