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

513 lines
19 KiB
Python

#!/usr/bin/env python3
"""
Enrich configuration.json with PLC monitors and outbound connections to sensors.
This tool analyzes the configuration and:
1. For each PLC input register, finds the corresponding sensor
2. Adds outbound_connections from PLC to sensor IP
3. Adds monitors to poll sensor values
4. For each HMI monitor, derives value_type/address/count from target PLC registers
Usage:
python3 -m tools.enrich_config --config outputs/configuration.json --out outputs/configuration_enriched.json
"""
import argparse
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
def find_register_mapping(device: Dict, register_id: str) -> Optional[Tuple[str, int, int]]:
"""
Search device registers for a matching id and return (value_type, address, count).
Args:
device: Device dict with "registers" section (PLC, sensor, actuator)
register_id: The register id to find
Returns:
(value_type, address, count) if found, None otherwise
"""
registers = device.get("registers", {})
for reg_type in ["coil", "discrete_input", "holding_register", "input_register"]:
for reg in registers.get(reg_type, []):
# Match by id or physical_value
if reg.get("id") == register_id or reg.get("physical_value") == register_id:
return reg_type, reg.get("address", 1), reg.get("count", 1)
return None
def find_sensor_for_pv(sensors: List[Dict], actuators: List[Dict], pv_name: str) -> Optional[Dict]:
"""
Find the sensor that exposes a physical_value matching pv_name.
Returns sensor dict or None.
"""
# Check sensors
for sensor in sensors:
for reg_type in ["holding_register", "input_register", "discrete_input", "coil"]:
for reg in sensor.get("registers", {}).get(reg_type, []):
if reg.get("physical_value") == pv_name:
return sensor
return None
def find_actuator_for_pv(actuators: List[Dict], pv_name: str) -> Optional[Dict]:
"""
Find the actuator that has a physical_value matching pv_name.
"""
for actuator in actuators:
for pv in actuator.get("physical_values", []):
if pv.get("name") == pv_name:
return actuator
return None
def get_sensor_register_info(sensor: Dict, pv_name: str) -> Tuple[Optional[str], int, int]:
"""
Get register type and address for a physical_value in a sensor.
Returns (value_type, address, count) or (None, 0, 0) if not found.
"""
for reg_type in ["holding_register", "input_register", "discrete_input", "coil"]:
for reg in sensor.get("registers", {}).get(reg_type, []):
if reg.get("physical_value") == pv_name:
return reg_type, reg.get("address", 1), reg.get("count", 1)
return None, 0, 0
def get_plc_input_registers(plc: Dict) -> List[Tuple[str, str]]:
"""
Get list of (register_id, register_type) for all io:"input" registers in PLC.
"""
inputs = []
registers = plc.get("registers", {})
for reg_type in ["holding_register", "input_register", "discrete_input", "coil"]:
for reg in registers.get(reg_type, []):
if reg.get("io") == "input":
reg_id = reg.get("id")
if reg_id:
inputs.append((reg_id, reg_type))
return inputs
def get_plc_output_registers(plc: Dict) -> List[Tuple[str, str]]:
"""
Get list of (register_id, register_type) for all io:"output" registers in PLC.
"""
outputs = []
registers = plc.get("registers", {})
for reg_type in ["holding_register", "input_register", "discrete_input", "coil"]:
for reg in registers.get(reg_type, []):
if reg.get("io") == "output":
reg_id = reg.get("id")
if reg_id:
outputs.append((reg_id, reg_type))
return outputs
def map_plc_input_to_hil_output(plc_input_id: str, hils: List[Dict]) -> Optional[str]:
"""
Map a PLC input register name to a HIL output physical_value name.
Convention: PLC reads "water_tank_level" -> HIL outputs "water_tank_level_output"
"""
# Direct mapping patterns
patterns = [
(plc_input_id, f"{plc_input_id}_output"), # water_tank_level -> water_tank_level_output
(plc_input_id, plc_input_id), # exact match
]
for hil in hils:
for pv in hil.get("physical_values", []):
pv_name = pv.get("name", "")
pv_io = pv.get("io", "")
if pv_io == "output":
for _, mapped_name in patterns:
if pv_name == mapped_name:
return pv_name
# Also check if PLC input name is contained in HIL output name
if plc_input_id in pv_name and "output" in pv_name:
return pv_name
return None
def find_plc_input_matching_output(plcs: List[Dict], output_id: str, source_plc_name: str) -> Optional[Tuple[Dict, str, int]]:
"""
Find a PLC that has an input register matching the given output_id.
Returns (target_plc, register_type, address) or None.
"""
for plc in plcs:
if plc.get("name") == source_plc_name:
continue # Skip self
registers = plc.get("registers", {})
for reg_type in ["coil", "discrete_input", "holding_register", "input_register"]:
for reg in registers.get(reg_type, []):
if reg.get("io") == "input" and reg.get("id") == output_id:
return plc, reg_type, reg.get("address", 1)
return None
def enrich_plc_connections(config: Dict) -> Dict:
"""
Enrich configuration with PLC outbound_connections and monitors for sensor inputs.
For each PLC input register:
1. Find the HIL output it corresponds to
2. Find the sensor that exposes that HIL output
3. Add outbound_connection to that sensor
4. Add monitor entry to poll the sensor
"""
plcs = config.get("plcs", [])
hils = config.get("hils", [])
sensors = config.get("sensors", [])
actuators = config.get("actuators", [])
for plc in plcs:
plc_name = plc.get("name", "plc")
existing_outbound = plc.get("outbound_connections", [])
existing_monitors = plc.get("monitors", [])
# Track which connections/monitors we've added
existing_conn_ids = {c.get("id") for c in existing_outbound}
existing_monitor_ids = {m.get("id") for m in existing_monitors}
# Get PLC inputs and outputs
plc_inputs = get_plc_input_registers(plc)
plc_outputs = get_plc_output_registers(plc)
# Process each PLC input - find sensor to read from
for input_id, input_reg_type in plc_inputs:
# Skip if monitor already exists
if input_id in existing_monitor_ids:
continue
# Map PLC input to HIL output
hil_output = map_plc_input_to_hil_output(input_id, hils)
if not hil_output:
continue
# Find sensor that exposes this HIL output
sensor = find_sensor_for_pv(sensors, actuators, hil_output)
if not sensor:
continue
sensor_name = sensor.get("name", "sensor")
sensor_ip = sensor.get("network", {}).get("ip")
if not sensor_ip:
continue
# Get sensor register info
value_type, address, count = get_sensor_register_info(sensor, hil_output)
if not value_type:
continue
# Create connection ID
conn_id = f"to_{sensor_name}"
# Add outbound connection if not exists
if conn_id not in existing_conn_ids:
new_conn = {
"type": "tcp",
"ip": sensor_ip,
"port": 502,
"id": conn_id
}
existing_outbound.append(new_conn)
existing_conn_ids.add(conn_id)
# Add monitor
new_monitor = {
"outbound_connection_id": conn_id,
"id": input_id,
"value_type": value_type,
"address": address,
"count": count,
"interval": 0.2,
"slave_id": 1
}
existing_monitors.append(new_monitor)
existing_monitor_ids.add(input_id)
# Process each PLC output - find actuator to write to
for output_id, output_reg_type in plc_outputs:
# Map output to actuator physical_value name
# Convention: PLC output "tank_input_valve" -> actuator pv "tank_input_valve_input"
actuator_pv_name = f"{output_id}_input"
actuator = find_actuator_for_pv(actuators, actuator_pv_name)
if not actuator:
continue
actuator_name = actuator.get("name", "actuator")
actuator_ip = actuator.get("network", {}).get("ip")
if not actuator_ip:
continue
# Create connection ID
conn_id = f"to_{actuator_name}"
# Add outbound connection if not exists
if conn_id not in existing_conn_ids:
new_conn = {
"type": "tcp",
"ip": actuator_ip,
"port": 502,
"id": conn_id
}
existing_outbound.append(new_conn)
existing_conn_ids.add(conn_id)
# Check if controller already exists for this output
existing_controllers = plc.get("controllers", [])
existing_controller_ids = {c.get("id") for c in existing_controllers}
if output_id not in existing_controller_ids:
# Get actuator register info
actuator_regs = actuator.get("registers", {})
for reg_type in ["coil", "holding_register"]:
for reg in actuator_regs.get(reg_type, []):
if reg.get("physical_value") == actuator_pv_name:
new_controller = {
"outbound_connection_id": conn_id,
"id": output_id,
"value_type": reg_type,
"address": reg.get("address", 1),
"count": reg.get("count", 1),
"interval": 0.5,
"slave_id": 1
}
existing_controllers.append(new_controller)
existing_controller_ids.add(output_id)
break
plc["controllers"] = existing_controllers
# Process PLC outputs that should go to other PLCs (PLC-to-PLC communication)
for output_id, output_reg_type in plc_outputs:
# Check if this output should be sent to another PLC
result = find_plc_input_matching_output(plcs, output_id, plc_name)
if not result:
continue
target_plc, target_reg_type, target_address = result
target_plc_name = target_plc.get("name", "plc")
target_plc_ip = target_plc.get("network", {}).get("ip")
if not target_plc_ip:
continue
# Create connection ID
conn_id = f"to_{target_plc_name}"
# Add outbound connection if not exists
if conn_id not in existing_conn_ids:
new_conn = {
"type": "tcp",
"ip": target_plc_ip,
"port": 502,
"id": conn_id
}
existing_outbound.append(new_conn)
existing_conn_ids.add(conn_id)
# Check if controller already exists
existing_controllers = plc.get("controllers", [])
existing_controller_ids = {c.get("id") for c in existing_controllers}
if output_id not in existing_controller_ids:
new_controller = {
"outbound_connection_id": conn_id,
"id": output_id,
"value_type": target_reg_type,
"address": target_address,
"count": 1,
"interval": 0.2,
"slave_id": 1
}
existing_controllers.append(new_controller)
existing_controller_ids.add(output_id)
plc["controllers"] = existing_controllers
# Update PLC
plc["outbound_connections"] = existing_outbound
plc["monitors"] = existing_monitors
return config
def enrich_hmi_connections(config: Dict) -> Dict:
"""
Fix HMI monitors/controllers by deriving value_type/address/count from target PLC registers.
For each HMI monitor that polls a PLC:
1. Find the target PLC from outbound_connection
2. Look up the register by id in the PLC's registers
3. Fix value_type, address, count to match the PLC's actual register
"""
hmis = config.get("hmis", [])
plcs = config.get("plcs", [])
# Build PLC lookup by IP
plc_by_ip: Dict[str, Dict] = {}
for plc in plcs:
plc_ip = plc.get("network", {}).get("ip")
if plc_ip:
plc_by_ip[plc_ip] = plc
for hmi in hmis:
hmi_name = hmi.get("name", "hmi")
outbound_conns = hmi.get("outbound_connections", [])
# Build connection id -> target IP mapping
conn_to_ip: Dict[str, str] = {}
for conn in outbound_conns:
conn_id = conn.get("id")
conn_ip = conn.get("ip")
if conn_id and conn_ip:
conn_to_ip[conn_id] = conn_ip
# Fix monitors
monitors = hmi.get("monitors", [])
for monitor in monitors:
monitor_id = monitor.get("id")
conn_id = monitor.get("outbound_connection_id")
if not monitor_id or not conn_id:
continue
# Find target PLC
target_ip = conn_to_ip.get(conn_id)
if not target_ip:
print(f" WARNING: {hmi_name} monitor '{monitor_id}': outbound_connection '{conn_id}' not found")
continue
target_plc = plc_by_ip.get(target_ip)
if not target_plc:
# Target might be a sensor, not a PLC - skip silently
continue
target_plc_name = target_plc.get("name", "plc")
# Look up register in target PLC
mapping = find_register_mapping(target_plc, monitor_id)
if mapping:
value_type, address, count = mapping
old_type = monitor.get("value_type")
old_addr = monitor.get("address")
if old_type != value_type or old_addr != address:
print(f" FIX: {hmi_name} monitor '{monitor_id}': {old_type}@{old_addr} -> {value_type}@{address} (from {target_plc_name})")
monitor["value_type"] = value_type
monitor["address"] = address
monitor["count"] = count
else:
print(f" WARNING: {hmi_name} monitor '{monitor_id}': register not found in {target_plc_name}, keeping current config")
# Fix controllers
controllers = hmi.get("controllers", [])
for controller in controllers:
ctrl_id = controller.get("id")
conn_id = controller.get("outbound_connection_id")
if not ctrl_id or not conn_id:
continue
# Find target PLC
target_ip = conn_to_ip.get(conn_id)
if not target_ip:
print(f" WARNING: {hmi_name} controller '{ctrl_id}': outbound_connection '{conn_id}' not found")
continue
target_plc = plc_by_ip.get(target_ip)
if not target_plc:
continue
target_plc_name = target_plc.get("name", "plc")
# Look up register in target PLC
mapping = find_register_mapping(target_plc, ctrl_id)
if mapping:
value_type, address, count = mapping
old_type = controller.get("value_type")
old_addr = controller.get("address")
if old_type != value_type or old_addr != address:
print(f" FIX: {hmi_name} controller '{ctrl_id}': {old_type}@{old_addr} -> {value_type}@{address} (from {target_plc_name})")
controller["value_type"] = value_type
controller["address"] = address
controller["count"] = count
else:
print(f" WARNING: {hmi_name} controller '{ctrl_id}': register not found in {target_plc_name}, keeping current config")
return config
def main() -> None:
parser = argparse.ArgumentParser(
description="Enrich configuration.json with PLC monitors and sensor connections"
)
parser.add_argument(
"--config",
required=True,
help="Input configuration.json path"
)
parser.add_argument(
"--out",
required=True,
help="Output enriched configuration.json path"
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite output file if exists"
)
args = parser.parse_args()
config_path = Path(args.config)
out_path = Path(args.out)
if not config_path.exists():
raise SystemExit(f"ERROR: Config file not found: {config_path}")
if out_path.exists() and not args.overwrite:
raise SystemExit(f"ERROR: Output file exists: {out_path} (use --overwrite)")
# Load config
config = json.loads(config_path.read_text(encoding="utf-8"))
# Enrich PLCs (monitors to sensors, controllers to actuators)
print("Enriching PLC connections...")
enriched = enrich_plc_connections(config)
# Fix HMI monitors/controllers (derive from PLC register maps)
print("Fixing HMI monitors/controllers...")
enriched = enrich_hmi_connections(enriched)
# Write
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(enriched, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"\nEnriched configuration written to: {out_path}")
# Summary
print("\nSummary:")
for plc in enriched.get("plcs", []):
plc_name = plc.get("name", "plc")
n_conn = len(plc.get("outbound_connections", []))
n_mon = len(plc.get("monitors", []))
n_ctrl = len(plc.get("controllers", []))
print(f" {plc_name}: {n_conn} outbound_connections, {n_mon} monitors, {n_ctrl} controllers")
for hmi in enriched.get("hmis", []):
hmi_name = hmi.get("name", "hmi")
n_mon = len(hmi.get("monitors", []))
n_ctrl = len(hmi.get("controllers", []))
print(f" {hmi_name}: {n_mon} monitors, {n_ctrl} controllers")
if __name__ == "__main__":
main()