feat: embed config in server log; replay_sim takes a single log path

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 23:17:50 +02:00
co-authored by Claude Sonnet 4.6
parent 4de6efcf82
commit dc687c3559
3 changed files with 62 additions and 49 deletions
+1 -1
View File
@@ -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
+7 -2
View File
@@ -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))
+54 -46
View File
@@ -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 <date_time> <sud_name>
python utils/replay_sim.py <date_time> <sud_name> \\
python utils/replay_sim.py logs/log_<date_time>_<name>.json
python utils/replay_sim.py logs/log_<date_time>_<name>.json \\
--heat-kp 0.1 --heat-ki 0.03 --heat-kt 1.0
python utils/replay_sim.py <date_time> <sud_name> \\
python utils/replay_sim.py logs/log_<date_time>_<name>.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 --<section>-<gain> or --plant-* flag
overrides just that one value regardless of source.
By default reads PID params from config.json in the repo root. Any
--<section>-<gain> 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]