ics-simlab-config-gen-claude/tools/compile_control_plan.py

591 lines
21 KiB
Python

#!/usr/bin/env python3
"""
Compile control_plan.json into deterministic HIL logic files.
Input: control_plan.json (ControlPlan schema)
Output: Python HIL logic files (one per HIL in the plan)
Usage:
python3 -m tools.compile_control_plan \
--control-plan outputs/control_plan.json \
--out outputs/scenario_run/logic
With validation against config:
python3 -m tools.compile_control_plan \
--control-plan outputs/control_plan.json \
--out outputs/scenario_run/logic \
--config outputs/configuration.json
Validation only (no file generation):
python3 -m tools.compile_control_plan \
--control-plan outputs/control_plan.json \
--validate-only
The generated HIL logic follows the ICS-SimLab contract:
def logic(physical_values):
# Initialize from plan.init
# Optional warmup sleep
# while True: run tasks (threaded if >1)
"""
from __future__ import annotations
import argparse
import json
import random
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple, Union
from models.control_plan import (
Action,
AddAction,
ControlPlan,
ControlPlanHIL,
GaussianProfile,
IfAction,
LoopTask,
PlaybackTask,
RampProfile,
SetAction,
StepProfile,
Task,
)
from tools.safe_eval import (
UnsafeExpressionError,
extract_variable_names,
generate_python_code,
validate_expression,
)
class CompilationError(Exception):
"""Raised when control plan compilation fails."""
pass
class ValidationError(Exception):
"""Raised when control plan validation fails."""
pass
def get_hil_physical_values_keys(config: dict, hil_name: str) -> Tuple[Set[str], Set[str]]:
"""
Extract (input_keys, output_keys) for a specific HIL from config.
Returns tuple of (set of input keys, set of output keys).
"""
input_keys: Set[str] = set()
output_keys: Set[str] = set()
for hil in config.get("hils", []):
if hil.get("name") == hil_name:
for pv in hil.get("physical_values", []):
key = pv.get("name")
io = pv.get("io")
if key:
if io == "input":
input_keys.add(key)
elif io == "output":
output_keys.add(key)
break
return input_keys, output_keys
def validate_control_plan(
plan: ControlPlan,
config: Optional[dict] = None
) -> List[str]:
"""
Validate a control plan for errors.
Checks:
1. All expressions are syntactically valid and safe
2. All variables in expressions exist in init/params or config physical_values
3. All set/add targets are valid output keys (if config provided)
Returns:
List of error messages (empty if valid)
"""
errors: List[str] = []
for hil in plan.hils:
hil_name = hil.name
# Build available namespace: init + params
available_vars: Set[str] = set(hil.init.keys())
if hil.params:
available_vars.update(hil.params.keys())
# Add config physical_values if provided
config_input_keys: Set[str] = set()
config_output_keys: Set[str] = set()
if config:
config_input_keys, config_output_keys = get_hil_physical_values_keys(config, hil_name)
available_vars.update(config_input_keys)
available_vars.update(config_output_keys)
# Validate tasks
for task in hil.tasks:
if isinstance(task, LoopTask):
# Validate all actions in the loop
action_errors = _validate_actions(
task.actions,
available_vars,
config_output_keys if config else None,
hil_name,
task.name
)
errors.extend(action_errors)
elif isinstance(task, PlaybackTask):
# Validate target variable
target = task.target
if target not in available_vars:
errors.append(
f"[{hil_name}/{task.name}] Playback target '{target}' not defined in init/params/physical_values"
)
if config and config_output_keys and target not in config_output_keys:
errors.append(
f"[{hil_name}/{task.name}] Playback target '{target}' is not an output in config"
)
return errors
def _validate_actions(
actions: List[Action],
available_vars: Set[str],
output_keys: Optional[Set[str]],
hil_name: str,
task_name: str
) -> List[str]:
"""Validate a list of actions recursively."""
errors: List[str] = []
prefix = f"[{hil_name}/{task_name}]"
for action in actions:
if isinstance(action, SetAction):
var, expr = action.set
# Validate expression
try:
validate_expression(expr)
except (SyntaxError, UnsafeExpressionError) as e:
errors.append(f"{prefix} Invalid expression in set({var}): {e}")
continue
# Check referenced variables exist
refs = extract_variable_names(expr)
undefined = refs - available_vars
if undefined:
errors.append(f"{prefix} Undefined variables in set({var}): {undefined}")
# Check target is writable
if output_keys is not None and var not in output_keys and var not in available_vars:
errors.append(f"{prefix} set target '{var}' is not defined in init/params/outputs")
elif isinstance(action, AddAction):
var, expr = action.add
# Validate expression
try:
validate_expression(expr)
except (SyntaxError, UnsafeExpressionError) as e:
errors.append(f"{prefix} Invalid expression in add({var}): {e}")
continue
# Check referenced variables exist
refs = extract_variable_names(expr)
undefined = refs - available_vars
if undefined:
errors.append(f"{prefix} Undefined variables in add({var}): {undefined}")
# Check target is writable
if output_keys is not None and var not in output_keys and var not in available_vars:
errors.append(f"{prefix} add target '{var}' is not defined in init/params/outputs")
elif isinstance(action, IfAction):
cond = action.if_
# Validate condition
try:
validate_expression(cond)
except (SyntaxError, UnsafeExpressionError) as e:
errors.append(f"{prefix} Invalid condition: {e}")
continue
# Check referenced variables
refs = extract_variable_names(cond)
undefined = refs - available_vars
if undefined:
errors.append(f"{prefix} Undefined variables in condition: {undefined}")
# Recursively validate then/else actions
errors.extend(_validate_actions(action.then, available_vars, output_keys, hil_name, task_name))
if action.else_:
errors.extend(_validate_actions(action.else_, available_vars, output_keys, hil_name, task_name))
return errors
def _indent(code: str, level: int = 1) -> str:
"""Indent code by the given number of levels (4 spaces each)."""
prefix = " " * level
return "\n".join(prefix + line if line else line for line in code.split("\n"))
def _compile_action(action: Action, indent_level: int, pv_var: str = "pv") -> str:
"""Compile a single action to Python code."""
lines: List[str] = []
indent = " " * indent_level
if isinstance(action, SetAction):
var, expr = action.set
py_expr = generate_python_code(expr, pv_var)
lines.append(f"{indent}{pv_var}['{var}'] = {py_expr}")
elif isinstance(action, AddAction):
var, expr = action.add
py_expr = generate_python_code(expr, pv_var)
lines.append(f"{indent}{pv_var}['{var}'] = {pv_var}.get('{var}', 0) + ({py_expr})")
elif isinstance(action, IfAction):
cond = action.if_
py_cond = generate_python_code(cond, pv_var)
lines.append(f"{indent}if {py_cond}:")
for a in action.then:
lines.append(_compile_action(a, indent_level + 1, pv_var))
if action.else_:
lines.append(f"{indent}else:")
for a in action.else_:
lines.append(_compile_action(a, indent_level + 1, pv_var))
return "\n".join(lines)
def _compile_loop_task(task: LoopTask, pv_var: str = "pv") -> str:
"""Compile a loop task to a function definition."""
lines: List[str] = []
func_name = f"_task_{task.name.replace('-', '_').replace(' ', '_')}"
lines.append(f"def {func_name}({pv_var}):")
lines.append(f' """Loop task: {task.name} (dt={task.dt_s}s)"""')
lines.append(" while True:")
# Compile actions
for action in task.actions:
lines.append(_compile_action(action, 2, pv_var))
lines.append(f" time.sleep({task.dt_s})")
return "\n".join(lines)
def _compile_playback_task(task: PlaybackTask, pv_var: str = "pv") -> str:
"""Compile a playback task to a function definition."""
lines: List[str] = []
func_name = f"_task_{task.name.replace('-', '_').replace(' ', '_')}"
profile = task.profile
lines.append(f"def {func_name}({pv_var}):")
lines.append(f' """Playback task: {task.name} (dt={task.dt_s}s)"""')
# Generate profile data
if isinstance(profile, GaussianProfile):
lines.append(f" # Gaussian profile: height={profile.height}, mean={profile.mean}, std={profile.std}, entries={profile.entries}")
lines.append(f" _profile_height = {profile.height}")
lines.append(f" _profile_mean = {profile.mean}")
lines.append(f" _profile_std = {profile.std}")
lines.append(f" _profile_entries = {profile.entries}")
lines.append(" _profile_idx = 0")
lines.append(" while True:")
lines.append(" _value = _profile_height + random.gauss(_profile_mean, _profile_std)")
lines.append(f" {pv_var}['{task.target}'] = _value")
lines.append(f" time.sleep({task.dt_s})")
if not task.repeat:
lines.append(" _profile_idx += 1")
lines.append(" if _profile_idx >= _profile_entries:")
lines.append(" break")
elif isinstance(profile, RampProfile):
lines.append(f" # Ramp profile: start={profile.start}, end={profile.end}, entries={profile.entries}")
lines.append(f" _profile_start = {profile.start}")
lines.append(f" _profile_end = {profile.end}")
lines.append(f" _profile_entries = {profile.entries}")
lines.append(" _profile_idx = 0")
lines.append(" while True:")
lines.append(" _t = _profile_idx / max(1, _profile_entries - 1)")
lines.append(" _value = _profile_start + (_profile_end - _profile_start) * _t")
lines.append(f" {pv_var}['{task.target}'] = _value")
lines.append(f" time.sleep({task.dt_s})")
lines.append(" _profile_idx += 1")
if task.repeat:
lines.append(" if _profile_idx >= _profile_entries:")
lines.append(" _profile_idx = 0")
else:
lines.append(" if _profile_idx >= _profile_entries:")
lines.append(" break")
elif isinstance(profile, StepProfile):
values_str = repr(profile.values)
lines.append(f" # Step profile: values={values_str}")
lines.append(f" _profile_values = {values_str}")
lines.append(" _profile_idx = 0")
lines.append(" while True:")
lines.append(" _value = _profile_values[_profile_idx % len(_profile_values)]")
lines.append(f" {pv_var}['{task.target}'] = _value")
lines.append(f" time.sleep({task.dt_s})")
lines.append(" _profile_idx += 1")
if not task.repeat:
lines.append(" if _profile_idx >= len(_profile_values):")
lines.append(" break")
return "\n".join(lines)
def compile_hil(hil: ControlPlanHIL, config_physical_values: Optional[Set[str]] = None) -> str:
"""
Compile a single HIL control plan to Python code.
Args:
hil: The HIL control plan to compile
config_physical_values: Optional set of physical_value keys from config.
If provided, ensures ALL keys are initialized (not just plan.init keys).
Keys in plan.init use their init value; others use 0 as default.
Returns:
Python code string for the HIL logic file
"""
lines: List[str] = []
# Header
lines.append('"""')
lines.append(f"HIL logic for {hil.name}: ControlPlan v0.1 compiled.")
lines.append("")
lines.append("Autogenerated by ics-simlab-config-gen (compile_control_plan).")
lines.append("DO NOT EDIT - regenerate from control_plan.json instead.")
lines.append('"""')
lines.append("")
# Imports
lines.append("import random")
lines.append("import time")
if len(hil.tasks) > 1:
lines.append("import threading")
lines.append("")
# Helper: clamp function
lines.append("")
lines.append("def clamp(x, lo, hi):")
lines.append(' """Clamp x to [lo, hi]."""')
lines.append(" return lo if x < lo else hi if x > hi else x")
lines.append("")
# Compile each task to a function
for task in hil.tasks:
lines.append("")
if isinstance(task, LoopTask):
lines.append(_compile_loop_task(task, "pv"))
elif isinstance(task, PlaybackTask):
lines.append(_compile_playback_task(task, "pv"))
lines.append("")
# Main logic function
lines.append("")
lines.append("def logic(physical_values):")
lines.append(' """')
lines.append(f" HIL logic entry point for {hil.name}.")
lines.append("")
lines.append(" ICS-SimLab calls this once and expects it to run forever.")
lines.append(' """')
# === CRITICAL: Initialize physical_values BEFORE any alias or threads ===
# Use setdefault directly on physical_values (not an alias) so that
# tools.validate_logic --check-hil-init can detect initialization via AST.
lines.append("")
lines.append(" # === Initialize physical values (validator-compatible) ===")
# Determine all keys that need initialization
all_keys_to_init: Set[str] = set(hil.init.keys())
if config_physical_values:
all_keys_to_init = all_keys_to_init | config_physical_values
# Emit setdefault for ALL keys, using plan.init value if available, else 0
for key in sorted(all_keys_to_init):
if key in hil.init:
value = hil.init[key]
if isinstance(value, bool):
py_val = "True" if value else "False"
else:
py_val = repr(value)
else:
# Key from config not in plan.init - use 0 as default
py_val = "0"
lines.append(f" physical_values.setdefault('{key}', {py_val})")
lines.append("")
# Now create alias for rest of generated code
lines.append(" pv = physical_values # Alias for generated code")
lines.append("")
# Params as local variables (for documentation; they're used via pv.get)
if hil.params:
lines.append(" # === Parameters (read-only constants) ===")
for key, value in hil.params.items():
if isinstance(value, bool):
py_val = "True" if value else "False"
else:
py_val = repr(value)
lines.append(f" pv['{key}'] = {py_val} # param")
lines.append("")
# Warmup sleep
if hil.warmup_s:
lines.append(f" # === Warmup delay ===")
lines.append(f" time.sleep({hil.warmup_s})")
lines.append("")
# Start tasks
if len(hil.tasks) == 1:
# Single task: just call it directly
task = hil.tasks[0]
func_name = f"_task_{task.name.replace('-', '_').replace(' ', '_')}"
lines.append(f" # === Run single task ===")
lines.append(f" {func_name}(pv)")
else:
# Multiple tasks: use threading
lines.append(" # === Start tasks in threads ===")
lines.append(" threads = []")
for task in hil.tasks:
func_name = f"_task_{task.name.replace('-', '_').replace(' ', '_')}"
lines.append(f" t = threading.Thread(target={func_name}, args=(pv,), daemon=True)")
lines.append(" t.start()")
lines.append(" threads.append(t)")
lines.append("")
lines.append(" # Wait for all threads (they run forever)")
lines.append(" for t in threads:")
lines.append(" t.join()")
lines.append("")
return "\n".join(lines)
def compile_control_plan(
plan: ControlPlan,
config: Optional[dict] = None,
) -> Dict[str, str]:
"""
Compile a control plan to HIL logic files.
Args:
plan: The ControlPlan to compile
config: Optional configuration.json dict. If provided, ensures ALL
physical_values declared in config for each HIL are initialized.
Returns:
Dict mapping HIL name to Python code string
"""
result: Dict[str, str] = {}
for hil in plan.hils:
# Extract config physical_values for this HIL if config provided
config_pv: Optional[Set[str]] = None
if config:
input_keys, output_keys = get_hil_physical_values_keys(config, hil.name)
config_pv = input_keys | output_keys
result[hil.name] = compile_hil(hil, config_physical_values=config_pv)
return result
def main() -> None:
parser = argparse.ArgumentParser(
description="Compile control_plan.json into HIL logic Python files"
)
parser.add_argument(
"--control-plan",
required=True,
help="Path to control_plan.json",
)
parser.add_argument(
"--out",
default=None,
help="Output directory for HIL logic .py files",
)
parser.add_argument(
"--config",
default=None,
help="Path to configuration.json (for validation)",
)
parser.add_argument(
"--validate-only",
action="store_true",
help="Only validate, don't generate files",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing output files",
)
args = parser.parse_args()
plan_path = Path(args.control_plan)
out_dir = Path(args.out) if args.out else None
config_path = Path(args.config) if args.config else None
if not plan_path.exists():
raise SystemExit(f"Control plan not found: {plan_path}")
if config_path and not config_path.exists():
raise SystemExit(f"Config file not found: {config_path}")
if not args.validate_only and not out_dir:
raise SystemExit("--out is required unless using --validate-only")
# Load plan
plan_dict = json.loads(plan_path.read_text(encoding="utf-8"))
plan = ControlPlan.model_validate(plan_dict)
print(f"Loaded control plan: {plan_path}")
print(f" Version: {plan.version}")
print(f" HILs: {[h.name for h in plan.hils]}")
# Load config for validation
config: Optional[dict] = None
if config_path:
config = json.loads(config_path.read_text(encoding="utf-8"))
print(f" Config: {config_path}")
# Validate
errors = validate_control_plan(plan, config)
if errors:
print(f"\nValidation FAILED ({len(errors)} errors):")
for err in errors:
print(f" - {err}")
raise SystemExit(1)
else:
print(" Validation: OK")
if args.validate_only:
print("\nValidation only mode, no files generated.")
return
# Compile (pass config to ensure all physical_values are initialized)
hil_code = compile_control_plan(plan, config=config)
# Write output files
assert out_dir is not None
out_dir.mkdir(parents=True, exist_ok=True)
for hil_name, code in hil_code.items():
# Use hil_name as filename (sanitized)
safe_name = hil_name.replace(" ", "_").replace("-", "_")
out_file = out_dir / f"{safe_name}.py"
if out_file.exists() and not args.overwrite:
raise SystemExit(f"Output file exists: {out_file} (use --overwrite)")
out_file.write_text(code, encoding="utf-8")
print(f"Wrote: {out_file}")
print(f"\nCompiled {len(hil_code)} HIL logic file(s) to {out_dir}")
if __name__ == "__main__":
main()