591 lines
21 KiB
Python
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()
|