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:
+1
-1
@@ -137,7 +137,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
# Continuous server-session log - records from startup to shutdown
|
# Continuous server-session log - records from startup to shutdown
|
||||||
# regardless of Sud state; written to logs/log_{date_time}.json on exit.
|
# 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)
|
taskmgr.add(server_log_task)
|
||||||
|
|
||||||
# Assign data flow
|
# Assign data flow
|
||||||
|
|||||||
+7
-2
@@ -16,12 +16,13 @@ class ServerLogTask(ATask):
|
|||||||
Sud state — useful for verifying controller and heater behaviour
|
Sud state — useful for verifying controller and heater behaviour
|
||||||
outside of a scheduled brew."""
|
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)
|
ATask.__init__(self, interval)
|
||||||
self.tc = tc
|
self.tc = tc
|
||||||
self.heater = heater
|
self.heater = heater
|
||||||
self.heater_task = heater_task
|
self.heater_task = heater_task
|
||||||
self.path = path
|
self.path = path
|
||||||
|
self.config = config
|
||||||
self._samples = []
|
self._samples = []
|
||||||
self._t0 = time.monotonic()
|
self._t0 = time.monotonic()
|
||||||
self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime())
|
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)
|
os.makedirs(self.path, exist_ok=True)
|
||||||
filename = 'log_{}.json'.format(self._run_id)
|
filename = 'log_{}.json'.format(self._run_id)
|
||||||
path = os.path.join(self.path, filename)
|
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:
|
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))
|
print('Server log: wrote {} samples to {}'.format(len(self._samples), filename))
|
||||||
|
|||||||
+45
-37
@@ -13,18 +13,19 @@ plant reaches setpoint faster, overshoots less, or oscillates less than
|
|||||||
the original logged run, all without touching real hardware.
|
the original logged run, all without touching real hardware.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
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 <date_time> <sud_name> \\
|
python utils/replay_sim.py logs/log_<date_time>_<name>.json \\
|
||||||
--heat-kp 0.1 --heat-ki 0.03 --heat-kt 1.0
|
--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-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,
|
PID params and plant params are read from the log's embedded "Config" and
|
||||||
Td=30 s — pass --plant-* to match the actual batch. Max heater power is
|
"PlantParams" sections when present (logs recorded since the config-in-log
|
||||||
inferred from the highest power_eff step in the 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
|
Max heater power is inferred from the highest power_eff step in the log.
|
||||||
--<section>-<gain> flag overrides just that one value.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -160,32 +161,25 @@ def plot_replay(samples, replay, name, max_power, params_label):
|
|||||||
def main():
|
def main():
|
||||||
repo_root = Path(__file__).parent.parent
|
repo_root = Path(__file__).parent.parent
|
||||||
default_config = repo_root / 'config.json'
|
default_config = repo_root / 'config.json'
|
||||||
default_log_dir = repo_root / 'logs'
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description=__doc__,
|
description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
)
|
)
|
||||||
parser.add_argument('date_time', help='Timestamp part of log filename, e.g. 20260628T184903')
|
parser.add_argument('log', help='Path to log_*.json file')
|
||||||
parser.add_argument('sud_name', help='Sud name part of log filename, e.g. Sud-0010')
|
parser.add_argument('--config', default=None,
|
||||||
parser.add_argument('--log-dir', default=str(default_log_dir),
|
help='Config file for PID params (default: embedded in log, or config.json)')
|
||||||
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('--rate-soll', type=float, default=None,
|
parser.add_argument('--rate-soll', type=float, default=None,
|
||||||
help='Heat-rate setpoint [K/min] (default: inferred from log)')
|
help='Heat-rate setpoint [K/min] (default: inferred from log)')
|
||||||
parser.add_argument('--ambient', type=float, default=None,
|
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
|
# Plant model params — all default to None so we can tell "not specified"
|
||||||
parser.add_argument('--plant-M', type=float, default=20.0, metavar='kg',
|
# and fall back to the log's embedded PlantParams when available.
|
||||||
help='Plant mass [kg] (default: %(default)s)')
|
parser.add_argument('--plant-M', type=float, default=None, metavar='kg', help='Plant mass [kg]')
|
||||||
parser.add_argument('--plant-C', type=float, default=4190.0, metavar='J/kgK',
|
parser.add_argument('--plant-C', type=float, default=None, metavar='J/kgK', help='Specific heat [J/(kg·K)]')
|
||||||
help='Specific heat capacity [J/(kg·K)] (default: %(default)s — water)')
|
parser.add_argument('--plant-L', type=float, default=None, metavar='W/kgK', help='Heat loss coeff [W/(kg·K)]')
|
||||||
parser.add_argument('--plant-L', type=float, default=0.2, metavar='W/kgK',
|
parser.add_argument('--plant-Td', type=float, default=None, metavar='s', help='Transport delay [s]')
|
||||||
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)')
|
|
||||||
parser.add_argument('--heater-efficiency', type=float, default=1.0, metavar='0-1',
|
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)')
|
help='Fraction of rated heater power delivered to the plant (default: %(default)s)')
|
||||||
|
|
||||||
@@ -201,44 +195,58 @@ def main():
|
|||||||
|
|
||||||
args = parser.parse_args()
|
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():
|
if not log_path.exists():
|
||||||
print('Error: log file not found: {}'.format(log_path), file=sys.stderr)
|
print('Error: log file not found: {}'.format(log_path), file=sys.stderr)
|
||||||
sys.exit(1)
|
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:
|
with open(log_path) as f:
|
||||||
log = json.load(f)
|
log = json.load(f)
|
||||||
|
|
||||||
|
# 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:
|
with open(config_path) as f:
|
||||||
config = json.load(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: 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']
|
samples = log['Samples']
|
||||||
name = log.get('Name', '{}_{}'.format(args.date_time, args.sud_name))
|
name = log.get('Name', log_path.stem)
|
||||||
pid_params = _apply_gain_overrides(config['TempCtrl'], args)
|
pid_params = _apply_gain_overrides(config['TempCtrl'], args)
|
||||||
dt = (samples[1]['t'] - samples[0]['t']) if len(samples) > 1 else 1.0
|
dt = (samples[1]['t'] - samples[0]['t']) if len(samples) > 1 else 1.0
|
||||||
max_power = _infer_max_power(samples)
|
max_power = _infer_max_power(samples)
|
||||||
rate_soll = args.rate_soll if args.rate_soll is not None else _infer_heatrate_soll_set(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)
|
ambient = args.ambient if args.ambient is not None else config.get('ambient_temperature', 20.0)
|
||||||
|
|
||||||
plant_params = {'M': args.plant_M, 'C': args.plant_C, 'L': args.plant_L, 'Td': args.plant_Td}
|
|
||||||
|
|
||||||
any_override = any(
|
any_override = any(
|
||||||
getattr(args, '{}_{}'.format(p, g)) is not None
|
getattr(args, '{}_{}'.format(p, g)) is not None
|
||||||
for p in ('hold', 'heat', 'cool')
|
for p in ('hold', 'heat', 'cool')
|
||||||
for g in ('kp', 'ki', 'kd', 'kt')
|
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('Log: {} ({} samples, dt={:.1f} s)'.format(name, len(samples), dt))
|
||||||
print('Power: max={:.0f} W'.format(max_power))
|
print('Power: max={:.0f} W'.format(max_power))
|
||||||
print('Rate: heatrate_soll_set={:.2f} K/min'.format(rate_soll))
|
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))
|
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()
|
print()
|
||||||
for section in ('Hold', 'Heat', 'Cool'):
|
for section in ('Hold', 'Heat', 'Cool'):
|
||||||
p = pid_params[section]
|
p = pid_params[section]
|
||||||
|
|||||||
Reference in New Issue
Block a user