""" Generate R040_APF routine from DESC_IP data. """ import xml.etree.ElementTree as ET import pandas as pd from ..utils.common import format_xml_to_match_original, natural_sort_key from ..plugin_system import RoutinePlugin, register_plugin def generate_apf_routine(data_loader) -> str: """Generate R040_APF routine from NETWORK sheet data. Args: data_loader: DataLoader instance with access to NETWORK sheet Returns: Formatted XML string for R040_APF routine """ # Get APF data from NETWORK sheet apf_df = data_loader.apf # Sort by Name for deterministic output if 'Name' in apf_df.columns: apf_df = apf_df.sort_values('Name', key=lambda x: [natural_sort_key(name) for name in x]) else: # This shouldn't happen with NETWORK sheet data return format_xml_to_match_original(ET.tostring(ET.Element("Routine", attrib={"Name": "R040_APF", "Type": "RLL"}), encoding='unicode')) # Create routine XML structure (config-driven name) try: from ..config import get_config routine_name = get_config().routines.name_map.get('apf', 'R040_APF') except Exception: routine_name = 'R040_APF' routine = ET.Element("Routine", attrib={"Name": routine_name, "Type": "RLL"}) rll_content = ET.SubElement(routine, "RLLContent") # Rung 0: Comment rung rung0 = ET.SubElement(rll_content, "Rung", attrib={"Number": "0", "Type": "N"}) comment = ET.SubElement(rung0, "Comment") comment.text = """APF (Variable Frequency Drive) Instantiation Routine These VFDs are connected to the DPMs for their Ethernet Communication""" text0 = ET.SubElement(rung0, "Text") text0.text = "NOP();" # Generate AOI_APF calls for each module rung_number = 1 for _, apf_row in apf_df.iterrows(): # APF name comes from Name column in NETWORK sheet if 'Name' not in apf_row or pd.isna(apf_row['Name']): continue apf_name = str(apf_row['Name']).strip() if not apf_name: continue # Extract DPM name from DPM column (from NETWORK sheet) dpm_name = None if 'DPM' in apf_row and pd.notna(apf_row['DPM']) and str(apf_row['DPM']).strip(): dpm_name = str(apf_row['DPM']).strip() else: # This should not happen with proper NETWORK sheet data print(f"Warning: No DPM specified for APF {apf_name}, using MCM") dpm_name = "MCM" print(f" APF {apf_name} -> DPM {dpm_name}") # Create rung for this APF module rung = ET.SubElement(rll_content, "Rung", attrib={"Number": str(rung_number), "Type": "N"}) text = ET.SubElement(rung, "Text") # Generate AOI_APF call with MOVE instruction # Pattern: AOI_APF({APF}.AOI,{APF}.HMI,{APF}.CTRL,{APF},{APF}:I,{APF}:O,MCM01.CTRL,{DPM}.CTRL,{APF}:I.In_0,1,Horn_Beacon)MOVE({APF}.CTRL.STS.Log,{APF}.CTRL.STS.Log); # Config-driven control tags and horn tag lookup from ..config import get_config rc = get_config().routines mcm_ctrl = getattr(rc, 'mcm_ctrl_tag', 'MCM.CTRL') in_def = getattr(rc, 'apf_input_default', 'In_0') # Look up horn beacon for this APF from horn mappings horn_mappings = data_loader.horn_mappings beacon_name = horn_mappings.get(apf_name) if not beacon_name: # Fallback to configured default or NO_Horn horn_tag = getattr(rc, 'no_horn_tag_name', 'NO_Horn') print(f" Warning: No horn mapping found for {apf_name}, using {horn_tag}") else: # Look up the actual IO path for this beacon in DESC_IP desc_ip = data_loader.desc_ip beacon_rows = desc_ip[desc_ip['DESCA'].astype(str).str.contains(beacon_name, case=False, na=False)] if not beacon_rows.empty: beacon_row = beacon_rows.iloc[0] io_path = beacon_row.get('IO_PATH', '') if io_path and str(io_path).strip(): horn_tag = str(io_path).strip() print(f" Horn mapping: {apf_name} -> {beacon_name} -> {horn_tag}") else: # No IO_PATH found, use beacon name as boolean tag horn_tag = beacon_name print(f" Horn mapping: {apf_name} -> {beacon_name} (no IO_PATH, using as boolean)") else: # Beacon not found in DESC_IP, use beacon name as boolean tag horn_tag = beacon_name print(f" Horn mapping: {apf_name} -> {beacon_name} (not found in DESC_IP, using as boolean)") aoi_call = ( f"AOI_APF({apf_name}.AOI,{apf_name}.HMI,{apf_name}.CTRL,{apf_name},{apf_name}:I,{apf_name}:O," f"{mcm_ctrl},{dpm_name}.CTRL,{apf_name}:I.{in_def},1,{horn_tag})" f"MOVE({apf_name}.CTRL.STS.Log,{apf_name}.CTRL.STS.Log);" ) text.text = aoi_call rung_number += 1 # Convert to string and format to match Rockwell L5X format xml_str = ET.tostring(routine, encoding='unicode') return format_xml_to_match_original(xml_str) def _get_apf_data(data_loader) -> pd.DataFrame: """Helper to get APF data from DataLoader.""" return data_loader.apf def append_apf_routine(routines_element: ET.Element, data_loader): """Append APF routine to routines element.""" routine_content = generate_apf_routine(data_loader) routine_element = ET.fromstring(routine_content) # Append to routines element routines_element.append(routine_element) class ApfRoutinePlugin(RoutinePlugin): name = "apf" description = "Generates the APF routine" category = "device" def can_generate(self) -> bool: return not self.context.data_loader.apf.empty def generate(self) -> bool: xml_text = generate_apf_routine(self.context.data_loader) if not xml_text: return False self.context.routines_element.append(ET.fromstring(xml_text)) return True register_plugin(ApfRoutinePlugin)