2025-08-05 14:38:54 +04:00

359 lines
14 KiB
Python

#!/usr/bin/env python3
"""
APF Module Boilerplate Model
============================
Model for APF (Armor PowerFlex) modules with support for different horsepower ratings.
Supports 1, 2, 3, 5, 7.5, and 10 HP variants.
"""
from typing import Dict, Optional, TYPE_CHECKING
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import datetime
import os
if TYPE_CHECKING:
from excel_data_processor import ModuleData
@dataclass
class APFModuleConfig:
"""Configuration for an APF module instance."""
name: str # Module name (e.g., "APF1")
hp: str # Horsepower rating: "1", "2", "3", "5", "7_5", or "10"
ip_address: str = "192.168.1.10"
parent_module: str = "SLOT2_EN4TR"
parent_port_id: str = "2"
inhibited: bool = False
major_fault: bool = False
safety_network: str = "16#0000_4c14_03e7_33a8"
safety_enabled: bool = True
input_device_names: Optional[Dict[int, str]] = None
output_device_names: Optional[Dict[int, str]] = None
safety_input_names: Optional[Dict[int, str]] = None
safety_output_names: Optional[Dict[int, str]] = None
class APFModuleGenerator:
"""Generator for APF module XML with different HP support."""
# Mapping of HP values to boilerplate filenames
HP_BOILERPLATE_MAP = {
"1": "APF_Module_1_HP.L5X",
"2": "APF_Module_2_HP.L5X",
"3": "APF_Module_3_HP.L5X",
"5": "APF_Module_5_HP.L5X",
"7_5": "APF_Module_7_5_HP.L5X",
"7.5": "APF_Module_7_5_HP.L5X", # Allow both formats
"10": "APF_Module_10_HP.L5X"
}
def __init__(self, config: APFModuleConfig):
self.config = config
# Normalize HP value
if self.config.hp == "7.5":
self.config.hp = "7_5"
# Determine the correct boilerplate file
if self.config.hp not in self.HP_BOILERPLATE_MAP:
raise ValueError(f"Unsupported HP value: {self.config.hp}. Supported values: 1, 2, 3, 5, 7.5 (or 7_5), 10")
self.boilerplate_filename = self.HP_BOILERPLATE_MAP[self.config.hp]
self.boilerplate_path = os.path.join("boilerplate", self.boilerplate_filename)
self.tree = None
self.root = None
def load_boilerplate(self):
"""Load the appropriate boilerplate template based on HP rating."""
if not os.path.exists(self.boilerplate_path):
raise FileNotFoundError(f"Boilerplate file not found: {self.boilerplate_path}")
self.tree = ET.parse(self.boilerplate_path)
self.root = self.tree.getroot()
def update_module_name(self):
"""Update the module name throughout the XML."""
# Update in root attributes
self.root.set("TargetName", self.config.name)
# Update Module element
module = self.root.find(".//Module[@Use='Target']")
if module is not None:
module.set("Name", self.config.name)
def update_ip_address(self):
"""Update the IP address in the Ethernet port."""
port = self.root.find(".//Port[@Type='Ethernet']")
if port is not None:
port.set("Address", self.config.ip_address)
def update_parent_module(self):
"""Update parent module references."""
module = self.root.find(".//Module[@Use='Target']")
if module is not None:
module.set("ParentModule", self.config.parent_module)
module.set("ParentModPortId", self.config.parent_port_id)
def update_comments(self):
"""Update comments for inputs and outputs."""
# Update standard connection input comments
if self.config.input_device_names:
input_comments = self.root.find(".//Connection[@Name='A_Standard_Rev2']/InputTag/Comments")
if input_comments is not None:
# Clear existing comments
input_comments.clear()
# Add new comments
for index, name in self.config.input_device_names.items():
comment = ET.SubElement(input_comments, "Comment")
if index < 4: # IN_0 through IN_3
comment.set("Operand", f".IN_{index}")
else: # IO_0 and IO_1 (index 4 and 5)
comment.set("Operand", f".IO_{index-4}")
comment.text = name
# Update standard connection output comments
if self.config.output_device_names:
output_comments = self.root.find(".//Connection[@Name='A_Standard_Rev2']/OutputTag/Comments")
if output_comments is not None:
# Clear existing comments
output_comments.clear()
# Add new comments
for index, name in self.config.output_device_names.items():
comment = ET.SubElement(output_comments, "Comment")
comment.set("Operand", f".IO_{index}")
comment.text = name
# Update safety input comments
if self.config.safety_input_names:
safety_input_comments = self.root.find(".//Connection[@Name='D_Safety_Input']/InputTag/Comments")
if safety_input_comments is not None:
# Clear existing comments
safety_input_comments.clear()
# Add new comments
for index, name in self.config.safety_input_names.items():
comment = ET.SubElement(safety_input_comments, "Comment")
comment.set("Operand", f".IN0{index}DATA")
comment.text = name
# Update safety output comments
if self.config.safety_output_names:
safety_output_comments = self.root.find(".//Connection[@Name='C_Safety_Output']/OutputTag/Comments")
if safety_output_comments is not None:
# Clear existing comments
safety_output_comments.clear()
# Add new comments
for index, name in self.config.safety_output_names.items():
comment = ET.SubElement(safety_output_comments, "Comment")
comment.set("Operand", f".OUT0{index}OUTPUT")
comment.text = name
def update_export_date(self):
"""Update the export date to current time."""
export_date = datetime.now().strftime("%a %b %d %H:%M:%S %Y")
self.root.set("ExportDate", export_date)
def apply_updates(self):
"""Apply all updates to the boilerplate."""
self.update_module_name()
self.update_ip_address()
self.update_parent_module()
self.update_comments()
self.update_export_date()
def save(self, output_path: str):
"""Save the updated module to file."""
if self.tree is None:
raise RuntimeError("No boilerplate loaded. Call load_boilerplate() first.")
# Save with proper formatting
self.tree.write(output_path, encoding='UTF-8', xml_declaration=True)
def get_xml_string(self) -> str:
"""Get the XML as a string."""
if self.tree is None:
raise RuntimeError("No boilerplate loaded. Call load_boilerplate() first.")
return ET.tostring(self.root, encoding='unicode')
# ------------------------------------------------------------------
# Convenience helpers for EnhancedMCMGenerator refactor
# ------------------------------------------------------------------
@staticmethod
def _extract_comment_dictionaries(module_data: 'ModuleData') -> (
Dict[int, str], Dict[int, str], Dict[int, str], Dict[int, str]
):
"""Translate the raw Excel `ModuleData` into the comment dictionaries
expected by APFModuleGenerator.update_comments().
The logic is a verbatim copy of what previously lived in
EnhancedMCMGenerator._add_apf_modules so that behaviour stays
identical after the refactor.
"""
input_device_names: Dict[int, str] = {}
output_device_names: Dict[int, str] = {}
safety_input_names: Dict[int, str] = {}
safety_output_names: Dict[int, str] = {}
for io_mapping in module_data.io_mappings:
if not io_mapping.description:
continue
# Handle SPARE entries explicitly
comment = (
"SPARE" if io_mapping.description.upper() == "SPARE" else io_mapping.description
)
io_path = io_mapping.io_path
if not io_path or ":" not in io_path:
# Skip malformed IO_PATH strings (keeps old behaviour)
continue
path_parts = io_path.split(":", 1)[1]
if "." not in path_parts:
continue
channel, terminal = path_parts.split(".", 1)
channel_upper = channel.upper()
if channel_upper == "I" and terminal.startswith("In_"):
# Standard input: I.In_0, I.In_1, …
try:
index = int(terminal.split("_")[1])
input_device_names[index] = comment
except (ValueError, IndexError):
continue
elif terminal.startswith("IO_"):
# IO channel: I.IO_0, O.IO_1, …
try:
index = int(terminal.split("_")[1])
except (ValueError, IndexError):
continue
if channel_upper == "O":
output_device_names[index] = comment
elif channel_upper == "I":
# For inputs IO channels start at index 4
input_device_names[index + 4] = comment
elif channel_upper == "SI":
# Safety input: SI.In00Data, SI.In01Data, …
if terminal.startswith("In") and terminal.endswith("Data"):
try:
index = int(terminal[2:-4]) # extract NN from InNNDATA
safety_input_names[index] = comment
except ValueError:
continue
elif channel_upper == "SO":
# Safety output: SO.Out00Output, …
if terminal.startswith("Out") and terminal.endswith("Output"):
try:
index = int(terminal[3:-6]) # extract NN from OutNNOUTPUT
safety_output_names[index] = comment
except ValueError:
continue
# Any other variants are ignored (same as before)
return (
input_device_names,
output_device_names,
safety_input_names,
safety_output_names,
)
@classmethod
def from_excel(
cls,
module_data: 'ModuleData',
hp: str,
*,
ip_address: str = "",
parent_module: str = "SLOT2_EN4TR",
parent_port_id: str = "2",
) -> 'APFModuleGenerator':
"""Factory that builds a fully-configured generator directly from
ExcelDataProcessor.ModuleData.
It returns an *instance* (already loaded and updated) so callers can
access .root or save it immediately.
"""
from excel_data_processor import ModuleData # local import to avoid cycle at top level
if not isinstance(module_data, ModuleData):
raise TypeError("module_data must be an Excel ModuleData instance")
(
input_device_names,
output_device_names,
safety_input_names,
safety_output_names,
) = cls._extract_comment_dictionaries(module_data)
config = create_apf_module(
name=module_data.tagname,
hp=hp,
ip_address=ip_address or module_data.ip_address or "192.168.1.10",
parent_module=parent_module,
parent_port_id=parent_port_id,
input_device_names=input_device_names if input_device_names else None,
output_device_names=output_device_names if output_device_names else None,
safety_input_names=safety_input_names if safety_input_names else None,
safety_output_names=safety_output_names if safety_output_names else None,
)
generator = cls(config)
generator.load_boilerplate()
generator.apply_updates()
return generator
def create_apf_module(name: str, hp: str, ip_address: str = "192.168.1.10",
parent_module: str = "SLOT2_EN4TR", parent_port_id: str = "2",
input_device_names: Optional[Dict[int, str]] = None,
output_device_names: Optional[Dict[int, str]] = None,
safety_input_names: Optional[Dict[int, str]] = None,
safety_output_names: Optional[Dict[int, str]] = None) -> APFModuleConfig:
"""Factory function to create an APF module configuration."""
return APFModuleConfig(
name=name,
hp=hp,
ip_address=ip_address,
parent_module=parent_module,
parent_port_id=parent_port_id,
input_device_names=input_device_names,
output_device_names=output_device_names,
safety_input_names=safety_input_names,
safety_output_names=safety_output_names
)
# Example usage
if __name__ == "__main__":
# Example: Create a 5 HP APF module
config = create_apf_module(
name="APF1_5HP",
hp="5", # Specify the horsepower
ip_address="192.168.1.10",
input_device_names={
0: "E-Stop",
1: "Start PB",
2: "Stop PB",
3: "Reset",
4: "Speed Ref",
5: "Enable"
}
)
generator = APFModuleGenerator(config)
generator.load_boilerplate()
generator.apply_updates()
generator.save("generated/APF1_5HP.L5X")
print(f"Generated {config.hp} HP APF module: {config.name}")