From dc687c35595ef99924fb101155b298586b33fc89 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Tue, 30 Jun 2026 23:17:50 +0200 Subject: [PATCH] feat: embed config in server log; replay_sim takes a single log path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerLogTask now embeds the full config in its JSON output, matching SudLogTask — so replay_sim and analyze_log work on server logs without needing a separate config file. replay_sim.py switches from two positional args (date_time, sud_name) to a single log file path, with config and plant params resolved from the log's embedded "Config"/"PlantParams" sections when present and falling back to config.json / hardcoded defaults for older logs. Co-Authored-By: Claude Sonnet 4.6 --- server/brewpi.py | 2 +- tasks/server_log.py | 9 +++- utils/replay_sim.py | 100 ++++++++++++++++++++++++-------------------- 3 files changed, 62 insertions(+), 49 deletions(-) diff --git a/server/brewpi.py b/server/brewpi.py index 30ef0d2..fda4d34 100755 --- a/server/brewpi.py +++ b/server/brewpi.py @@ -137,7 +137,7 @@ if __name__ == '__main__': # Continuous server-session log - records from startup to shutdown # regardless of Sud state; written to logs/log_{date_time}.json on exit. - server_log_task = ServerLogTask(tc, heater, heater_task, DT_TASK, args.logdir) + server_log_task = ServerLogTask(tc, heater, heater_task, DT_TASK, args.logdir, config=config) taskmgr.add(server_log_task) # Assign data flow diff --git a/tasks/server_log.py b/tasks/server_log.py index 06e160c..f5b1ed5 100644 --- a/tasks/server_log.py +++ b/tasks/server_log.py @@ -16,12 +16,13 @@ class ServerLogTask(ATask): Sud state — useful for verifying controller and heater behaviour outside of a scheduled brew.""" - def __init__(self, tc: APid, heater: AHeater, heater_task, interval, path='./logs'): + def __init__(self, tc: APid, heater: AHeater, heater_task, interval, path='./logs', config=None): ATask.__init__(self, interval) self.tc = tc self.heater = heater self.heater_task = heater_task self.path = path + self.config = config self._samples = [] self._t0 = time.monotonic() self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime()) @@ -47,6 +48,10 @@ class ServerLogTask(ATask): os.makedirs(self.path, exist_ok=True) filename = 'log_{}.json'.format(self._run_id) path = os.path.join(self.path, filename) + log_data = {'Name': ''} + if self.config is not None: + log_data['Config'] = self.config + log_data['Samples'] = self._samples with open(path, 'w') as f: - json.dump({'Name': '', 'Samples': self._samples}, f, indent='\t') + json.dump(log_data, f, indent='\t') print('Server log: wrote {} samples to {}'.format(len(self._samples), filename)) diff --git a/utils/replay_sim.py b/utils/replay_sim.py index 8c713cf..f055cf3 100644 --- a/utils/replay_sim.py +++ b/utils/replay_sim.py @@ -13,18 +13,19 @@ plant reaches setpoint faster, overshoots less, or oscillates less than the original logged run, all without touching real hardware. Usage: - python utils/replay_sim.py - python utils/replay_sim.py \\ + python utils/replay_sim.py logs/log__.json + python utils/replay_sim.py logs/log__.json \\ --heat-kp 0.1 --heat-ki 0.03 --heat-kt 1.0 - python utils/replay_sim.py \\ + python utils/replay_sim.py logs/log__.json \\ --plant-M 25 --plant-L 0.15 --ambient 18 -Plant params default to water (C=4190 J/kg·K), M=20 kg, L=0.2 W/kg·K, -Td=30 s — pass --plant-* to match the actual batch. Max heater power is -inferred from the highest power_eff step in the log. +PID params and plant params are read from the log's embedded "Config" and +"PlantParams" sections when present (logs recorded since the config-in-log +feature was added). Pass --config to supply a config file for older logs +that predate that feature. Any --
- or --plant-* flag +overrides just that one value regardless of source. -By default reads PID params from config.json in the repo root. Any ---
- flag overrides just that one value. +Max heater power is inferred from the highest power_eff step in the log. """ import argparse @@ -158,34 +159,27 @@ def plot_replay(samples, replay, name, max_power, params_label): def main(): - repo_root = Path(__file__).parent.parent - default_config = repo_root / 'config.json' - default_log_dir = repo_root / 'logs' + repo_root = Path(__file__).parent.parent + default_config = repo_root / 'config.json' parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument('date_time', help='Timestamp part of log filename, e.g. 20260628T184903') - parser.add_argument('sud_name', help='Sud name part of log filename, e.g. Sud-0010') - parser.add_argument('--log-dir', default=str(default_log_dir), - help='Log directory (default: %(default)s)') - parser.add_argument('--config', default=str(default_config), - help='Config file for base PID params (default: %(default)s)') + parser.add_argument('log', help='Path to log_*.json file') + parser.add_argument('--config', default=None, + help='Config file for PID params (default: embedded in log, or config.json)') parser.add_argument('--rate-soll', type=float, default=None, help='Heat-rate setpoint [K/min] (default: inferred from log)') parser.add_argument('--ambient', type=float, default=None, - help='Ambient temperature [°C] (default: from config ambient_temperature)') + help='Ambient temperature [°C] (default: from config)') - # Plant model params - parser.add_argument('--plant-M', type=float, default=20.0, metavar='kg', - help='Plant mass [kg] (default: %(default)s)') - parser.add_argument('--plant-C', type=float, default=4190.0, metavar='J/kgK', - help='Specific heat capacity [J/(kg·K)] (default: %(default)s — water)') - parser.add_argument('--plant-L', type=float, default=0.2, metavar='W/kgK', - help='Heat loss coefficient [W/(kg·K)] (default: %(default)s)') - parser.add_argument('--plant-Td', type=float, default=30.0, metavar='s', - help='Transport delay [s] (default: %(default)s)') + # Plant model params — all default to None so we can tell "not specified" + # and fall back to the log's embedded PlantParams when available. + parser.add_argument('--plant-M', type=float, default=None, metavar='kg', help='Plant mass [kg]') + parser.add_argument('--plant-C', type=float, default=None, metavar='J/kgK', help='Specific heat [J/(kg·K)]') + parser.add_argument('--plant-L', type=float, default=None, metavar='W/kgK', help='Heat loss coeff [W/(kg·K)]') + parser.add_argument('--plant-Td', type=float, default=None, metavar='s', help='Transport delay [s]') parser.add_argument('--heater-efficiency', type=float, default=1.0, metavar='0-1', help='Fraction of rated heater power delivered to the plant (default: %(default)s)') @@ -201,44 +195,58 @@ def main(): args = parser.parse_args() - log_path = Path(args.log_dir) / 'log_{}_{}.json'.format(args.date_time, args.sud_name) + log_path = Path(args.log) if not log_path.exists(): print('Error: log file not found: {}'.format(log_path), file=sys.stderr) sys.exit(1) - config_path = Path(args.config) - if not config_path.exists(): - print('Error: config file not found: {}'.format(config_path), file=sys.stderr) - sys.exit(1) - with open(log_path) as f: log = json.load(f) - with open(config_path) as f: - config = json.load(f) - samples = log['Samples'] - name = log.get('Name', '{}_{}'.format(args.date_time, args.sud_name)) - pid_params = _apply_gain_overrides(config['TempCtrl'], args) - dt = (samples[1]['t'] - samples[0]['t']) if len(samples) > 1 else 1.0 - max_power = _infer_max_power(samples) - rate_soll = args.rate_soll if args.rate_soll is not None else _infer_heatrate_soll_set(samples) - ambient = args.ambient if args.ambient is not None else config.get('ambient_temperature', 20.0) + # Config: prefer embedded in log, then --config arg, then default config.json. + config = log.get('Config') + if config is None: + config_path = Path(args.config) if args.config else default_config + if config_path.exists(): + with open(config_path) as f: + config = json.load(f) + else: + print('Error: no config embedded in log and config file not found: {}'.format(config_path), + file=sys.stderr) + sys.exit(1) - plant_params = {'M': args.plant_M, 'C': args.plant_C, 'L': args.plant_L, 'Td': args.plant_Td} + # Plant params: prefer log's PlantParams[0], then --plant-* flags, then hardcoded defaults. + log_plant = log['PlantParams'][0]['params'] if log.get('PlantParams') else None + plant_params = { + 'M': args.plant_M if args.plant_M is not None else (log_plant['M'] if log_plant else 20.0), + 'C': args.plant_C if args.plant_C is not None else (log_plant['C'] if log_plant else 4190.0), + 'L': args.plant_L if args.plant_L is not None else (log_plant['L'] if log_plant else 0.2), + 'Td': args.plant_Td if args.plant_Td is not None else (log_plant['Td'] if log_plant else 30.0), + } + + samples = log['Samples'] + name = log.get('Name', log_path.stem) + pid_params = _apply_gain_overrides(config['TempCtrl'], args) + dt = (samples[1]['t'] - samples[0]['t']) if len(samples) > 1 else 1.0 + max_power = _infer_max_power(samples) + rate_soll = args.rate_soll if args.rate_soll is not None else _infer_heatrate_soll_set(samples) + ambient = args.ambient if args.ambient is not None else config.get('ambient_temperature', 20.0) any_override = any( getattr(args, '{}_{}'.format(p, g)) is not None for p in ('hold', 'heat', 'cool') for g in ('kp', 'ki', 'kd', 'kt') ) - params_label = 'candidate' if any_override else 'config' + config_source = 'log' if log.get('Config') else 'file' + params_label = 'candidate' if any_override else config_source print('Log: {} ({} samples, dt={:.1f} s)'.format(name, len(samples), dt)) print('Power: max={:.0f} W'.format(max_power)) print('Rate: heatrate_soll_set={:.2f} K/min'.format(rate_soll)) - print('Plant: M={M} kg C={C} J/kgK L={L} W/kgK Td={Td} s ambient={amb} °C heater_efficiency={eff}'.format( + print('Plant: M={M} kg C={C:.0f} J/kgK L={L} W/kgK Td={Td} s ambient={amb} °C heater_efficiency={eff}'.format( amb=ambient, eff=args.heater_efficiency, **plant_params)) - print('Params: {}'.format('overridden' if any_override else 'from config')) + print('Params: {} ({})'.format('overridden' if any_override else 'from {}'.format(config_source), + 'log' if log_plant else 'defaults')) print() for section in ('Hold', 'Heat', 'Cool'): p = pid_params[section]