#!/usr/bin/env python3 """ Debug tool for semantic validation issues. Prints a wiring summary showing: - Orphan sensors and actuators - Missing PLC registers for monitors/controllers - IO mismatches - HMI controller targets Usage: python3 -m tools.debug_semantics --config outputs/configuration_raw.json """ import argparse import json from pathlib import Path from typing import Any, Dict, List, Set from models.ics_simlab_config_v2 import Config def debug_semantics(config_path: Path) -> None: """Print a wiring summary for debugging semantic issues.""" with open(config_path) as f: cfg_dict = json.load(f) config = Config(**cfg_dict) print("=" * 70) print("SEMANTIC WIRING SUMMARY") print("=" * 70) # Build IP -> device mapping plc_by_ip: Dict[str, str] = {} sensor_by_ip: Dict[str, str] = {} actuator_by_ip: Dict[str, str] = {} for plc in config.plcs: if plc.network and plc.network.ip: plc_by_ip[plc.network.ip] = plc.name for sensor in config.sensors: if sensor.network and sensor.network.ip: sensor_by_ip[sensor.network.ip] = sensor.name for actuator in config.actuators: if actuator.network and actuator.network.ip: actuator_by_ip[actuator.network.ip] = actuator.name # Track which sensors/actuators are referenced monitored_sensor_ips: Set[str] = set() controlled_actuator_ips: Set[str] = set() # PLC Wiring Summary print("\n" + "-" * 70) print("PLC WIRING") print("-" * 70) for plc in config.plcs: print(f"\n{plc.name} ({plc.network.ip if plc.network else 'no IP'}):") # Build connection_id -> IP mapping conn_to_ip: Dict[str, str] = {} for conn in plc.outbound_connections: if hasattr(conn, 'ip') and conn.id: conn_to_ip[conn.id] = conn.ip # Monitors print(f" Monitors ({len(plc.monitors)}):") for m in plc.monitors: target_ip = conn_to_ip.get(m.outbound_connection_id, "???") target_device = ( plc_by_ip.get(target_ip) or sensor_by_ip.get(target_ip) or actuator_by_ip.get(target_ip) or f"unknown ({target_ip})" ) if target_ip in sensor_by_ip: monitored_sensor_ips.add(target_ip) print(f" - {m.id} -> {target_device} ({m.value_type})") # Controllers print(f" Controllers ({len(plc.controllers)}):") for c in plc.controllers: target_ip = conn_to_ip.get(c.outbound_connection_id, "???") target_device = ( plc_by_ip.get(target_ip) or actuator_by_ip.get(target_ip) or sensor_by_ip.get(target_ip) or f"unknown ({target_ip})" ) if target_ip in actuator_by_ip: controlled_actuator_ips.add(target_ip) print(f" - {c.id} -> {target_device} ({c.value_type})") # Local registers print(f" Local Registers:") for reg_type in ["coil", "discrete_input", "holding_register", "input_register"]: regs = getattr(plc.registers, reg_type, []) for reg in regs: io_str = f" io={reg.io}" if reg.io else "" print(f" - {reg_type}: {reg.id or reg.physical_value or 'unnamed'} @{reg.address}{io_str}") # HMI Wiring Summary print("\n" + "-" * 70) print("HMI WIRING") print("-" * 70) for hmi in config.hmis: print(f"\n{hmi.name}:") # Build connection_id -> IP mapping conn_to_ip: Dict[str, str] = {} for conn in hmi.outbound_connections: if hasattr(conn, 'ip') and conn.id: conn_to_ip[conn.id] = conn.ip # Monitors print(f" Monitors ({len(hmi.monitors)}):") for m in hmi.monitors: target_ip = conn_to_ip.get(m.outbound_connection_id, "???") target_device = plc_by_ip.get(target_ip, f"unknown ({target_ip})") print(f" - {m.id} -> {target_device} ({m.value_type})") # Controllers print(f" Controllers ({len(hmi.controllers)}):") for c in hmi.controllers: target_ip = conn_to_ip.get(c.outbound_connection_id, "???") target_device = plc_by_ip.get(target_ip, f"unknown ({target_ip})") print(f" - {c.id} -> {target_device} ({c.value_type})") # Orphan Summary print("\n" + "-" * 70) print("ORPHAN DEVICES") print("-" * 70) orphan_sensors = [] for sensor in config.sensors: if sensor.network and sensor.network.ip: if sensor.network.ip not in monitored_sensor_ips: orphan_sensors.append(sensor.name) orphan_actuators = [] for actuator in config.actuators: if actuator.network and actuator.network.ip: if actuator.network.ip not in controlled_actuator_ips: orphan_actuators.append(actuator.name) if orphan_sensors: print(f"\nOrphan Sensors (no PLC monitor):") for name in orphan_sensors: print(f" - {name}") else: print("\nNo orphan sensors") if orphan_actuators: print(f"\nOrphan Actuators (no PLC controller):") for name in orphan_actuators: print(f" - {name}") else: print("\nNo orphan actuators") # IO Mismatch Summary print("\n" + "-" * 70) print("IO MISMATCH CHECK") print("-" * 70) mismatches = [] for plc in config.plcs: # Build register id -> io mapping reg_io: Dict[str, Dict[str, str]] = {} for reg_type in ["coil", "discrete_input", "holding_register", "input_register"]: reg_io[reg_type] = {} for reg in getattr(plc.registers, reg_type, []): if reg.id: reg_io[reg_type][reg.id] = reg.io or "" # Check monitors (should be io=input) for m in plc.monitors: if m.value_type in reg_io: actual_io = reg_io[m.value_type].get(m.id, "") if actual_io and actual_io != "input": mismatches.append( f"{plc.name}: monitor '{m.id}' has io='{actual_io}' (should be 'input')" ) # Check controllers (should be io=output) for c in plc.controllers: if c.value_type in reg_io: actual_io = reg_io[c.value_type].get(c.id, "") if actual_io and actual_io != "output": mismatches.append( f"{plc.name}: controller '{c.id}' has io='{actual_io}' (should be 'output')" ) if mismatches: print("\nIO Mismatches found:") for m in mismatches: print(f" - {m}") else: print("\nNo IO mismatches") # Missing Register Check print("\n" + "-" * 70) print("MISSING REGISTERS CHECK") print("-" * 70) missing = [] for plc in config.plcs: # Build set of existing register ids by type existing: Dict[str, Set[str]] = {} for reg_type in ["coil", "discrete_input", "holding_register", "input_register"]: existing[reg_type] = { reg.id for reg in getattr(plc.registers, reg_type, []) if reg.id } # Check monitors for m in plc.monitors: if m.value_type in existing: if m.id not in existing[m.value_type]: missing.append( f"{plc.name}: monitor '{m.id}' missing local register in {m.value_type}" ) # Check controllers for c in plc.controllers: if c.value_type in existing: if c.id not in existing[c.value_type]: missing.append( f"{plc.name}: controller '{c.id}' missing local register in {c.value_type}" ) if missing: print("\nMissing Registers:") for m in missing: print(f" - {m}") else: print("\nNo missing registers") print("\n" + "=" * 70) def main() -> None: parser = argparse.ArgumentParser( description="Debug tool for semantic validation issues" ) parser.add_argument( "--config", required=True, help="Input configuration.json path" ) args = parser.parse_args() config_path = Path(args.config) if not config_path.exists(): raise SystemExit(f"ERROR: Config file not found: {config_path}") debug_semantics(config_path) if __name__ == "__main__": main()