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