import argparse import json from pathlib import Path from typing import Dict, List from models.ir_v1 import IRSpec, TankLevelBlock, BottleLineBlock, HysteresisFillRule, ThresholdOutputRule from templates.tank import render_hil_tank def write_text(path: Path, content: str, overwrite: bool) -> None: path.parent.mkdir(parents=True, exist_ok=True) if path.exists() and not overwrite: raise SystemExit(f"Refusing to overwrite existing file: {path} (use --overwrite)") path.write_text(content, encoding="utf-8") def _collect_output_keys(rules: List[object]) -> List[str]: """Collect all output register keys from rules.""" keys = [] for r in rules: if isinstance(r, HysteresisFillRule): keys.append(r.inlet_out) keys.append(r.outlet_out) elif isinstance(r, ThresholdOutputRule): keys.append(r.output_id) return list(dict.fromkeys(keys)) # Remove duplicates, preserve order def _compute_initial_values(rules: List[object]) -> Dict[str, int]: """ Compute rule-aware initial values for outputs. Problem: If all outputs start at 0 and the system is in mid-range (e.g., tank at 500 which is between low=200 and high=800), the hysteresis logic won't trigger any changes, and the system stays stuck forever. Solution: - HysteresisFillRule: inlet_out=0 (closed), outlet_out=1 (open) This starts draining the tank, which will eventually hit the low threshold and trigger the hysteresis cycle. - ThresholdOutputRule: output_id=true_value (commonly 1) This activates the output initially, ensuring the system starts in an active state. """ init_values: Dict[str, int] = {} for r in rules: if isinstance(r, HysteresisFillRule): # Start with inlet closed, outlet open -> tank drains -> hits low -> cycle starts init_values[r.inlet_out] = 0 init_values[r.outlet_out] = 1 elif isinstance(r, ThresholdOutputRule): # Start with true_value to activate the output init_values[r.output_id] = int(r.true_value) return init_values def render_plc_rules(plc_name: str, rules: List[object]) -> str: output_keys = _collect_output_keys(rules) init_values = _compute_initial_values(rules) lines = [] lines.append('"""\n') lines.append(f"PLC logic for {plc_name}: IR-compiled rules.\n\n") lines.append("Autogenerated by ics-simlab-config-gen (IR compiler).\n") lines.append('"""\n\n') lines.append("import time\n") lines.append("from typing import Any, Callable, Dict\n\n") lines.append(f"_PLC_NAME = '{plc_name}'\n") lines.append("_last_heartbeat: float = 0.0\n") lines.append("_last_write_ok: bool = False\n") lines.append("_prev_outputs: Dict[str, Any] = {} # Track previous output values for external change detection\n\n\n") lines.append("def _get_float(regs: Dict[str, Any], key: str, default: float = 0.0) -> float:\n") lines.append(" try:\n") lines.append(" return float(regs[key]['value'])\n") lines.append(" except Exception:\n") lines.append(" return float(default)\n\n\n") lines.append("def _safe_callback(cb: Callable[[], None], retries: int = 20, delay: float = 0.25) -> bool:\n") lines.append(" \"\"\"\n") lines.append(" Invoke callback with retry logic to handle startup race conditions.\n") lines.append(" Catches ConnectionException and OSError (connection refused).\n") lines.append(" Returns True if successful, False otherwise.\n") lines.append(" \"\"\"\n") lines.append(" for attempt in range(retries):\n") lines.append(" try:\n") lines.append(" cb()\n") lines.append(" return True\n") lines.append(" except OSError as e:\n") lines.append(" if attempt == retries - 1:\n") lines.append(" print(f\"WARNING [{_PLC_NAME}]: Callback failed after {retries} attempts (OSError): {e}\")\n") lines.append(" return False\n") lines.append(" time.sleep(delay)\n") lines.append(" except Exception as e:\n") lines.append(" # Catch pymodbus.exceptions.ConnectionException and others\n") lines.append(" if 'ConnectionException' in type(e).__name__ or 'Connection' in str(type(e)):\n") lines.append(" if attempt == retries - 1:\n") lines.append(" print(f\"WARNING [{_PLC_NAME}]: Callback failed after {retries} attempts (Connection): {e}\")\n") lines.append(" return False\n") lines.append(" time.sleep(delay)\n") lines.append(" else:\n") lines.append(" print(f\"WARNING [{_PLC_NAME}]: Callback failed with unexpected error: {e}\")\n") lines.append(" return False\n") lines.append(" return False\n\n\n") lines.append("def _write(out_regs: Dict[str, Any], cbs: Dict[str, Callable[[], None]], key: str, value: int) -> None:\n") lines.append(" \"\"\"Write output and call callback. Updates _prev_outputs to avoid double-callback.\"\"\"\n") lines.append(" global _last_write_ok, _prev_outputs\n") lines.append(" if key not in out_regs:\n") lines.append(" return\n") lines.append(" cur = out_regs[key].get('value', None)\n") lines.append(" if cur == value:\n") lines.append(" return\n") lines.append(" out_regs[key]['value'] = value\n") lines.append(" _prev_outputs[key] = value # Track that WE wrote this value\n") lines.append(" if key in cbs:\n") lines.append(" _last_write_ok = _safe_callback(cbs[key])\n\n\n") lines.append("def _check_external_changes(out_regs: Dict[str, Any], cbs: Dict[str, Callable[[], None]], keys: list) -> None:\n") lines.append(" \"\"\"Detect if HMI changed an output externally and call callback.\"\"\"\n") lines.append(" global _last_write_ok, _prev_outputs\n") lines.append(" for key in keys:\n") lines.append(" if key not in out_regs:\n") lines.append(" continue\n") lines.append(" cur = out_regs[key].get('value', None)\n") lines.append(" prev = _prev_outputs.get(key, None)\n") lines.append(" if cur != prev:\n") lines.append(" # Value changed externally (e.g., by HMI)\n") lines.append(" _prev_outputs[key] = cur\n") lines.append(" if key in cbs:\n") lines.append(" _last_write_ok = _safe_callback(cbs[key])\n\n\n") lines.append("def _heartbeat() -> None:\n") lines.append(" \"\"\"Log heartbeat every 5 seconds to confirm PLC loop is alive.\"\"\"\n") lines.append(" global _last_heartbeat\n") lines.append(" now = time.time()\n") lines.append(" if now - _last_heartbeat >= 5.0:\n") lines.append(" print(f\"HEARTBEAT [{_PLC_NAME}]: loop alive, last_write_ok={_last_write_ok}\")\n") lines.append(" _last_heartbeat = now\n\n\n") lines.append("def logic(input_registers, output_registers, state_update_callbacks):\n") lines.append(" global _prev_outputs\n") # --- Explicit initialization phase (BEFORE loop) --- lines.append(" # --- Explicit initialization: set outputs with rule-aware defaults ---\n") lines.append(" # (outlet=1 to start draining, so hysteresis cycle can begin)\n") if output_keys: for key in output_keys: init_val = init_values.get(key, 0) lines.append(f" if '{key}' in output_registers:\n") lines.append(f" output_registers['{key}']['value'] = {init_val}\n") lines.append(f" _prev_outputs['{key}'] = {init_val}\n") lines.append(f" if '{key}' in state_update_callbacks:\n") lines.append(f" _safe_callback(state_update_callbacks['{key}'])\n") lines.append("\n") lines.append(" # Wait for other components to start\n") lines.append(" time.sleep(2)\n\n") # Generate list of output keys for external watcher if output_keys: keys_str = repr(output_keys) lines.append(f" _output_keys = {keys_str}\n\n") lines.append(" # Main loop - runs forever\n") lines.append(" while True:\n") lines.append(" _heartbeat()\n") # --- External watcher: detect HMI changes --- if output_keys: lines.append(" # Check for external changes (e.g., HMI)\n") lines.append(" _check_external_changes(output_registers, state_update_callbacks, _output_keys)\n\n") # Inside while True loop - all code needs 8 spaces indent if not rules: lines.append(" time.sleep(0.1)\n") return "".join(lines) for r in rules: if isinstance(r, HysteresisFillRule): # Convert normalized thresholds to absolute values using signal_max abs_low = float(r.low * r.signal_max) abs_high = float(r.high * r.signal_max) if r.enable_input: lines.append(f" en = _get_float(input_registers, '{r.enable_input}', default=0.0)\n") lines.append(" if en <= 0.5:\n") lines.append(f" _write(output_registers, state_update_callbacks, '{r.inlet_out}', 0)\n") lines.append(f" _write(output_registers, state_update_callbacks, '{r.outlet_out}', 0)\n") lines.append(" else:\n") lines.append(f" lvl = _get_float(input_registers, '{r.level_in}', default=0.0)\n") lines.append(f" if lvl <= {abs_low}:\n") lines.append(f" _write(output_registers, state_update_callbacks, '{r.inlet_out}', 1)\n") lines.append(f" _write(output_registers, state_update_callbacks, '{r.outlet_out}', 0)\n") lines.append(f" elif lvl >= {abs_high}:\n") lines.append(f" _write(output_registers, state_update_callbacks, '{r.inlet_out}', 0)\n") lines.append(f" _write(output_registers, state_update_callbacks, '{r.outlet_out}', 1)\n") lines.append("\n") else: lines.append(f" lvl = _get_float(input_registers, '{r.level_in}', default=0.0)\n") lines.append(f" if lvl <= {abs_low}:\n") lines.append(f" _write(output_registers, state_update_callbacks, '{r.inlet_out}', 1)\n") lines.append(f" _write(output_registers, state_update_callbacks, '{r.outlet_out}', 0)\n") lines.append(f" elif lvl >= {abs_high}:\n") lines.append(f" _write(output_registers, state_update_callbacks, '{r.inlet_out}', 0)\n") lines.append(f" _write(output_registers, state_update_callbacks, '{r.outlet_out}', 1)\n") lines.append("\n") elif isinstance(r, ThresholdOutputRule): # Convert normalized threshold to absolute value using signal_max abs_threshold = float(r.threshold * r.signal_max) lines.append(f" v = _get_float(input_registers, '{r.input_id}', default=0.0)\n") lines.append(f" if v < {abs_threshold}:\n") lines.append(f" _write(output_registers, state_update_callbacks, '{r.output_id}', {int(r.true_value)})\n") lines.append(" else:\n") lines.append(f" _write(output_registers, state_update_callbacks, '{r.output_id}', {int(r.false_value)})\n") lines.append("\n") # End of while loop - sleep before next iteration lines.append(" time.sleep(0.1)\n") return "".join(lines) def render_hil_multi(hil_name: str, outputs_init: Dict[str, float], blocks: List[object]) -> str: """ Compose multiple blocks inside ONE HIL logic() function. ICS-SimLab calls logic() once and expects it to run forever. Physics model (inspired by official bottle_factory example): - Tank level: integer range 0-1000, inflow/outflow as discrete steps - Bottle distance: internal state 0-130, decreases when conveyor runs - Bottle at filler: True when distance in [0, 30] - Bottle fill: only when tank_output_valve is ON and bottle is at filler - Bottle reset: when bottle exits (distance < 0), reset distance=130 and fill=0 - Conservation: filling bottle drains tank """ # Check if we have both tank and bottle blocks for coupled physics tank_block = None bottle_block = None for b in blocks: if isinstance(b, TankLevelBlock): tank_block = b elif isinstance(b, BottleLineBlock): bottle_block = b lines = [] lines.append('"""\n') lines.append(f"HIL logic for {hil_name}: IR-compiled blocks.\n\n") lines.append("Autogenerated by ics-simlab-config-gen (IR compiler).\n") lines.append("Physics: coupled tank + bottle model with conservation.\n") lines.append('"""\n\n') lines.append("import time\n\n") # Generate coupled physics if we have both tank and bottle if tank_block and bottle_block: # Use example-style physics with coupling lines.append("def logic(physical_values):\n") lines.append(" # Initialize outputs with integer-range values (like official example)\n") lines.append(f" physical_values['{tank_block.level_out}'] = 500 # Tank starts half full (0-1000 range)\n") lines.append(f" physical_values['{bottle_block.bottle_fill_level_out}'] = 0 # Bottle starts empty (0-200 range)\n") lines.append(f" physical_values['{bottle_block.bottle_at_filler_out}'] = 1 # Bottle starts at filler\n") lines.append("\n") lines.append(" # Internal state: bottle distance to filler (0-130 range)\n") lines.append(" # When distance in [0, 30], bottle is under the filler\n") lines.append(" _bottle_distance = 0\n") lines.append("\n") lines.append(" # Wait for other components to start\n") lines.append(" time.sleep(3)\n\n") lines.append(" # Main physics loop - runs forever\n") lines.append(" while True:\n") lines.append(" # --- Read actuator states (as booleans) ---\n") lines.append(f" inlet_valve_on = bool(physical_values.get('{tank_block.inlet_cmd}', 0))\n") lines.append(f" outlet_valve_on = bool(physical_values.get('{tank_block.outlet_cmd}', 0))\n") lines.append(f" conveyor_on = bool(physical_values.get('{bottle_block.conveyor_cmd}', 0))\n") lines.append("\n") lines.append(" # --- Read current state ---\n") lines.append(f" tank_level = physical_values.get('{tank_block.level_out}', 500)\n") lines.append(f" bottle_fill = physical_values.get('{bottle_block.bottle_fill_level_out}', 0)\n") lines.append("\n") lines.append(" # --- Determine if bottle is at filler ---\n") lines.append(" bottle_at_filler = (0 <= _bottle_distance <= 30)\n") lines.append("\n") lines.append(" # --- Tank dynamics ---\n") lines.append(" # Inflow: add water when inlet valve is open\n") lines.append(" if inlet_valve_on:\n") lines.append(" tank_level += 18 # Discrete step (like example)\n") lines.append("\n") lines.append(" # Outflow: drain tank when outlet valve is open\n") lines.append(" # Conservation: if bottle is at filler AND not full, water goes to bottle\n") lines.append(" if outlet_valve_on:\n") lines.append(" tank_level -= 6 # Drain from tank\n") lines.append(" if bottle_at_filler and bottle_fill < 200:\n") lines.append(" bottle_fill += 6 # Fill bottle (conservation)\n") lines.append("\n") lines.append(" # Clamp tank level to valid range\n") lines.append(" tank_level = max(0, min(1000, tank_level))\n") lines.append(" bottle_fill = max(0, min(200, bottle_fill))\n") lines.append("\n") lines.append(" # --- Conveyor dynamics ---\n") lines.append(" if conveyor_on:\n") lines.append(" _bottle_distance -= 4 # Move bottle\n") lines.append(" if _bottle_distance < 0:\n") lines.append(" # Bottle exits, new empty bottle enters\n") lines.append(" _bottle_distance = 130\n") lines.append(" bottle_fill = 0\n") lines.append("\n") lines.append(" # --- Update outputs ---\n") lines.append(f" physical_values['{tank_block.level_out}'] = tank_level\n") lines.append(f" physical_values['{bottle_block.bottle_fill_level_out}'] = bottle_fill\n") lines.append(f" physical_values['{bottle_block.bottle_at_filler_out}'] = 1 if bottle_at_filler else 0\n") lines.append("\n") lines.append(" time.sleep(0.6) # Match example timing\n") else: # Fallback: generate simple independent physics for each block lines.append("def _clamp(x, lo, hi):\n") lines.append(" return lo if x < lo else hi if x > hi else x\n\n\n") lines.append("def logic(physical_values):\n") lines.append(" # Initialize all output physical values\n") for k, v in outputs_init.items(): lines.append(f" physical_values['{k}'] = {float(v)}\n") lines.append("\n") lines.append(" # Wait for other components to start\n") lines.append(" time.sleep(3)\n\n") lines.append(" # Main physics loop - runs forever\n") lines.append(" while True:\n") for b in blocks: if isinstance(b, BottleLineBlock): lines.append(f" cmd = float(physical_values.get('{b.conveyor_cmd}', 0.0) or 0.0)\n") lines.append(f" at = 1.0 if cmd <= 0.5 else 0.0\n") lines.append(f" physical_values['{b.bottle_at_filler_out}'] = at\n") lines.append(f" lvl = float(physical_values.get('{b.bottle_fill_level_out}', {float(b.initial_fill)}) or 0.0)\n") lines.append(f" if at >= 0.5:\n") lines.append(f" lvl = lvl + {float(b.fill_rate)} * {float(b.dt)}\n") lines.append(" else:\n") lines.append(f" lvl = lvl - {float(b.drain_rate)} * {float(b.dt)}\n") lines.append(" lvl = _clamp(lvl, 0.0, 1.0)\n") lines.append(f" physical_values['{b.bottle_fill_level_out}'] = lvl\n\n") elif isinstance(b, TankLevelBlock): lines.append(f" inlet = float(physical_values.get('{b.inlet_cmd}', 0.0) or 0.0)\n") lines.append(f" outlet = float(physical_values.get('{b.outlet_cmd}', 0.0) or 0.0)\n") lines.append(f" lvl = float(physical_values.get('{b.level_out}', {float(b.initial_level or 0.5)}) or 0.0)\n") lines.append(f" inflow = ({float(b.inflow_rate)} if inlet >= 0.5 else 0.0)\n") lines.append(f" outflow = ({float(b.outflow_rate)} if outlet >= 0.5 else 0.0)\n") lines.append(f" lvl = lvl + ({float(b.dt)}/{float(b.area)}) * (inflow - outflow - {float(b.leak_rate)})\n") lines.append(f" lvl = _clamp(lvl, 0.0, {float(b.max_level)})\n") lines.append(f" physical_values['{b.level_out}'] = lvl\n\n") lines.append(" time.sleep(0.1)\n") return "".join(lines) def main() -> None: ap = argparse.ArgumentParser(description="Compile IR v1 into logic/*.py (deterministic)") ap.add_argument("--ir", required=True, help="Path to IR json") ap.add_argument("--out-dir", required=True, help="Directory for generated .py files") ap.add_argument("--overwrite", action="store_true", help="Overwrite existing files") args = ap.parse_args() ir_obj = json.loads(Path(args.ir).read_text(encoding="utf-8")) ir = IRSpec.model_validate(ir_obj) out_dir = Path(args.out_dir) seen: Dict[str, str] = {} for plc in ir.plcs: if plc.logic in seen: raise SystemExit(f"Duplicate logic filename '{plc.logic}' used by {seen[plc.logic]} and plc:{plc.name}") seen[plc.logic] = f"plc:{plc.name}" for hil in ir.hils: if hil.logic in seen: raise SystemExit(f"Duplicate logic filename '{hil.logic}' used by {seen[hil.logic]} and hil:{hil.name}") seen[hil.logic] = f"hil:{hil.name}" for plc in ir.plcs: if not plc.logic: continue content = render_plc_rules(plc.name, plc.rules) write_text(out_dir / plc.logic, content, overwrite=bool(args.overwrite)) print(f"Wrote PLC logic: {out_dir / plc.logic}") for hil in ir.hils: if not hil.logic: continue content = render_hil_multi(hil.name, hil.outputs_init, hil.blocks) write_text(out_dir / hil.logic, content, overwrite=bool(args.overwrite)) print(f"Wrote HIL logic: {out_dir / hil.logic}") if __name__ == "__main__": main()