diff --git a/.gitignore b/.gitignore index 10d3c36..8dcb05e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.DS_Store + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/src/ditto/readers/cyme/__init__.py b/src/ditto/readers/cyme/__init__.py new file mode 100644 index 0000000..0d4db49 --- /dev/null +++ b/src/ditto/readers/cyme/__init__.py @@ -0,0 +1,46 @@ +from ditto.readers.cyme.components.distribution_bus import DistributionBusMapper +from ditto.readers.cyme.components.distribution_capacitor import DistributionCapacitorMapper +from ditto.readers.cyme.components.distribution_load import DistributionLoadMapper +from ditto.readers.cyme.equipment.geometry_branch_equipment import BareConductorEquipmentMapper +from ditto.readers.cyme.equipment.geometry_branch_equipment import GeometryBranchEquipmentMapper +from ditto.readers.cyme.equipment.matrix_impedance_branch_equipment import ( + MatrixImpedanceBranchEquipmentMapper, +) +from ditto.readers.cyme.equipment.geometry_branch_equipment import ( + GeometryBranchByPhaseEquipmentMapper, +) +from ditto.readers.cyme.components.geometry_branch import GeometryBranchMapper +from ditto.readers.cyme.equipment.distribution_transformer_equipment import ( + DistributionTransformerEquipmentMapper, +) +from ditto.readers.cyme.equipment.distribution_transformer_equipment import WindingEquipmentMapper +from ditto.readers.cyme.equipment.distribution_transformer_three_winding_equipment import ( + DistributionTransformerThreeWindingEquipmentMapper, +) +from ditto.readers.cyme.equipment.distribution_transformer_three_winding_equipment import ( + ThreeWindingEquipmentMapper, +) +from ditto.readers.cyme.components.distribution_transformer import ( + DistributionTransformerByPhaseMapper, + DistributionTransformerMapper, + DistributionTransformerThreeWindingMapper, +) +from ditto.readers.cyme.components.matrix_impedance_switch import MatrixImpedanceSwitchMapper +from ditto.readers.cyme.equipment.matrix_impedance_switch_equipment import ( + MatrixImpedanceSwitchEquipmentMapper, +) +from ditto.readers.cyme.components.matrix_impedance_fuse import MatrixImpedanceFuseMapper +from ditto.readers.cyme.equipment.matrix_impedance_fuse_equipment import ( + MatrixImpedanceFuseEquipmentMapper, +) +from ditto.readers.cyme.components.matrix_impedance_recloser import MatrixImpedanceRecloserMapper +from ditto.readers.cyme.equipment.matrix_impedance_recloser_equipment import ( + MatrixImpedanceRecloserEquipmentMapper, +) +from ditto.readers.cyme.components.matrix_impedance_branch import MatrixImpedanceBranchMapper +from ditto.readers.cyme.components.distribution_voltage_source import ( + DistributionVoltageSourceMapper, +) +from ditto.readers.cyme.equipment.phase_voltagesource_equipment import ( + PhaseVoltageSourceEquipmentMapper, +) diff --git a/src/ditto/readers/cyme/components/distribution_bus.py b/src/ditto/readers/cyme/components/distribution_bus.py new file mode 100644 index 0000000..924de32 --- /dev/null +++ b/src/ditto/readers/cyme/components/distribution_bus.py @@ -0,0 +1,83 @@ +from infrasys.location import Location +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.enums import VoltageTypes, Phase +from gdm.quantities import Voltage +from ditto.readers.cyme.cyme_mapper import CymeMapper + + +class DistributionBusMapper(CymeMapper): + def __init__(self, cyme_model): + super().__init__(cyme_model) + + cyme_file = "Network" + cyme_section = "NODE" + + def parse( + self, row, from_node_sections, to_node_sections, node_feeder_map, node_substation_map + ): + name = self.map_name(row) + feeder = node_feeder_map.get(name, None) + substation = node_substation_map.get(name, None) + + coordinate = self.map_coordinate(row) + phases = self.map_phases(row, from_node_sections, to_node_sections) + rated_voltage = self.map_rated_voltage(row) + voltage_limits = self.map_voltagelimits(row) + voltage_type = self.map_voltage_type(row) + return DistributionBus.model_construct( + name=name, + coordinate=coordinate, + rated_voltage=rated_voltage, + feeder=feeder, + substation=substation, + phases=phases, + voltagelimits=voltage_limits, + voltage_type=voltage_type, + ) + + def map_name(self, row): + name = row["NodeID"] + return name + + def map_coordinate(self, row): + x_key = "CoordX" if "CoordX" in row and row["CoordX"] != "" else "CoordX1" + y_key = "CoordY" if "CoordY" in row and row["CoordY"] != "" else "CoordY1" + # CRS is not provided in the Cyme data + return Location(x=float(row[x_key]), y=float(row[y_key]), crs=None) + + def map_rated_voltage(self, row): + # Placehoder voltage until assign_bus_voltages assigns voltages based on network traversal and transformer ratings + return Voltage(float(12.47), "kilovolts") + + def map_phases(self, row, from_node_sections, to_node_sections): + node_id = row["NodeID"] + all_phases = set() + if node_id in from_node_sections: + for section in from_node_sections[node_id]: + phases = section["Phase"] + for phase in phases: + all_phases.add(phase) + if node_id in to_node_sections: + for section in to_node_sections[node_id]: + phases = section["Phase"] + for phase in phases: + all_phases.add(phase) + + phase_map = {"A": Phase.A, "B": Phase.B, "C": Phase.C, "N": Phase.N} + return [phase_map[p] for p in sorted(all_phases) if p in phase_map] + + def map_voltagelimits(self, row): + low_voltage = None + high_voltage = None + if row["LowVoltageLimit"] != "": + low_voltage = Voltage(row["LowVoltageLimit"], "kilovolts") + if row["HighVoltageLimit"] != "": + high_voltage = Voltage(row["HighVoltageLimit"], "kilovolts") + if low_voltage is not None and high_voltage is not None: + return [low_voltage, high_voltage] + else: + return [] + + def map_voltage_type(self, row): + # Defined later in assigne_bus_voltages based on network traversal and transformer ratings + return VoltageTypes.LINE_TO_LINE diff --git a/src/ditto/readers/cyme/components/distribution_capacitor.py b/src/ditto/readers/cyme/components/distribution_capacitor.py new file mode 100644 index 0000000..92f5d7b --- /dev/null +++ b/src/ditto/readers/cyme/components/distribution_capacitor.py @@ -0,0 +1,89 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from ditto.readers.cyme.equipment.capacitor_equipment import CapacitorEquipmentMapper +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.components.distribution_capacitor import DistributionCapacitor +from gdm.distribution.enums import Phase +from loguru import logger + + +class DistributionCapacitorMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Network" + cyme_section = "SHUNT CAPACITOR SETTING" + + def parse(self, row, section_id_sections, equipment_data): + name = self.map_name(row) + bus = self.map_bus(row, section_id_sections) + phases = self.map_phases(row, section_id_sections) + controllers = self.map_controllers(row) + equipment = self.map_equipment(row, equipment_data) + in_service = self.map_in_service(row) + return DistributionCapacitor.model_construct( + name=name, + bus=bus, + phases=phases, + controllers=controllers, + equipment=equipment, + in_service=in_service, + ) + + def map_name(self, row): + return row["DeviceNumber"] + + def map_phases(self, row, section_id_sections): + phases = [] + section_id = row["SectionID"] + section = section_id_sections[section_id] + section_phases = section["Phase"] + if "FixedKVARA" in row and row["FixedKVARA"] or "A" in section_phases: + phases.append(Phase.A) + if "FixedKVARB" in row and row["FixedKVARB"] or "B" in section_phases: + phases.append(Phase.B) + if "FixedKVARC" in row and row["FixedKVARC"] or "C" in section_phases: + phases.append(Phase.C) + if phases == []: + raise ValueError( + f"Could not determine phases for capacitor {row['DeviceNumber']} on section {section_id} with section phases {section_phases}" + ) + return phases + + def map_bus(self, row, section_id_sections): + section_id = row["SectionID"] + section = section_id_sections[section_id] + from_bus_name = section["FromNodeID"] + to_bus_name = section["ToNodeID"] + to_bus = None + from_bus = None + + from_bus = self.system.get_component(component_type=DistributionBus, name=from_bus_name) + + to_bus = self.system.get_component(component_type=DistributionBus, name=to_bus_name) + + if from_bus is None: + if to_bus is None: + logger.warning(f"Capacitor {section_id} has no bus") + return None + return to_bus + return from_bus + + def map_controllers(self, row): + return [] + + def map_equipment(self, row, equipment_data): + mapper = CapacitorEquipmentMapper(self.system) + capacitor_id = row["ShuntCapacitorID"] + if capacitor_id not in equipment_data.index: + logger.warning( + f"Capacitor {row['DeviceNumber']} references capacitor equipment {capacitor_id} which is not defined in the equipment data. Assigning default capacitor equipment." + ) + capacitor_id = "DEFAULT" + equipment_row = equipment_data.loc[capacitor_id] + if not equipment_row.empty: + equipment = mapper.parse(equipment_row, connection=row["Connection"]) + return equipment + return None + + def map_in_service(self, row): + return True if int(row["ConnectionStatus"]) == 0 else False diff --git a/src/ditto/readers/cyme/components/distribution_load.py b/src/ditto/readers/cyme/components/distribution_load.py new file mode 100644 index 0000000..250997f --- /dev/null +++ b/src/ditto/readers/cyme/components/distribution_load.py @@ -0,0 +1,90 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from ditto.readers.cyme.equipment.load_equipment import LoadEquipmentMapper +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.components.distribution_load import DistributionLoad +from gdm.distribution.enums import Phase +from loguru import logger + + +class DistributionLoadMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Load" + cyme_section = "CUSTOMER LOADS" + + def parse(self, row, section_id_sections, equipment_file, load_record): + name = self.map_name(row) + + bus = self.map_bus(row, section_id_sections) + phases = self.map_phases(row) + equipment = self.map_equipment(row, equipment_file) + if equipment is None: + return None + + if load_record.get(name) is not None: + # Combines powers from multiple customer IDs to their spot loads. + # Individual customer loads are not supported. + + existing_load = load_record.get(name) + existing_load.equipment.phase_loads[0].real_power += equipment.phase_loads[ + 0 + ].real_power + existing_load.equipment.phase_loads[0].reactive_power += equipment.phase_loads[ + 0 + ].reactive_power + return None + + if len(phases) == 0: + logger.warning(f"Load {name} has no phase values. Skipping...") + return None + + load = DistributionLoad.model_construct( + name=name, bus=bus, phases=phases, equipment=equipment + ) + load_record[name] = load + return load + + def map_name(self, row): + load_phase = row["LoadPhase"] + return row["DeviceNumber"] + "_" + str(load_phase) + + def map_bus(self, row, section_id_sections): + section_id = row["SectionID"] + section = section_id_sections[section_id] + from_bus_name = section["FromNodeID"] + to_bus_name = section["ToNodeID"] + to_bus = None + from_bus = None + + from_bus = self.system.get_component(component_type=DistributionBus, name=from_bus_name) + + to_bus = self.system.get_component(component_type=DistributionBus, name=to_bus_name) + + if from_bus is None: + if to_bus is None: + logger.warning(f"Load {section_id} has no bus") + return None + return to_bus + return from_bus + + def map_phases(self, row): + phases = [] + if row["LoadPhase"] is not None: + phase = row["LoadPhase"] + if phase == "A": + phases.append(Phase.A) + elif phase == "B": + phases.append(Phase.B) + elif phase == "C": + phases.append(Phase.C) + return phases + + def map_equipment(self, row, equipment_file): + mapper = LoadEquipmentMapper(self.system) + equipment_row = equipment_file.loc[row["DeviceNumber"]] + if equipment_row is not None: + equipment = mapper.parse(equipment_row, row) + return equipment + + return None diff --git a/src/ditto/readers/cyme/components/distribution_transformer.py b/src/ditto/readers/cyme/components/distribution_transformer.py new file mode 100644 index 0000000..dc08016 --- /dev/null +++ b/src/ditto/readers/cyme/components/distribution_transformer.py @@ -0,0 +1,247 @@ +from loguru import logger +from ditto.readers.cyme.cyme_mapper import CymeMapper +from ditto.readers.cyme.equipment.distribution_transformer_equipment import ( + DistributionTransformerEquipmentMapper, +) +from ditto.readers.cyme.equipment.distribution_transformer_three_winding_equipment import ( + DistributionTransformerThreeWindingEquipmentMapper, +) +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.components.distribution_transformer import DistributionTransformer +from gdm.distribution.enums import Phase + + +class DistributionTransformerMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Network" + cyme_section = "TRANSFORMER SETTING" + + def parse(self, row, used_sections, section_id_sections, equipment_data): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + phase = section["Phase"] + + equipment_row = equipment_data.get(row["EqID"], None) + name = self.map_name(row) + buses = self.map_buses(row, section_id_sections) + winding_phases = self.map_winding_phases(row, section_id_sections, equipment_row, phase) + equipment = self.map_equipment(row, equipment_row, phase) + try: + used_sections.add(name) + return DistributionTransformer.model_construct( + name=name, buses=buses, winding_phases=winding_phases, equipment=equipment + ) + except Exception as e: + logger.warning(f"Failed to create DistributionTransformer {name}: {e}") + return None + + def map_name(self, row): + name = row["SectionID"] + return name + + def map_buses(self, row, section_id_sections): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + from_bus_name = section["FromNodeID"] + to_bus_name = section["ToNodeID"] + + from_bus = self.system.get_component(component_type=DistributionBus, name=from_bus_name) + to_bus = self.system.get_component(component_type=DistributionBus, name=to_bus_name) + return [from_bus, to_bus] + + def map_winding_phases(self, row, section_id_sections, equipment_row, phase): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + + if equipment_row is None: + print( + f"Equipment row not found for transformer {row['EqID']}. Assuming 2 windings. {section}" + ) + equipment_row = {"Type": "2"} + windings_list = [] + # TODO Center tapped/Split phase not supported + # This will assign it properly but handling of buses needs to be developed + # if equipment_row['Type'] == "4": + # num_windings = 3 + # else: + # num_windings = 2 + num_windings = 2 + for i in range(num_windings): + winding_phases = [] + if "A" in phase: + winding_phases.append(Phase.A) + if "B" in phase: + winding_phases.append(Phase.B) + if "C" in phase: + winding_phases.append(Phase.C) + windings_list.append(winding_phases) + return windings_list + + def map_equipment(self, row, equipment_row, phase): + mapper = DistributionTransformerEquipmentMapper(self.system) + if equipment_row is not None: + equipment = mapper.parse(equipment_row, row, phase) + if equipment is not None: + return equipment + return None + + +class DistributionTransformerByPhaseMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Network" + cyme_section = "TRANSFORMER BYPHASE SETTING" + + def parse(self, row, used_sections, section_id_sections, equipment_data): + additional_transformers = [] + + for phase in ["1", "2", "3"]: + if ( + row["PhaseTransformerID" + phase] is None + or row["PhaseTransformerID" + phase] == "" + ): + continue + equipment_row = equipment_data.get(row["PhaseTransformerID" + phase], None) + + name = self.map_name(row, phase) + equipment = self.map_equipment(row, phase, equipment_row) + buses = self.map_buses(row, section_id_sections, equipment.is_center_tapped) + winding_phases = self.map_winding_phases(row, phase, equipment_row) + + try: + used_sections.add(row["SectionID"]) + additional_transformers.append( + DistributionTransformer.model_construct( + name=name, buses=buses, winding_phases=winding_phases, equipment=equipment + ) + ) + except Exception as e: + logger.warning( + f"Failed to add additional transformer {name} for phase {phase} on {row['SectionID']}: {e}" + ) + continue + return additional_transformers + + def map_name(self, row, phase): + name = row["SectionID"] + f"_{phase}" + return name + + def map_buses(self, row, section_id_sections, is_center_tapped=False): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + from_bus_name = section["FromNodeID"] + to_bus_name = section["ToNodeID"] + + from_bus = self.system.get_component(component_type=DistributionBus, name=from_bus_name) + to_bus = self.system.get_component(component_type=DistributionBus, name=to_bus_name) + if is_center_tapped: + return [from_bus, to_bus, to_bus] + return [from_bus, to_bus] + + def map_winding_phases(self, row, phase, equipment_row): + if equipment_row is None: + print( + f"Equipment row not found for transformer {row['PhaseTransformerID' + phase]}. Assuming 2 windings." + ) + equipment_row = {"Type": 2} + windings_list = [] + + # TODO Center tapped/Split phase not supported + # This will assign it properly but handling of buses needs to be developed + # num_windings = 3 + # if equipment_row['Type'] == "4": + # num_windings = 3 + # else: + # num_windings = 2 + num_windings = 2 + for i in range(num_windings): + winding_phases = [] + if "1" == phase: + winding_phases.append(Phase.A) + if "2" == phase: + winding_phases.append(Phase.B) + if "3" == phase: + winding_phases.append(Phase.C) + windings_list.append(winding_phases) + assert len(windings_list) == num_windings + return windings_list + + def map_equipment(self, row, phase, equipment_row): + mapper = DistributionTransformerEquipmentMapper(self.system) + if equipment_row is not None: + equipment = mapper.parse(equipment_row, row, phase) + if equipment is not None: + return equipment + return None + + +class DistributionTransformerThreeWindingMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Network" + cyme_section = "THREE WINDING TRANSFORMER SETTING" + + def parse(self, row, used_sections, section_id_sections, equipment_data): + equipment_row = equipment_data.get(row["EqID"], None) + name = self.map_name(row) + buses = self.map_buses(row, section_id_sections) + winding_phases = self.map_winding_phases(row, section_id_sections) + equipment = self.map_equipment(row, equipment_row) + try: + used_sections.add(name) + return DistributionTransformer.model_construct( + name=name, buses=buses, winding_phases=winding_phases, equipment=equipment + ) + except Exception as e: + logger.warning(f"Failed to create DistributionTransformer {name}: {e}") + return None + + def map_name(self, row): + name = row["SectionID"] + return name + + def map_buses(self, row, section_id_sections): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + from_bus_name = section["FromNodeID"] + to_bus_name = section["ToNodeID"] + tertiary_bus = row["TertiaryNodeID"] + + from_bus = self.system.get_component(component_type=DistributionBus, name=from_bus_name) + to_bus = self.system.get_component(component_type=DistributionBus, name=to_bus_name) + + tertiary_bus = self.system.get_component(component_type=DistributionBus, name=tertiary_bus) + if tertiary_bus.phases == []: + tertiary_bus.phases = to_bus.phases + + return [from_bus, to_bus, tertiary_bus] + + def map_winding_phases(self, row, section_id_sections): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + phase = section["Phase"] + windings_list = [] + + num_windings = 3 + for i in range(num_windings): + winding_phases = [] + if "A" in phase: + winding_phases.append(Phase.A) + if "B" in phase: + winding_phases.append(Phase.B) + if "C" in phase: + winding_phases.append(Phase.C) + windings_list.append(winding_phases) + return windings_list + + def map_equipment(self, row, equipment_row): + mapper = DistributionTransformerThreeWindingEquipmentMapper(self.system) + if equipment_row is not None: + equipment = mapper.parse(equipment_row, row) + if equipment is not None: + return equipment + return None diff --git a/src/ditto/readers/cyme/components/distribution_voltage_source.py b/src/ditto/readers/cyme/components/distribution_voltage_source.py new file mode 100644 index 0000000..45b09a1 --- /dev/null +++ b/src/ditto/readers/cyme/components/distribution_voltage_source.py @@ -0,0 +1,60 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from ditto.readers.cyme.equipment.phase_voltagesource_equipment import ( + PhaseVoltageSourceEquipmentMapper, +) +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.components.distribution_vsource import DistributionVoltageSource +from gdm.distribution.equipment.voltagesource_equipment import VoltageSourceEquipment + + +class DistributionVoltageSourceMapper(CymeMapper): + def __init__(self, cyme_model): + super().__init__(cyme_model) + + cyme_file = "Network" + cyme_section = "SOURCE" + + def parse(self, row): + name = self.map_name(row) + bus = self.map_bus(row) + feeder = bus.feeder + substation = bus.substation + if "OperatingVoltageA" in row: + voltage = float(row["OperatingVoltageA"]) + elif "DesiredVoltage" in row: + voltage = float(row["DesiredVoltage"]) + else: + raise ValueError(f"Operating voltage not found in row: {row}") + + if voltage is None or voltage == "": + return None + + phases = [phs for phs in bus.phases] + equipment = self.map_equipment(bus, voltage) + + return DistributionVoltageSource.model_construct( + name=name, + feeder=feeder, + substation=substation, + bus=bus, + phases=phases, + equipment=equipment, + ) + + def map_name(self, row): + name = row["NodeID"] + return name + + def map_feeder(self, row): + feeder = row["NetworkID"] + return feeder + + def map_bus(self, row): + bus_name = row["NodeID"] + bus = self.system.get_component(DistributionBus, bus_name) + return bus + + def map_equipment(self, bus, voltage): + mapper = PhaseVoltageSourceEquipmentMapper(self.system) + sources = mapper.parse(bus, voltage) + return VoltageSourceEquipment.model_construct(name=bus.name + "-source", sources=sources) diff --git a/src/ditto/readers/cyme/components/geometry_branch.py b/src/ditto/readers/cyme/components/geometry_branch.py new file mode 100644 index 0000000..b8a3b49 --- /dev/null +++ b/src/ditto/readers/cyme/components/geometry_branch.py @@ -0,0 +1,77 @@ +from gdm.quantities import Distance +from ditto.readers.cyme.cyme_mapper import CymeMapper +from ditto.readers.cyme.equipment.geometry_branch_equipment import GeometryBranchEquipment +from gdm.distribution.components.geometry_branch import GeometryBranch +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.enums import Phase + + +class GeometryBranchMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Network" + cyme_section = ["OVERHEADLINE SETTING", "OVERHEAD BYPHASE SETTING"] + + def parse(self, row, used_sections, section_id_sections, cyme_section): + name = self.map_name(row) + buses = self.map_buses(row, section_id_sections) + length = self.map_length(row) + equipment = self.map_equipment(row, cyme_section) + phases = self.map_phases(row, section_id_sections, equipment, buses) + + used_sections.add(name) + return GeometryBranch.model_construct( + name=name, buses=buses, length=length, phases=phases, equipment=equipment + ) + + def map_name(self, row): + name = row["SectionID"] + return name + + def map_buses(self, row, section_id_sections): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + from_bus_name = section["FromNodeID"] + to_bus_name = section["ToNodeID"] + + from_bus = self.system.get_component(component_type=DistributionBus, name=from_bus_name) + to_bus = self.system.get_component(component_type=DistributionBus, name=to_bus_name) + return [from_bus, to_bus] + + def map_length(self, row): + length = Distance(float(row["Length"]), "foot").to("km") + if length <= 0: + length = Distance(0.001, "km") + return length + + def map_phases(self, row, section_id_sections, equipment, buses): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + phase = section["Phase"] + phases = [] + if "A" in phase: + phases.append(Phase.A) + if "B" in phase: + phases.append(Phase.B) + if "C" in phase: + phases.append(Phase.C) + + if len(phases) == len(equipment.conductors): + return phases + elif len(phase) == len(equipment.conductors) - 1: + phases.append(Phase.N) + for bus in buses: + if bus.phases is not None and Phase.N not in bus.phases: + bus.phases.append(Phase.N) + return phases + else: + return phases + + def map_equipment(self, row, cyme_section): + line_id = ( + row["LineCableID"] if cyme_section == "OVERHEADLINE SETTING" else row["DeviceNumber"] + ) + + line = self.system.get_component(component_type=GeometryBranchEquipment, name=line_id) + return line diff --git a/src/ditto/readers/cyme/components/matrix_impedance_branch.py b/src/ditto/readers/cyme/components/matrix_impedance_branch.py new file mode 100644 index 0000000..7070945 --- /dev/null +++ b/src/ditto/readers/cyme/components/matrix_impedance_branch.py @@ -0,0 +1,111 @@ +from gdm.quantities import Distance, ResistancePULength, ReactancePULength, CapacitancePULength +from ditto.readers.cyme.cyme_mapper import CymeMapper +from gdm.distribution.equipment.matrix_impedance_branch_equipment import ( + MatrixImpedanceBranchEquipment, +) +from gdm.distribution.components.matrix_impedance_branch import MatrixImpedanceBranch +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.enums import Phase + + +from ditto.readers.cyme.constants import ( + DEFAULT_BRANCH_LENGTH, + DEFAULT_R_MATRIX, + DEFAULT_X_MATRIX, + DEFAULT_C_MATRIX, + DEFAULT_BRANCH_AMPACITY, +) + + +class MatrixImpedanceBranchMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Network" + cyme_section = ["UNDERGROUNDLINE SETTING", "SECTION"] + + def parse(self, row, used_sections, section_id_sections, cyme_section): + name = self.map_name(row) + if cyme_section == "SECTION" and name in used_sections: + return None + buses = self.map_buses(row, section_id_sections) + length = self.map_length(row, cyme_section) + phases = self.map_phases(row, section_id_sections) + equipment = self.map_equipment(row, phases, cyme_section) + used_sections.add(name) + try: + return MatrixImpedanceBranch( + name=name, buses=buses, length=length, phases=phases, equipment=equipment + ) + except Exception as e: + print(f"Error creating MatrixImpedanceBranch {name}: {e}") + print(buses) + return None + + def map_name(self, row): + name = row["SectionID"] + return name + + def map_buses(self, row, section_id_sections): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + from_bus_name = section["FromNodeID"] + to_bus_name = section["ToNodeID"] + + from_bus = self.system.get_component(component_type=DistributionBus, name=from_bus_name) + to_bus = self.system.get_component(component_type=DistributionBus, name=to_bus_name) + return [from_bus, to_bus] + + def map_length(self, row, cyme_section): + if cyme_section == "UNDERGROUNDLINE SETTING": + length = Distance(float(row["Length"]), "foot").to("km") + else: + length = DEFAULT_BRANCH_LENGTH + + return length + + def map_phases(self, row, section_id_sections): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + phase = section["Phase"] + phases = [] + if "A" in phase: + phases.append(Phase.A) + if "B" in phase: + phases.append(Phase.B) + if "C" in phase: + phases.append(Phase.C) + return phases + + def map_equipment(self, row, phases, cyme_section): + if cyme_section == "UNDERGROUNDLINE SETTING": + line_id = row["LineCableID"] + equipment_name = f"{line_id}_{len(phases)}" + line = self.system.get_component( + component_type=MatrixImpedanceBranchEquipment, name=equipment_name + ) + elif cyme_section == "SECTION": + r = DEFAULT_R_MATRIX + r_matrix = ResistancePULength( + [row[: len(phases)] for row in r[: len(phases)]], + "ohm/mi", + ) + x = DEFAULT_X_MATRIX + x_matrix = ReactancePULength( + [row[: len(phases)] for row in x[: len(phases)]], + "ohm/mi", + ) + c = DEFAULT_C_MATRIX + c_matrix = CapacitancePULength( + [row[: len(phases)] for row in c[: len(phases)]], + "nanofarad/mi", + ) + ampacity = DEFAULT_BRANCH_AMPACITY + line = MatrixImpedanceBranchEquipment.model_construct( + name=row["SectionID"], + r_matrix=r_matrix, + x_matrix=x_matrix, + c_matrix=c_matrix, + ampacity=ampacity, + ) + return line diff --git a/src/ditto/readers/cyme/components/matrix_impedance_fuse.py b/src/ditto/readers/cyme/components/matrix_impedance_fuse.py new file mode 100644 index 0000000..19d5b83 --- /dev/null +++ b/src/ditto/readers/cyme/components/matrix_impedance_fuse.py @@ -0,0 +1,79 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from ditto.readers.cyme.equipment.matrix_impedance_fuse_equipment import ( + MatrixImpedanceFuseEquipment, +) +from gdm.distribution.components.matrix_impedance_fuse import MatrixImpedanceFuse +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.enums import Phase +from ditto.readers.cyme.constants import DEFAULT_BRANCH_LENGTH + + +class MatrixImpedanceFuseMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Network" + cyme_section = "FUSE SETTING" + + def parse(self, row, used_sections, section_id_sections): + name = self.map_name(row) + buses = self.map_buses(row, section_id_sections) + length = self.map_length() + phases = self.map_phases(row, section_id_sections) + is_closed = self.map_is_closed(row, phases) + equipment = self.map_equipment(row, phases) + + used_sections.add(name) + return MatrixImpedanceFuse( + name=name, + buses=buses, + length=length, + phases=phases, + is_closed=is_closed, + equipment=equipment, + ) + + def map_name(self, row): + name = row["SectionID"] + return name + + def map_buses(self, row, section_id_sections): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + from_bus_name = section["FromNodeID"] + to_bus_name = section["ToNodeID"] + + from_bus = self.system.get_component(component_type=DistributionBus, name=from_bus_name) + to_bus = self.system.get_component(component_type=DistributionBus, name=to_bus_name) + return [from_bus, to_bus] + + def map_length(self): + length = DEFAULT_BRANCH_LENGTH + return length + + def map_phases(self, row, section_id_sections): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + phase = section["Phase"] + phases = [] + if "A" in phase: + phases.append(Phase.A) + if "B" in phase: + phases.append(Phase.B) + if "C" in phase: + phases.append(Phase.C) + return phases + + def map_is_closed(self, row, phases): + is_closed = [] + for phase in phases: + if row["NStatus"] == "0": + is_closed.append(True) + else: + is_closed.append(False) + return is_closed + + def map_equipment(self, row, phases): + fuse_id = f"{row['EqID']}_{len(phases)}" + fuse = self.system.get_component(component_type=MatrixImpedanceFuseEquipment, name=fuse_id) + return fuse diff --git a/src/ditto/readers/cyme/components/matrix_impedance_recloser.py b/src/ditto/readers/cyme/components/matrix_impedance_recloser.py new file mode 100644 index 0000000..586b274 --- /dev/null +++ b/src/ditto/readers/cyme/components/matrix_impedance_recloser.py @@ -0,0 +1,90 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from ditto.readers.cyme.equipment.matrix_impedance_recloser_equipment import ( + MatrixImpedanceRecloserEquipment, +) +from gdm.distribution.components.matrix_impedance_recloser import MatrixImpedanceRecloser +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.controllers.distribution_recloser_controller import ( + DistributionRecloserController, +) +from gdm.distribution.enums import Phase +from ditto.readers.cyme.constants import DEFAULT_BRANCH_LENGTH + + +class MatrixImpedanceRecloserMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Network" + cyme_section = "RECLOSER SETTING" + + def parse(self, row, used_sections, section_id_sections): + name = self.map_name(row) + buses = self.map_buses(row, section_id_sections) + length = self.map_length() + phases = self.map_phases(row, section_id_sections) + is_closed = self.map_is_closed(row, phases) + controller = self.map_controller(row) + equipment = self.map_equipment(row, phases) + + used_sections.add(name) + + return MatrixImpedanceRecloser( + name=name, + buses=buses, + length=length, + phases=phases, + is_closed=is_closed, + controller=controller, + equipment=equipment, + ) + + def map_name(self, row): + name = row["SectionID"] + return name + + def map_buses(self, row, section_id_sections): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + from_bus_name = section["FromNodeID"] + to_bus_name = section["ToNodeID"] + + from_bus = self.system.get_component(component_type=DistributionBus, name=from_bus_name) + to_bus = self.system.get_component(component_type=DistributionBus, name=to_bus_name) + return [from_bus, to_bus] + + def map_length(self): + length = DEFAULT_BRANCH_LENGTH + return length + + def map_phases(self, row, section_id_sections): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + phase = section["Phase"] + phases = [] + if "A" in phase: + phases.append(Phase.A) + if "B" in phase: + phases.append(Phase.B) + if "C" in phase: + phases.append(Phase.C) + return phases + + def map_is_closed(self, row, phases): + is_closed = [] + for phase in phases: + if row["NStatus"] == "0": + is_closed.append(True) + else: + is_closed.append(False) + return is_closed + + def map_controller(self, row): + return DistributionRecloserController.example() + + def map_equipment(self, row, phases): + recloser_id = f"{row['EqID']}_{len(phases)}" + recloser = self.system.get_component( + component_type=MatrixImpedanceRecloserEquipment, name=recloser_id + ) + return recloser diff --git a/src/ditto/readers/cyme/components/matrix_impedance_switch.py b/src/ditto/readers/cyme/components/matrix_impedance_switch.py new file mode 100644 index 0000000..3e906aa --- /dev/null +++ b/src/ditto/readers/cyme/components/matrix_impedance_switch.py @@ -0,0 +1,80 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from ditto.readers.cyme.equipment.matrix_impedance_switch_equipment import ( + MatrixImpedanceSwitchEquipment, +) +from gdm.distribution.components.matrix_impedance_switch import MatrixImpedanceSwitch +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.enums import Phase +from ditto.readers.cyme.constants import DEFAULT_BRANCH_LENGTH + + +class MatrixImpedanceSwitchMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Network" + cyme_section = "SWITCH SETTING" + + def parse(self, row, used_sections, section_id_sections): + name = self.map_name(row) + buses = self.map_buses(row, section_id_sections) + length = self.map_length(row) + phases = self.map_phases(row, section_id_sections) + is_closed = self.map_is_closed(row, phases) + equipment = self.map_equipment(row, phases) + used_sections.add(name) + return MatrixImpedanceSwitch( + name=name, + buses=buses, + length=length, + phases=phases, + is_closed=is_closed, + equipment=equipment, + ) + + def map_name(self, row): + name = row["SectionID"] + return name + + def map_buses(self, row, section_id_sections): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + from_bus_name = section["FromNodeID"] + to_bus_name = section["ToNodeID"] + + from_bus = self.system.get_component(component_type=DistributionBus, name=from_bus_name) + to_bus = self.system.get_component(component_type=DistributionBus, name=to_bus_name) + return [from_bus, to_bus] + + def map_length(self, row): + length = DEFAULT_BRANCH_LENGTH + return length + + def map_phases(self, row, section_id_sections): + section_id = str(row["SectionID"]) + section = section_id_sections[section_id] + phase = section["Phase"] + phases = [] + if "A" in phase: + phases.append(Phase.A) + if "B" in phase: + phases.append(Phase.B) + if "C" in phase: + phases.append(Phase.C) + return phases + + def map_is_closed(self, row, phases): + is_closed = [] + for phase in phases: + if row["NStatus"] == "0": + is_closed.append(True) + else: + is_closed.append(False) + return is_closed + + def map_equipment(self, row, phases): + switch_id = f"{row['EqID']}_{len(phases)}" + switch = self.system.get_component( + component_type=MatrixImpedanceSwitchEquipment, name=switch_id + ) + return switch diff --git a/src/ditto/readers/cyme/constants.py b/src/ditto/readers/cyme/constants.py new file mode 100644 index 0000000..6659d82 --- /dev/null +++ b/src/ditto/readers/cyme/constants.py @@ -0,0 +1,27 @@ +from gdm.quantities import Distance, Current, ResistancePULength + +DEFAULT_BRANCH_LENGTH = Distance(0.001, "km") + +DEFAULT_R_MATRIX = [ + [1e-6, 0.0, 0.0], + [0.0, 1e-6, 0.0], + [0.0, 0.0, 1e-6], +] + + +DEFAULT_X_MATRIX = [ + [1e-4, 0.0, 0.0], + [0.0, 1e-4, 0.0], + [0.0, 0.0, 1e-4], +] + + +DEFAULT_C_MATRIX = [ + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], +] + +DEFAULT_BRANCH_AMPACITY = Current(600.0, "A") + +DEFAULT_BRANCH_RESISTANCE = ResistancePULength(0.555000, "ohm/mile").to("ohm/km") diff --git a/src/ditto/readers/cyme/cyme_mapper.py b/src/ditto/readers/cyme/cyme_mapper.py new file mode 100644 index 0000000..882efb3 --- /dev/null +++ b/src/ditto/readers/cyme/cyme_mapper.py @@ -0,0 +1,6 @@ +from abc import ABC + +class CymeMapper(ABC): + + def __init__(self, system): + self.system = system diff --git a/src/ditto/readers/cyme/equipment/capacitor_equipment.py b/src/ditto/readers/cyme/equipment/capacitor_equipment.py new file mode 100644 index 0000000..7cb49f5 --- /dev/null +++ b/src/ditto/readers/cyme/equipment/capacitor_equipment.py @@ -0,0 +1,74 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from gdm.quantities import ReactivePower +from gdm.distribution.equipment.phase_capacitor_equipment import PhaseCapacitorEquipment +from gdm.distribution.equipment.capacitor_equipment import CapacitorEquipment +from gdm.distribution.enums import VoltageTypes +from gdm.quantities import Voltage + + +class CapacitorEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Equipment" + cyme_section = "SHUNT CAPACITOR" + + def parse(self, row, connection): + name = self.map_name(row) + rated_voltage = self.map_rated_voltage(row) + phase_capacitors = self.map_phase_capacitors(row) + voltage_type = self.map_voltage_type(connection) + return CapacitorEquipment( + name=name, + phase_capacitors=phase_capacitors, + rated_voltage=rated_voltage, + voltage_type=voltage_type, + ) + + def map_name(self, row): + return row["ID"] + + def map_rated_voltage(self, row): + return Voltage(float(row["KV"]), "kilovolt") + + def map_phase_capacitors(self, row): + phase_capacitors = [] + number_of_phases = 3 if int(row["Type"]) > 1 else 1 + + for phase in range(1, number_of_phases + 1): + mapper = PhaseCapacitorEquipmentMapper(self.system, num_banks_on=number_of_phases) + phase_capacitor = mapper.parse(row, phase) + phase_capacitors.append(phase_capacitor) + return phase_capacitors + + def map_voltage_type(self, connection): + if connection in ("Y", "YNG"): + return VoltageTypes.LINE_TO_GROUND + return VoltageTypes.LINE_TO_LINE + + +class PhaseCapacitorEquipmentMapper(CymeMapper): + def __init__(self, system, num_banks_on): + super().__init__(system) + self.num_banks_on = num_banks_on + + cyme_file = "Equipment" + cyme_section = "SHUNT CAPACITOR" + + def parse(self, row, phase): + name = self.map_name(row, phase) + rated_reactive_power = self.map_rated_reactive_power(row) + return PhaseCapacitorEquipment( + name=name, rated_reactive_power=rated_reactive_power, num_banks_on=self.num_banks_on + ) + + def map_name(self, row, phase): + if phase == 1: + return row["ID"] + "_A" + if phase == 2: + return row["ID"] + "_B" + if phase == 3: + return row["ID"] + "_C" + + def map_rated_reactive_power(self, row): + return ReactivePower(float(row["KVAR"]), "kilovar") diff --git a/src/ditto/readers/cyme/equipment/distribution_transformer_equipment.py b/src/ditto/readers/cyme/equipment/distribution_transformer_equipment.py new file mode 100644 index 0000000..5067e22 --- /dev/null +++ b/src/ditto/readers/cyme/equipment/distribution_transformer_equipment.py @@ -0,0 +1,337 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from gdm.distribution.equipment.distribution_transformer_equipment import ( + DistributionTransformerEquipment, +) +from gdm.distribution.equipment.distribution_transformer_equipment import WindingEquipment +from gdm.quantities import ActivePower, Voltage +from gdm.distribution.common.sequence_pair import SequencePair +from gdm.distribution.enums import ConnectionType, VoltageTypes + + +class DistributionTransformerEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Equipment" + cyme_section = "TRANSFORMER" + + def parse(self, row, network_row, phase): + name = self.map_name(row) + pct_no_load_loss = self.map_pct_no_load_loss(row) + pct_full_load_loss = self.map_pct_full_load_loss(row) + is_center_tapped = self.map_is_center_tapped(row) + windings = self.map_windings(row, network_row, is_center_tapped, phase) + winding_reactances = self.map_winding_reactances(row, is_center_tapped) + coupling_sequences = self.map_coupling(row, is_center_tapped) + + return DistributionTransformerEquipment.model_construct( + name=name, + pct_no_load_loss=pct_no_load_loss, + pct_full_load_loss=pct_full_load_loss, + windings=windings, + winding_reactances=winding_reactances, + is_center_tapped=is_center_tapped, + coupling_sequences=coupling_sequences, + ) + + def map_name(self, row): + name = row["ID"] + return name + + def map_pct_no_load_loss(self, row): + no_load_loss = float(row["NoLoadLosses"]) + kva = float(row["KVA"]) + pct_no_load_loss = no_load_loss / kva * 100 + return pct_no_load_loss + + def map_pct_full_load_loss(self, row): + # Need to compute rated current and rated resistance to compute full load loss + rated_current_sec = float(row["KVA"]) * 1000 / (float(row["KVLLsec"]) * 1000) + resistance_pu = float(row["Z1"]) / 100 / ((1 + float(row["XR"]) ** 2) ** 0.5) + resistance_sec = resistance_pu * (float(row["KVLLsec"]) ** 2 * 1000) / float(row["KVA"]) + + full_load_loss = rated_current_sec**2 * resistance_sec + pct_full_load_loss = 100 * full_load_loss / (float(row["KVA"]) * 1000) + return pct_full_load_loss + + def map_winding_reactances(self, row, is_center_tapped): + xr_ratio = float(row["XR"]) + if xr_ratio == 0: + xr_ratio = 0.01 + rx_ratio = 1 / xr_ratio + reactance_pu = float(row["Z1"]) / 100 / ((1 + rx_ratio**2) ** 0.5) + if is_center_tapped: + winding_reactances = [reactance_pu * 100, reactance_pu * 100, reactance_pu * 100] + else: + winding_reactances = [reactance_pu * 100] + return winding_reactances + + def map_is_center_tapped(self, row): + # TODO Center tapped/Split phase not supported + # This will assign it properly but handling of buses needs to be developed + # transformer_type = row["Type"] + # if transformer_type == '4': + # return True + + return False + + def map_windings(self, row, network_row, is_center_tapped, phase): + windings = [] + + winding_mapper1 = WindingEquipmentMapper(self.system) + winding_1 = winding_mapper1.parse(row, network_row, winding_number=1, phase=phase) + winding_mapper2 = WindingEquipmentMapper(self.system) + winding_2 = winding_mapper2.parse(row, network_row, winding_number=2, phase=phase) + windings.append(winding_1) + windings.append(winding_2) + if is_center_tapped: + winding_mapper3 = WindingEquipmentMapper(self.system) + winding_3 = winding_mapper3.parse(row, network_row, winding_number=3, phase=phase) + windings.append(winding_3) + return windings + + def map_coupling(self, row, is_center_tapped): + if is_center_tapped: + coupling = [SequencePair(0, 1), SequencePair(0, 2), SequencePair(1, 2)] + else: + coupling = [SequencePair(0, 1)] + return coupling + + +class WindingEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Equipment" + cyme_section = "TRANSFORMER" + + """ + connection_map = { + 0: "Y_Y", + 1: "D_Y", + 2: "D_Y", + 3: "YNG_YNG", + 4: "D_D", + 5: "DO_DO", + 6: "YO_DO", + 7: "D_YNG", + 8: "YNG_D", + 9: "Y_YNG", + 10: "YNG_Y", + 11: "Yg_Zg", + 12: "D_Zg", + } + """ + + # The documentation is confusing on where the below connection types are used + # It appears they are used in the equipment file although it is reported in the network file + # Including the above map incase this is incorrect but the below map appears to be correct based on testing with CYME data + connection_map = { + 0: "Yg_Yg", + 1: "D_Yg", + 2: "D_D", + 3: "Y_Y", + 4: "DO_DO", + 5: "YO_D", + 6: "Yg_D", + 7: "D_Y", + 8: "Y_D", + 9: "Yg_Y", + 10: "Y_Yg", + 11: "Yg_Zg", + 12: "D_Zg", + 13: "Zg_Yg", + 14: "Zg_D", + 15: "Yg_CT", + 16: "D_CT", + 17: "Yg_DCT", + 18: "D_DCT", + 19: "Y_DCT", + 20: "DO_DOCT", + 21: "YO_DOCT", + 22: "DO_YO", + 23: "Yg_Dn", + 24: "Y_Dn", + 25: "D_Dn", + 26: "Zg_Dn", + 27: "Dn_Yg", + 28: "Dn_Y", + 29: "Dn_D", + 30: "Dn_Dn", + 31: "Dn_Zg", + 99: "Equip_Connection", + } + + def parse(self, row, network_row, winding_number, phase): + name = self.map_name(row) + resistance = self.map_resistance(row, winding_number) + is_grounded = self.map_is_grounded(row, winding_number) + rated_voltage = self.map_rated_voltage(row, winding_number) + voltage_type = self.map_voltage_type(row, rated_voltage) + rated_power = self.map_rated_power(row) + num_phases = self.map_num_phases(phase) + connection_type = self.map_connection_type(row, winding_number) + tap_positions = self.map_tap_positions(row, winding_number, network_row, phase) + total_taps = self.map_total_taps(row) + min_tap_pu = self.min_tap_pu(row) + max_tap_pu = self.max_tap_pu(row) + return WindingEquipment.model_construct( + name=name, + resistance=resistance, + is_grounded=is_grounded, + rated_voltage=rated_voltage, + voltage_type=voltage_type, + rated_power=rated_power, + num_phases=num_phases, + connection_type=connection_type, + tap_positions=tap_positions, + total_taps=total_taps, + min_tap_pu=min_tap_pu, + max_tap_pu=max_tap_pu, + ) + + def map_name(self, row): + name = row["ID"] + return name + + def map_resistance(self, row, winding_number): + xr_ratio = float(row["XR"]) + resistance_pu = float(row["Z1"]) / 100 / ((1 + xr_ratio**2) ** 0.5) + return resistance_pu * 100 + + def map_is_grounded(self, row, winding_number): + connection_type = row["Conn"] + if winding_number == 1: + winding = 0 + elif winding_number == 2: + winding = 1 + elif winding_number == 3: + winding = 1 + if isinstance(connection_type, int) or ( + isinstance(connection_type, str) and connection_type.isdigit() + ): + conn_type_int = int(connection_type) + winding_type = self.connection_map.get(conn_type_int, "Y_Y").split("_")[winding] + else: + winding_type = str(connection_type) + + if "YNG" in winding_type: + grounded = False + elif "D" in winding_type: + grounded = False + elif "YO" in winding_type: + grounded = False + else: + grounded = True + return grounded + + def map_rated_voltage(self, row, winding_number): + if winding_number == 1: + voltage = row["KVLLprim"] + elif winding_number == 2: + voltage = row["KVLLsec"] + elif winding_number == 3: + voltage = row["KVLLsec"] + + voltage = Voltage(float(voltage), "kilovolt") + return voltage + + def map_voltage_type(self, row, rated_voltage): + # This is from the CYME documentation but appears to not be entirely correct + # Clearly L-L voltages still appear with a voltage type of 1 + if "VoltageUnit" in row and (row["VoltageUnit"] == "1" or row["VoltageUnit"] == "3"): + return VoltageTypes.LINE_TO_GROUND + return VoltageTypes.LINE_TO_LINE + + def map_rated_power(self, row): + power = row["KVA"] + power = ActivePower(float(power), "kilowatt") + return power + + def map_num_phases(self, phase): + num_phases = len(phase) + return num_phases + + def map_connection_type(self, row, winding_number): + connection_type = row["Conn"] + if winding_number == 1: + winding = 0 + elif winding_number == 2 or winding_number == 3: + winding = 1 + + if isinstance(connection_type, int) or ( + isinstance(connection_type, str) and connection_type.isdigit() + ): + conn_type_int = int(connection_type) + winding_type = self.connection_map.get(conn_type_int, "Y_Y").split("_")[winding] + else: + winding_type = str(connection_type) + + winding_connection_map = { + "YO": "OPEN_STAR", + "DO": "OPEN_DELTA", + "DCT": "DELTA", + "CT": "STAR", + } + + if winding_type in winding_connection_map: + connection_type = winding_connection_map[winding_type] + elif "Z" in winding_type: + connection_type = "ZIG_ZAG" + elif "Y" in winding_type: + connection_type = "STAR" + elif "D" in winding_type: + connection_type = "DELTA" + else: + connection_type = "STAR" + + return ConnectionType(connection_type) + + def map_tap_positions(self, row, winding_number, network_row, phase): + num_phases = len(phase) + + tap_positions = [] + if winding_number == 1: + if network_row is None: + tap = 1.0 + else: + tap = network_row.get("PrimTap", None) + if tap is None: + tap = network_row.get("PrimaryTapSettingA", 100) + tap = float(tap) / 100 + + elif winding_number == 2 or winding_number == 3: + if network_row is None: + tap = 1.0 + else: + tap = network_row.get("SecTap", None) + if tap is None: + tap = network_row.get("SecondaryTapSettingA", 100) + tap = float(tap) / 100 + + if row["Taps"] == "" or row["Taps"] is None: + tap = 1.0 + for phase in range(1, num_phases + 1): + tap_positions.append(tap) + return tap_positions + + def map_total_taps(self, row): + taps = row["Taps"] + if taps == "" or taps is None: + taps = 32 + total_taps = int(taps) + return total_taps + + def min_tap_pu(self, row): + min_tap_pu = row["MinReg_Range"] + if min_tap_pu == "" or min_tap_pu is None: + return 0.9 + min_tap_pu = 1 - float(min_tap_pu) / 100 + return float(min_tap_pu) + + def max_tap_pu(self, row): + max_tap_pu = row["MaxReg_Range"] + if max_tap_pu == "" or max_tap_pu is None: + return 1.1 + max_tap_pu = 1 + float(max_tap_pu) / 100 + return float(max_tap_pu) diff --git a/src/ditto/readers/cyme/equipment/distribution_transformer_three_winding_equipment.py b/src/ditto/readers/cyme/equipment/distribution_transformer_three_winding_equipment.py new file mode 100644 index 0000000..3ef510d --- /dev/null +++ b/src/ditto/readers/cyme/equipment/distribution_transformer_three_winding_equipment.py @@ -0,0 +1,348 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from gdm.distribution.equipment.distribution_transformer_equipment import ( + DistributionTransformerEquipment, +) +from gdm.distribution.equipment.distribution_transformer_equipment import WindingEquipment +from gdm.quantities import ActivePower, Voltage +from gdm.distribution.common.sequence_pair import SequencePair +from gdm.distribution.enums import ConnectionType, VoltageTypes + + +class DistributionTransformerThreeWindingEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Equipment" + cyme_section = "THREE WINDING TRANSFORMER" + + def parse(self, row, network_row): + name = self.map_name(row) + pct_no_load_loss = self.map_pct_no_load_loss(row) + pct_full_load_loss = self.map_pct_full_load_loss(row) + is_center_tapped = self.map_is_center_tapped(row) + windings = self.map_windings(row, network_row) + winding_reactances = self.map_winding_reactances(row) + coupling_sequences = self.map_coupling(row) + + return DistributionTransformerEquipment.model_construct( + name=name, + pct_no_load_loss=pct_no_load_loss, + pct_full_load_loss=pct_full_load_loss, + windings=windings, + winding_reactances=winding_reactances, + is_center_tapped=is_center_tapped, + coupling_sequences=coupling_sequences, + ) + + def map_name(self, row): + name = row["ID"] + return name + + def map_pct_no_load_loss(self, row): + no_load_loss = float(row["NoLoadLosses"]) + kva = float(row["PrimaryRatedCapacity"]) + pct_no_load_loss = no_load_loss / kva * 100 + return pct_no_load_loss + + def map_pct_full_load_loss(self, row): + I1 = float(row["PrimaryRatedCapacity"]) * 1000 / (float(row["PrimaryVoltage"]) * 1000) + I2 = float(row["SecondaryRatedCapacity"]) * 1000 / (float(row["PrimaryVoltage"]) * 1000) + I3 = float(row["TertiaryRatedCapacity"]) * 1000 / (float(row["PrimaryVoltage"]) * 1000) + + Rpu_12 = ( + float(row["PrimaryToSecondaryZ1"]) + / 100 + / ((1 + float(row["PrimaryToSecondaryXR1"]) ** 2) ** 0.5) + ) + Rpu_13 = ( + float(row["PrimaryToTertiaryZ1"]) + / 100 + / ((1 + float(row["PrimaryToTertiaryXR1"]) ** 2) ** 0.5) + ) + Rpu_23 = ( + float(row["SecondaryToTertiaryZ1"]) + / 100 + / ((1 + float(row["SecondaryToTertiaryXR1"]) ** 2) ** 0.5) + ) + + R12 = ( + Rpu_12 + * (float(row["PrimaryVoltage"]) ** 2 * 1000) + / float(row["PrimaryRatedCapacity"]) + ) + R13 = ( + Rpu_13 + * (float(row["PrimaryVoltage"]) ** 2 * 1000) + / float(row["PrimaryRatedCapacity"]) + ) + R23 = ( + Rpu_23 + * (float(row["PrimaryVoltage"]) ** 2 * 1000) + / float(row["PrimaryRatedCapacity"]) + ) + + R1 = (R12 + R13 - R23) / 2 + R2 = (R12 + R23 - R13) / 2 + R3 = (R13 + R23 - R12) / 2 + + full_load_loss = I1**2 * R1 + I2**2 * R2 + I3**2 * R3 + + va = float(row["PrimaryRatedCapacity"]) * 1000 + pct_full_load_loss = 100 * full_load_loss / va + return pct_full_load_loss + + def map_winding_reactances(self, row): + winding_reactances = [] + + xr_ratio12 = float(row["PrimaryToSecondaryXR1"]) + if xr_ratio12 == 0: + xr_ratio12 = 0.01 + rx_ratio12 = 1 / xr_ratio12 + reactance_pu12 = float(row["PrimaryToSecondaryZ1"]) / 100 / ((1 + rx_ratio12**2) ** 0.5) + winding_reactances.append(reactance_pu12) + + xr_ratio13 = float(row["PrimaryToTertiaryXR1"]) + if xr_ratio13 == 0: + xr_ratio13 = 0.01 + rx_ratio13 = 1 / xr_ratio13 + reactance_pu13 = float(row["PrimaryToTertiaryZ1"]) / 100 / ((1 + rx_ratio13**2) ** 0.5) + winding_reactances.append(reactance_pu13) + + xr_ratio23 = float(row["SecondaryToTertiaryXR1"]) + if xr_ratio23 == 0: + xr_ratio23 = 0.01 + rx_ratio23 = 1 / xr_ratio23 + reactance_pu23 = float(row["SecondaryToTertiaryZ1"]) / 100 / ((1 + rx_ratio23**2) ** 0.5) + winding_reactances.append(reactance_pu23) + + return winding_reactances + + def map_is_center_tapped(self, row): + return False + + def map_windings(self, row, network_row): + windings = [] + + winding_mapper1 = ThreeWindingEquipmentMapper(self.system) + winding_1 = winding_mapper1.parse(row, network_row, winding_number=1) + windings.append(winding_1) + + winding_mapper2 = ThreeWindingEquipmentMapper(self.system) + winding_2 = winding_mapper2.parse(row, network_row, winding_number=2) + windings.append(winding_2) + + winding_mapper3 = ThreeWindingEquipmentMapper(self.system) + winding_3 = winding_mapper3.parse(row, network_row, winding_number=3) + windings.append(winding_3) + + return windings + + def map_coupling(self, row): + coupling = [SequencePair(0, 1), SequencePair(0, 2), SequencePair(1, 2)] + return coupling + + +class ThreeWindingEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Equipment" + cyme_section = "THREE WINDING TRANSFORMER" + connection_map = { + 0: "Yg", + 1: "Y", + 2: "Delta", + 3: "Open Delta", + 4: "Closed Delta", + 5: "Zg", + 6: "CT", + 7: "Dg", + } + + def parse(self, row, network_row, winding_number): + name = self.map_name(row) + resistance = self.map_resistance(row, winding_number) + is_grounded = self.map_is_grounded(row, winding_number) + rated_voltage = self.map_rated_voltage(row, winding_number) + voltage_type = self.map_voltage_type(row) + rated_power = self.map_rated_power(row, winding_number) + num_phases = self.map_num_phases(row) + connection_type = self.map_connection_type(row, winding_number) + tap_positions = self.map_tap_positions(row, winding_number, network_row) + total_taps = self.map_total_taps(row) + min_tap_pu = self.min_tap_pu(row) + max_tap_pu = self.max_tap_pu(row) + return WindingEquipment.model_construct( + name=name, + resistance=resistance, + is_grounded=is_grounded, + rated_voltage=rated_voltage, + voltage_type=voltage_type, + rated_power=rated_power, + num_phases=num_phases, + connection_type=connection_type, + tap_positions=tap_positions, + total_taps=total_taps, + min_tap_pu=min_tap_pu, + max_tap_pu=max_tap_pu, + ) + + def map_name(self, row): + name = row["ID"] + return name + + def map_resistance(self, row, winding_number): + Rpu_12 = ( + float(row["PrimaryToSecondaryZ1"]) + / 100 + / ((1 + float(row["PrimaryToSecondaryXR1"]) ** 2) ** 0.5) + ) + Rpu_13 = ( + float(row["PrimaryToTertiaryZ1"]) + / 100 + / ((1 + float(row["PrimaryToTertiaryXR1"]) ** 2) ** 0.5) + ) + Rpu_23 = ( + float(row["SecondaryToTertiaryZ1"]) + / 100 + / ((1 + float(row["SecondaryToTertiaryXR1"]) ** 2) ** 0.5) + ) + + R12 = ( + Rpu_12 + * (float(row["SecondaryVoltage"]) ** 2 * 1000) + / float(row["SecondaryRatedCapacity"]) + ) + R13 = ( + Rpu_13 + * (float(row["TertiaryVoltage"]) ** 2 * 1000) + / float(row["TertiaryRatedCapacity"]) + ) + R23 = ( + Rpu_23 + * (float(row["TertiaryVoltage"]) ** 2 * 1000) + / float(row["TertiaryRatedCapacity"]) + ) + + R1 = max(0.5 * (R12 + R13 - R23), 1e-6) + R2 = max(0.5 * (R12 + R23 - R13), 1e-6) + R3 = max(0.5 * (R13 + R23 - R12), 1e-6) + + if winding_number == 1: + return R1 + elif winding_number == 2: + return R2 + elif winding_number == 3: + return R3 + + def map_is_grounded(self, row, winding_number): + connection_type = None + if winding_number == 1: + connection_type = row["PrimaryConnection"] + elif winding_number == 2: + connection_type = row["SecondaryConnection"] + elif winding_number == 3: + connection_type = row["TertiaryConnection"] + + winding_type = self.connection_map.get(int(connection_type), "Y") + if "Yg" in winding_type: + grounded = True + elif "Dg" in winding_type: + grounded = True + elif "Zg" in winding_type: + grounded = True + else: + grounded = False + return grounded + + def map_rated_voltage(self, row, winding_number): + if winding_number == 1: + voltage = Voltage(float(row["PrimaryVoltage"]), "kilovolt") + elif winding_number == 2: + voltage = Voltage(float(row["SecondaryVoltage"]), "kilovolt") + elif winding_number == 3: + voltage = Voltage(float(row["TertiaryVoltage"]), "kilovolt") + + return voltage + + def map_voltage_type(self, row): + return VoltageTypes.LINE_TO_LINE + + def map_rated_power(self, row, winding_number): + if winding_number == 1: + power = ActivePower(float(row["PrimaryRatedCapacity"]), "kilowatt") + elif winding_number == 2: + power = ActivePower(float(row["SecondaryRatedCapacity"]), "kilowatt") + elif winding_number == 3: + power = ActivePower(float(row["TertiaryRatedCapacity"]), "kilowatt") + return power + + def map_num_phases(self, row): + num_phases = 3 + return num_phases + + def map_connection_type(self, row, winding_number): + connection_type = None + if winding_number == 1: + connection_type = row["PrimaryConnection"] + elif winding_number == 2: + connection_type = row["SecondaryConnection"] + elif winding_number == 3: + connection_type = row["TertiaryConnection"] + + winding_type = self.connection_map.get(int(connection_type), "Y") + if winding_type == "Open Delta": + connection_type = "OPEN_DELTA" + elif "Delta" in winding_type: + connection_type = "DELTA" + elif "Z" in winding_type: + connection_type = "ZIG_ZAG" + elif "Y" in winding_type: + connection_type = "STAR" + elif "D" in winding_type: + connection_type = "DELTA" + elif "CT" == winding_type: + connection_type = "STAR" + else: + connection_type = "STAR" + + return ConnectionType(connection_type) + + def map_tap_positions(self, row, winding_number, network_row): + num_phases = 3 + tap_location = network_row["LTC1_TapLocation"] + if tap_location == "": + return [1.0 for _ in range(num_phases)] + if int(tap_location) != winding_number: + return [1.0 for _ in range(num_phases)] + + if network_row is None: + tap = 1.0 + else: + tap = network_row["LTC1_InitialTapPosition"] + tap = float(tap) / 100 + tap_positions = [] + for _ in range(1, num_phases + 1): + tap_positions.append(tap) + return tap_positions + + def map_total_taps(self, row): + taps = row["LTC1_NumberOfTaps"] + if taps == "" or taps is None: + taps = 32 + total_taps = int(taps) + return total_taps + + def min_tap_pu(self, row): + min_tap_pu = row["LTC1_MinimumRegulationRange"] + if min_tap_pu == "" or min_tap_pu is None: + return 0.9 + min_tap_pu = 1 - float(min_tap_pu) / 100 + return float(min_tap_pu) + + def max_tap_pu(self, row): + max_tap_pu = row["LTC1_MaximumRegulationRange"] + if max_tap_pu == "" or max_tap_pu is None: + return 1.1 + max_tap_pu = 1 + float(max_tap_pu) / 100 + return float(max_tap_pu) diff --git a/src/ditto/readers/cyme/equipment/geometry_branch_equipment.py b/src/ditto/readers/cyme/equipment/geometry_branch_equipment.py new file mode 100644 index 0000000..1cbf774 --- /dev/null +++ b/src/ditto/readers/cyme/equipment/geometry_branch_equipment.py @@ -0,0 +1,324 @@ +from ditto.readers.cyme.constants import DEFAULT_BRANCH_AMPACITY, DEFAULT_BRANCH_RESISTANCE +from loguru import logger +from gdm.quantities import Current, Distance, ResistancePULength +from ditto.readers.cyme.cyme_mapper import CymeMapper +from gdm.distribution.equipment.geometry_branch_equipment import GeometryBranchEquipment +from gdm.distribution.equipment.bare_conductor_equipment import BareConductorEquipment + + +class GeometryBranchEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Equipment" + cyme_section = "LINE" + + def parse(self, row, spacing_ids): + name = self.map_name(row) + conductors = self.map_conductors(row, spacing_ids) + horizontal_positions = self.map_horizontal_positions(row, spacing_ids) + vertical_positions = self.map_vertical_positions(row, spacing_ids) + + return GeometryBranchEquipment.model_construct( + name=name, + conductors=conductors, + horizontal_positions=horizontal_positions, + vertical_positions=vertical_positions, + ) + + def map_name(self, row): + name = row["ID"] + return name + + def map_vertical_positions(self, row, spacing_ids): + spacing_id = row["SpacingID"] + spacing = spacing_ids.loc[spacing_id] + + if not spacing.empty: + vertical_positions = [] + cond1_y = spacing["PosOfCond1_Y"] + cond2_y = spacing["PosOfCond2_Y"] + cond3_y = spacing["PosOfCond3_Y"] + neutral_y = spacing["PosOfNeutralCond_Y"] + if cond1_y != "": + y1 = float(cond1_y) + vertical_positions.append(y1) + if cond2_y != "": + y2 = float(cond2_y) + vertical_positions.append(y2) + if cond3_y != "": + y3 = float(cond3_y) + vertical_positions.append(y3) + if neutral_y != "": + y_n = float(neutral_y) + vertical_positions.append(y_n) + return Distance(vertical_positions, "feet").to("m") + return None + + def map_horizontal_positions(self, row, spacing_ids): + spacing_id = row["SpacingID"] + spacing = spacing_ids.loc[spacing_id] + if not spacing.empty: + horizontal_positions = [] + cond1_x = spacing["PosOfCond1_X"] + cond2_x = spacing["PosOfCond2_X"] + cond3_x = spacing["PosOfCond3_X"] + neutral_x = spacing["PosOfNeutralCond_X"] + if cond1_x != "": + x1 = float(cond1_x) + horizontal_positions.append(x1) + if cond2_x != "": + x2 = float(cond2_x) + horizontal_positions.append(x2) + if cond3_x != "": + x3 = float(cond3_x) + horizontal_positions.append(x3) + if neutral_x != "": + x_n = float(neutral_x) + horizontal_positions.append(x_n) + return Distance(horizontal_positions, "feet").to("m") + return None + + def map_conductors(self, row, spacing_ids): + phase_conductor_name = row["PhaseCondID"] + neutral_conductor_name = row["NeutralCondID"] + try: + phase_conductor = self.system.get_component( + component_type=BareConductorEquipment, name=phase_conductor_name + ) + except Exception as e: + logger.warning( + f"Phase conductor {phase_conductor_name} not found in system. Using default conductor. Error: {e}" + ) + phase_conductor = self.system.get_component( + component_type=BareConductorEquipment, name="Default" + ) + try: + neutral_conductor = self.system.get_component( + component_type=BareConductorEquipment, name=neutral_conductor_name + ) + except Exception as e: + logger.warning( + f"Neutral conductor {neutral_conductor_name} not found in system. Using default conductor. Error: {e}" + ) + neutral_conductor = self.system.get_component( + component_type=BareConductorEquipment, name="Default" + ) + + spacing_id = row["SpacingID"] + spacing = spacing_ids.loc[spacing_id] + if not spacing.empty: + conductors = [] + cond1 = spacing["PosOfCond1_X"] + cond2 = spacing["PosOfCond2_X"] + cond3 = spacing["PosOfCond3_X"] + neutral = spacing["PosOfNeutralCond_X"] + if cond1 != "": + conductors.append(phase_conductor) + if cond2 != "": + conductors.append(phase_conductor) + if cond3 != "": + conductors.append(phase_conductor) + if neutral != "": + conductors.append(neutral_conductor) + return conductors + return None + + +class BareConductorEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Equipment" + cyme_section = "CONDUCTOR" + + def parse(self, row): + name = self.map_name(row) + conductor_diameter = self.map_conductor_diameter(row) + conductor_gmr = self.map_conductor_gmr(row) + ampacity = self.map_ampacity(row) + emergency_ampacity = self.map_emergency_ampacity(row) + ac_resistance = self.map_ac_resistance(row) + dc_resistance = self.map_dc_resistance(row) + return BareConductorEquipment.model_construct( + name=name, + conductor_diameter=conductor_diameter, + conductor_gmr=conductor_gmr, + ampacity=ampacity, + emergency_ampacity=emergency_ampacity, + ac_resistance=ac_resistance, + dc_resistance=dc_resistance, + ) + + def map_name(self, row): + name = row["ID"] + return name + + def map_conductor_diameter(self, row): + conductor_diameter = float(row["Diameter"]) + return Distance(conductor_diameter, "inch").to("mm") + + def map_conductor_gmr(self, row): + conductor_gmr = Distance(float(row["GMR"]), "inch").to("mm") + return conductor_gmr + + def map_ampacity(self, row): + ampacity = Current(float(row["Amps"]), "amp") + if ampacity == 0.0: + ampacity = DEFAULT_BRANCH_AMPACITY + return ampacity + + def map_emergency_ampacity(self, row): + emergency_ampacity = Current(float(row["Amps_4"]), "amp") + if emergency_ampacity == 0.0: + emergency_ampacity = DEFAULT_BRANCH_AMPACITY + return emergency_ampacity + + def map_ac_resistance(self, row): + ac_resistance = ResistancePULength(float(row["R25"]), "ohm/mile").to("ohm/km") + if ac_resistance == 0.0: + ac_resistance = DEFAULT_BRANCH_RESISTANCE + return ac_resistance + + def map_dc_resistance(self, row): + dc_resistance = ResistancePULength(float(row["R25"]), "ohm/mile").to("ohm/km") + if dc_resistance == 0.0: + dc_resistance = DEFAULT_BRANCH_RESISTANCE + return dc_resistance + + +class GeometryBranchByPhaseEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Network" + cyme_section = "OVERHEAD BYPHASE SETTING" + + def parse(self, row, spacing_ids): + name = self.map_name(row) + conductors = self.map_conductors(row, spacing_ids) + horizontal_positions = self.map_horizontal_positions(row, spacing_ids) + vertical_positions = self.map_vertical_positions(row, spacing_ids) + + return GeometryBranchEquipment.model_construct( + name=name, + conductors=conductors, + horizontal_positions=horizontal_positions, + vertical_positions=vertical_positions, + ) + + def map_name(self, row): + name = row["DeviceNumber"] + return name + + def map_vertical_positions(self, row, spacing_ids): + phase_A_conductor_name = row["CondID_A"] + phase_B_conductor_name = row["CondID_B"] + phase_C_conductor_name = row["CondID_C"] + if "CondID_N1" in row: + neutral_conductor_name = row["CondID_N1"] + else: + neutral_conductor_name = row["CondID_N"] + spacing_id = row["SpacingID"] + spacing = spacing_ids.loc[spacing_id] + + if not spacing.empty: + vertical_positions = [] + cond1_y = spacing["PosOfCond1_Y"] + cond2_y = spacing["PosOfCond2_Y"] + cond3_y = spacing["PosOfCond3_Y"] + neutral_y = spacing["PosOfNeutralCond_Y"] + if cond1_y != "" and phase_A_conductor_name != "NONE": + y1 = float(cond1_y) + vertical_positions.append(y1) + if cond2_y != "" and phase_B_conductor_name != "NONE": + y2 = float(cond2_y) + vertical_positions.append(y2) + if cond3_y != "" and phase_C_conductor_name != "NONE": + y3 = float(cond3_y) + vertical_positions.append(y3) + if neutral_y != "" and neutral_conductor_name != "NONE": + y_n = float(neutral_y) + vertical_positions.append(y_n) + return Distance(vertical_positions, "feet").to("m") + return None + + def map_horizontal_positions(self, row, spacing_ids): + phase_A_conductor_name = row["CondID_A"] + phase_B_conductor_name = row["CondID_B"] + phase_C_conductor_name = row["CondID_C"] + if "CondID_N1" in row: + neutral_conductor_name = row["CondID_N1"] + else: + neutral_conductor_name = row["CondID_N"] + spacing_id = row["SpacingID"] + spacing = spacing_ids.loc[spacing_id] + if not spacing.empty: + horizontal_positions = [] + cond1_x = spacing["PosOfCond1_X"] + cond2_x = spacing["PosOfCond2_X"] + cond3_x = spacing["PosOfCond3_X"] + neutral_x = spacing["PosOfNeutralCond_X"] + if cond1_x != "" and phase_A_conductor_name != "NONE": + x1 = float(cond1_x) + horizontal_positions.append(x1) + if cond2_x != "" and phase_B_conductor_name != "NONE": + x2 = float(cond2_x) + horizontal_positions.append(x2) + if cond3_x != "" and phase_C_conductor_name != "NONE": + x3 = float(cond3_x) + horizontal_positions.append(x3) + if neutral_x != "" and neutral_conductor_name != "NONE": + x_n = float(neutral_x) + horizontal_positions.append(x_n) + return Distance(horizontal_positions, "feet").to("m") + return None + + def map_conductors(self, row, spacing_ids): + phase_A_conductor_name = row["CondID_A"] + phase_B_conductor_name = row["CondID_B"] + phase_C_conductor_name = row["CondID_C"] + neutral_conductor_name = row["CondID_N1"] if "CondID_N1" in row else row["CondID_N"] + + phase_A_conductor = None + phase_B_conductor = None + phase_C_conductor = None + neutral_conductor = None + + if phase_A_conductor_name != "NONE": + phase_A_conductor = self.system.get_component( + component_type=BareConductorEquipment, name=phase_A_conductor_name + ) + if phase_B_conductor_name != "NONE": + phase_B_conductor = self.system.get_component( + component_type=BareConductorEquipment, name=phase_B_conductor_name + ) + if phase_C_conductor_name != "NONE": + phase_C_conductor = self.system.get_component( + component_type=BareConductorEquipment, name=phase_C_conductor_name + ) + if neutral_conductor_name != "NONE": + neutral_conductor = self.system.get_component( + component_type=BareConductorEquipment, name=neutral_conductor_name + ) + + spacing_id = row["SpacingID"] + # Spacing is used to see how many conductors there are + spacing = spacing_ids.loc[spacing_id] + if not spacing.empty: + conductors = [] + cond1 = spacing["PosOfCond1_X"] + cond2 = spacing["PosOfCond2_X"] + cond3 = spacing["PosOfCond3_X"] + neutral = spacing["PosOfNeutralCond_X"] + if cond1 != "" and phase_A_conductor is not None: + conductors.append(phase_A_conductor) + if cond2 != "" and phase_B_conductor is not None: + conductors.append(phase_B_conductor) + if cond3 != "" and phase_C_conductor is not None: + conductors.append(phase_C_conductor) + if neutral != "" and neutral_conductor is not None: + conductors.append(neutral_conductor) + + return conductors + return None diff --git a/src/ditto/readers/cyme/equipment/load_equipment.py b/src/ditto/readers/cyme/equipment/load_equipment.py new file mode 100644 index 0000000..2688604 --- /dev/null +++ b/src/ditto/readers/cyme/equipment/load_equipment.py @@ -0,0 +1,138 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from gdm.distribution.equipment.load_equipment import LoadEquipment +from gdm.distribution.equipment.phase_load_equipment import PhaseLoadEquipment +from gdm.quantities import ActivePower, ReactivePower +from gdm.distribution.enums import ConnectionType +from loguru import logger + + +class LoadEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Load" + cyme_section = "LOADS" + + def parse(self, row, network_row): + name = self.map_name(row) + # Connection is not included in LOOADS but in CONSUMER LOADS + connection_type = self.map_connection_type(row) + phase_loads = self.map_phase_loads(network_row) + if any(pl is None for pl in phase_loads): + return None + return LoadEquipment.model_construct( + name=name, phase_loads=phase_loads, connection_type=connection_type + ) + + def map_name(self, row): + return row["DeviceNumber"] + + def map_connection_type(self, row): + connection_number = int(row["Connection"]) + connection_map = { + 0: ConnectionType.STAR, # Yg + 1: ConnectionType.STAR, # Y + 2: ConnectionType.DELTA, # Delta + 3: ConnectionType.OPEN_DELTA, # Open Delta + 4: ConnectionType.DELTA, # Closed Delta + 5: ConnectionType.ZIG_ZAG, # Zg + 6: ConnectionType.STAR, # CT + 7: ConnectionType.DELTA, # Dg - Not sure what this is? + } + return connection_map[connection_number] + + def map_phase_loads(self, row): + # Get the PhaseLoadEquipment with the same name as the Load + phase_load_equipment_mapper = PhaseLoadEquipmentMapper(self.system) + phase_load_equipment = phase_load_equipment_mapper.parse(row) + phase_loads = [phase_load_equipment] + return phase_loads + + +class PhaseLoadEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Load" + cyme_section = "CUSTOMER LOADS" + + def parse(self, row): + name = self.map_name(row) + real_power = self.map_real_power(row) + reactive_power = self.map_reactive_power(row) + if real_power == 0 and reactive_power == 0: + logger.warning(f"Load {name} has 0 kW and 0 kVAR. Skipping...") + return None + z_real = self.map_z_real(row) + z_imag = self.map_z_imag(row) + i_real = self.map_i_real(row) + i_imag = self.map_i_imag(row) + p_real = self.map_p_real(row) + p_imag = self.map_p_imag(row) + return PhaseLoadEquipment( + name=name, + real_power=real_power, + reactive_power=reactive_power, + z_real=z_real, + z_imag=z_imag, + i_real=i_real, + i_imag=i_imag, + p_real=p_real, + p_imag=p_imag, + ) + + def map_name(self, row): + phase = row["LoadPhase"] + return row["DeviceNumber"] + "_" + str(phase) + + def compute_powers(self, row): + v1 = float(row["Value1"]) + v2 = float(row["Value2"]) + kw = None + kvar = None + value_type = int(row["ValueType"]) + # kw and kvar + if value_type == 0: + kw = v1 + kvar = v2 + # kva and pf + elif value_type == 1: + v2 = v2 / 100.0 + kw = v1 * v2 + kvar = v1 * (1 - v2**2) ** 0.5 + # kw and pf + elif value_type == 2: + v2 = v2 / 100.0 + kw = v1 + kvar = v1 * (1 / v2**2 - 1) ** 0.5 + # amp and pf + else: + pass + return ActivePower(kw, "kilowatt"), ReactivePower(kvar, "kilovar") + + def map_real_power(self, row): + kw, kvar = self.compute_powers(row) + return ActivePower(kw, "kilowatt") + + def map_reactive_power(self, row): + kw, kvar = self.compute_powers(row) + return ReactivePower(kvar, "kilovar") + + # Is this included in CYME 9.* ? It was in customer class in previous cyme versions + def map_z_real(self, row): + return 1 + + def map_z_imag(self, row): + return 1 + + def map_i_real(self, row): + return 0 + + def map_i_imag(self, row): + return 0 + + def map_p_real(self, row): + return 0 + + def map_p_imag(self, row): + return 0 diff --git a/src/ditto/readers/cyme/equipment/matrix_impedance_branch_equipment.py b/src/ditto/readers/cyme/equipment/matrix_impedance_branch_equipment.py new file mode 100644 index 0000000..3a308a8 --- /dev/null +++ b/src/ditto/readers/cyme/equipment/matrix_impedance_branch_equipment.py @@ -0,0 +1,84 @@ +from gdm.quantities import ResistancePULength +from ditto.readers.cyme.cyme_mapper import CymeMapper +from gdm.distribution.equipment.matrix_impedance_branch_equipment import ( + MatrixImpedanceBranchEquipment, +) +import numpy as np + + +class MatrixImpedanceBranchEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Equipment" + cyme_section = "CABLE" + + def _sequence_impedance_to_phase_impedance_matrix(self, r1, r0, phases=3): + """ + Return the phase resistance matrix given + positive-sequence r1 and zero-sequence r0. + Assumes r2 = r1 (typical for transposed lines/cables). + """ + if phases == 3: + r_s = (r0 + 2 * r1) / 3.0 # self term + r_m = (r0 - r1) / 3.0 # mutual term + R = np.array([[r_s, r_m, r_m], [r_m, r_s, r_m], [r_m, r_m, r_s]], dtype=float) + elif phases == 2: + r_s = (r0 + 2 * r1) / 3.0 # self term + r_m = (r0 - r1) / 3.0 # mutual term + R = np.array([[r_s, r_m], [r_m, r_s]], dtype=float) + elif phases == 1: + r_s = (r0 + 2 * r1) / 3.0 # self term + R = np.array([[r_s]], dtype=float) + return R + + def parse(self, row, phases): + num_phases = len(phases) + name = self.map_name(row, num_phases) + r_matrix = self.map_r_matrix(row, num_phases) + x_matrix = self.map_x_matrix(row, num_phases) + c_matrix = self.map_c_matrix(row, num_phases) + ampacity = self.map_ampacity(row) + try: + return MatrixImpedanceBranchEquipment( + name=name, + r_matrix=r_matrix, + x_matrix=x_matrix, + c_matrix=c_matrix, + ampacity=ampacity, + ) + except Exception as e: + print(f"Error creating MatrixImpedanceBranchEquipment {name}: {e}") + return None + + def map_name(self, row, phases): + name = f"{row['ID']}_{phases}" + return name + + def map_r_matrix(self, row, phases): + r1 = float(row["R1"]) + r0 = float(row["R0"]) + matrix = self._sequence_impedance_to_phase_impedance_matrix(r1, r0, phases) + matrix = ResistancePULength(np.array(matrix), "ohm/mile") + return matrix + + def map_x_matrix(self, row, phases): + x1 = float(row["X1"]) + x0 = float(row["X0"]) + matrix = self._sequence_impedance_to_phase_impedance_matrix(x1, x0, phases) + matrix = ResistancePULength(np.array(matrix), "ohm/mile") + return matrix + + def map_c_matrix(self, row, phases): + b1 = float(row["B1"]) + b0 = float(row["B0"]) + susceptance_matrix = self._sequence_impedance_to_phase_impedance_matrix(b1, b0, phases) + # Convert susceptance to capacitance: C = B / (2 * pi * f) + frequency = 60 # Hz + capacitance_matrix = susceptance_matrix / (2 * np.pi * frequency) + capacitance_matrix = ResistancePULength(np.array(capacitance_matrix), "microfarad/mile") + return capacitance_matrix + + def map_ampacity(self, row): + ampacity = float(row["Amps"]) + return ampacity diff --git a/src/ditto/readers/cyme/equipment/matrix_impedance_fuse_equipment.py b/src/ditto/readers/cyme/equipment/matrix_impedance_fuse_equipment.py new file mode 100644 index 0000000..1323772 --- /dev/null +++ b/src/ditto/readers/cyme/equipment/matrix_impedance_fuse_equipment.py @@ -0,0 +1,73 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from gdm.quantities import Current, ResistancePULength, ReactancePULength, CapacitancePULength +from gdm.distribution.equipment.matrix_impedance_fuse_equipment import MatrixImpedanceFuseEquipment +from gdm.distribution.common.curve import TimeCurrentCurve +from infrasys.quantities import Time +from gdm.distribution.enums import LineType + +from ditto.readers.cyme.constants import DEFAULT_C_MATRIX, DEFAULT_X_MATRIX, DEFAULT_R_MATRIX + + +class MatrixImpedanceFuseEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Equipment" + cyme_section = "FUSE" + + def parse(self, row, phases): + name = self.map_name(row, phases) + delay = self.map_delay(row) + tcc_curve = self.map_tcc_curve(row) + r_matrix = self.map_r_matrix(phases) + x_matrix = self.map_x_matrix(phases) + c_matrix = self.map_c_matrix(phases) + ampacity = self.map_ampacity(row) + + return MatrixImpedanceFuseEquipment( + name=name, + delay=delay, + tcc_curve=tcc_curve, + construction=LineType.OVERHEAD, + r_matrix=r_matrix, + x_matrix=x_matrix, + c_matrix=c_matrix, + ampacity=ampacity, + ) + + def map_name(self, row, phases): + return f"{row['ID']}_{len(phases)}" + + def map_r_matrix(self, phases): + default_matrix = DEFAULT_R_MATRIX + matrix = [row[: len(phases)] for row in default_matrix[: len(phases)]] + return ResistancePULength( + matrix, + "ohm/mi", + ) + + def map_x_matrix(self, phases): + default_matrix = DEFAULT_X_MATRIX + matrix = [row[: len(phases)] for row in default_matrix[: len(phases)]] + return ReactancePULength( + matrix, + "ohm/mi", + ) + + def map_c_matrix(self, phases): + default_matrix = DEFAULT_C_MATRIX + matrix = [row[: len(phases)] for row in default_matrix[: len(phases)]] + + return CapacitancePULength( + matrix, + "nanofarad/mi", + ) + + def map_ampacity(self, row): + return Current(float(row["Amps"]), "ampere") + + def map_delay(self, row): + return Time(0, "minutes") + + def map_tcc_curve(self, row): + return TimeCurrentCurve.example() diff --git a/src/ditto/readers/cyme/equipment/matrix_impedance_recloser_equipment.py b/src/ditto/readers/cyme/equipment/matrix_impedance_recloser_equipment.py new file mode 100644 index 0000000..9291aab --- /dev/null +++ b/src/ditto/readers/cyme/equipment/matrix_impedance_recloser_equipment.py @@ -0,0 +1,62 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from gdm.quantities import Current, ResistancePULength, ReactancePULength, CapacitancePULength +from gdm.distribution.equipment.matrix_impedance_recloser_equipment import ( + MatrixImpedanceRecloserEquipment, +) +from gdm.distribution.enums import LineType +from ditto.readers.cyme.constants import DEFAULT_C_MATRIX, DEFAULT_X_MATRIX, DEFAULT_R_MATRIX + + +class MatrixImpedanceRecloserEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Equipment" + cyme_section = "RECLOSER" + + def parse(self, row, phases): + name = self.map_name(row, phases) + r_matrix = self.map_r_matrix(phases) + x_matrix = self.map_x_matrix(phases) + c_matrix = self.map_c_matrix(phases) + ampacity = self.map_ampacity(row) + + return MatrixImpedanceRecloserEquipment( + name=name, + construction=LineType.OVERHEAD, + r_matrix=r_matrix, + x_matrix=x_matrix, + c_matrix=c_matrix, + ampacity=ampacity, + ) + + def map_name(self, row, phases): + return f"{row['ID']}_{len(phases)}" + + def map_r_matrix(self, phases): + default_matrix = DEFAULT_R_MATRIX + matrix = [row[: len(phases)] for row in default_matrix[: len(phases)]] + return ResistancePULength( + matrix, + "ohm/mi", + ) + + def map_x_matrix(self, phases): + default_matrix = DEFAULT_X_MATRIX + matrix = [row[: len(phases)] for row in default_matrix[: len(phases)]] + return ReactancePULength( + matrix, + "ohm/mi", + ) + + def map_c_matrix(self, phases): + default_matrix = DEFAULT_C_MATRIX + matrix = [row[: len(phases)] for row in default_matrix[: len(phases)]] + + return CapacitancePULength( + matrix, + "nanofarad/mi", + ) + + def map_ampacity(self, row): + return Current(float(row["Amps"]), "ampere") diff --git a/src/ditto/readers/cyme/equipment/matrix_impedance_switch_equipment.py b/src/ditto/readers/cyme/equipment/matrix_impedance_switch_equipment.py new file mode 100644 index 0000000..15d77a7 --- /dev/null +++ b/src/ditto/readers/cyme/equipment/matrix_impedance_switch_equipment.py @@ -0,0 +1,63 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from gdm.quantities import Current, ResistancePULength, ReactancePULength, CapacitancePULength +from gdm.distribution.equipment.matrix_impedance_switch_equipment import ( + MatrixImpedanceSwitchEquipment, +) +from gdm.distribution.enums import LineType + +from ditto.readers.cyme.constants import DEFAULT_C_MATRIX, DEFAULT_X_MATRIX + + +class MatrixImpedanceSwitchEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + cyme_file = "Equipment" + cyme_section = "SWITCH" + + def parse(self, row, phases): + name = self.map_name(row, phases) + r_matrix = self.map_r_matrix(phases) + x_matrix = self.map_x_matrix(phases) + c_matrix = self.map_c_matrix(phases) + ampacity = self.map_ampacity(row) + + return MatrixImpedanceSwitchEquipment( + name=name, + construction=LineType.OVERHEAD, + r_matrix=r_matrix, + x_matrix=x_matrix, + c_matrix=c_matrix, + ampacity=ampacity, + ) + + def map_name(self, row, phases): + return f"{row['ID']}_{len(phases)}" + + def map_r_matrix(self, phases): + default_matrix = DEFAULT_C_MATRIX + matrix = [row[: len(phases)] for row in default_matrix[: len(phases)]] + return ResistancePULength( + matrix, + "ohm/mi", + ) + + def map_x_matrix(self, phases): + default_matrix = DEFAULT_X_MATRIX + matrix = [row[: len(phases)] for row in default_matrix[: len(phases)]] + return ReactancePULength( + matrix, + "ohm/mi", + ) + + def map_c_matrix(self, phases): + default_matrix = DEFAULT_C_MATRIX + matrix = [row[: len(phases)] for row in default_matrix[: len(phases)]] + + return CapacitancePULength( + matrix, + "nanofarad/mi", + ) + + def map_ampacity(self, row): + return Current(float(row["Amps"]), "ampere") diff --git a/src/ditto/readers/cyme/equipment/phase_voltagesource_equipment.py b/src/ditto/readers/cyme/equipment/phase_voltagesource_equipment.py new file mode 100644 index 0000000..1117301 --- /dev/null +++ b/src/ditto/readers/cyme/equipment/phase_voltagesource_equipment.py @@ -0,0 +1,27 @@ +from ditto.readers.cyme.cyme_mapper import CymeMapper +from gdm.distribution.equipment.phase_voltagesource_equipment import PhaseVoltageSourceEquipment +from gdm.quantities import Angle, Reactance, Resistance +from gdm.distribution.enums import VoltageTypes +from gdm.quantities import Voltage + + +class PhaseVoltageSourceEquipmentMapper(CymeMapper): + def __init__(self, system): + super().__init__(system) + + def parse(self, bus, source_voltage): + sources = [] + num_phases = len(bus.phases) + for i in range(num_phases): + source = PhaseVoltageSourceEquipment.model_construct( + name=f"{bus.name}-phase-source-{i+1}", + r0=Resistance(0.001, "ohm"), + r1=Resistance(0.001, "ohm"), + x0=Reactance(0.001, "ohm"), + x1=Reactance(0.001, "ohm"), + voltage=Voltage(source_voltage, "kilovolt"), + voltage_type=VoltageTypes.LINE_TO_GROUND, + angle=Angle(i * (360.0 / num_phases), "degree"), + ) + sources.append(source) + return sources diff --git a/src/ditto/readers/cyme/reader.py b/src/ditto/readers/cyme/reader.py new file mode 100644 index 0000000..c1c041b --- /dev/null +++ b/src/ditto/readers/cyme/reader.py @@ -0,0 +1,364 @@ +from gdm.distribution.distribution_system import DistributionSystem +from ditto.readers.reader import AbstractReader +from ditto.readers.cyme.utils import read_cyme_data, network_truncation +import ditto.readers.cyme as cyme_mapper +from loguru import logger +from pydantic import ValidationError +from rich.console import Console +from infrasys import Component +from rich.table import Table +from collections import defaultdict, deque + + +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.components import DistributionVoltageSource + + +class Reader(AbstractReader): + # Order of components is important + component_types = [ + "DistributionBus", # First as other components connect to buses + "DistributionVoltageSource", + "MatrixImpedanceRecloserEquipment", + "MatrixImpedanceRecloser", + "MatrixImpedanceSwitchEquipment", + "MatrixImpedanceSwitch", + "MatrixImpedanceFuseEquipment", + "MatrixImpedanceFuse", + "DistributionCapacitor", + "DistributionLoad", + "BareConductorEquipment", + "MatrixImpedanceBranchEquipment", + "GeometryBranchEquipment", + "GeometryBranchByPhaseEquipment", + "GeometryBranch", + "DistributionTransformerByPhase", + "DistributionTransformer", + "DistributionTransformerThreeWinding", + "MatrixImpedanceBranch", # This must be last as it includes a catch-all for unrecognized branches + ] + + def __init__( + self, + network_file, + equipment_file, + load_file, + load_model_id=None, + substation_names=None, + feeder_names=None, + ): + self.validation_errors = [] + self.system = DistributionSystem(auto_add_composed_components=True) + self.read( + network_file, + equipment_file, + load_file, + load_model_id, + substation_names=substation_names, + feeder_names=feeder_names, + ) + + def read( + self, + network_file, + equipment_file, + load_file, + load_model_id=None, + substation_names=None, + feeder_names=None, + ): + # Section data read separately as it links to other tables + section_id_sections = {} + from_node_sections = {} + to_node_sections = {} + phase_elements = set( + [ + "MatrixImpedanceBranchEquipmentMapper", + "MatrixImpedanceRecloserEquipmentMapper", + "MatrixImpedanceSwitchEquipmentMapper", + "MatrixImpedanceFuseEquipmentMapper", + ] + ) + + node_feeder_map = {} + node_substation_map = {} + load_record = {} + used_sections = set() + + section_data = read_cyme_data( + network_file, + "SECTION", + node_feeder_map=node_feeder_map, + node_substation_map=node_substation_map, + parse_feeders=True, + parse_substation=True, + ) + + section_id_sections = section_data.set_index("SectionID").to_dict(orient="index") + from_node_sections = ( + section_data.groupby("FromNodeID") + .apply(lambda df: df.to_dict(orient="records")) + .to_dict() + ) + to_node_sections = ( + section_data.groupby("ToNodeID") + .apply(lambda df: df.to_dict(orient="records")) + .to_dict() + ) + + for component_type in self.component_types: + logger.info(f"Parsing Type: {component_type}") + mapper_name = component_type + "Mapper" + if not hasattr(cyme_mapper, mapper_name): + logger.warning(f"Mapper {mapper_name} not found. Skipping.") + continue + + mapper = getattr(cyme_mapper, mapper_name)(self.system) + + cyme_file = mapper.cyme_file + cyme_section = mapper.cyme_section + all_cyme_sections = mapper.cyme_section + if isinstance(all_cyme_sections, str): + all_cyme_sections = [all_cyme_sections] + + for cyme_section in all_cyme_sections: + data = self._prepare_data( + cyme_file, cyme_section, load_model_id, network_file, equipment_file, load_file + ) + + argument_handler = { + "DistributionCapacitorMapper": lambda: [ + section_id_sections, + read_cyme_data(equipment_file, "SHUNT CAPACITOR", index_col="ID"), + ], + "DistributionBusMapper": lambda: [ + from_node_sections, + to_node_sections, + node_feeder_map, + node_substation_map, + ], + "DistributionVoltageSourceMapper": lambda: [], + "DistributionLoadMapper": lambda: [ + section_id_sections, + read_cyme_data(load_file, "LOADS", index_col="DeviceNumber"), + load_record, + ], + "GeometryBranchMapper": lambda: [ + used_sections, + section_id_sections, + cyme_section, + ], + "BareConductorEquipmentMapper": lambda: [], + "GeometryBranchEquipmentMapper": lambda: [ + read_cyme_data(equipment_file, "SPACING TABLE FOR LINE", index_col="ID") + ], + "MatrixImpedanceSwitchEquipmentMapper": lambda: [], + "MatrixImpedanceSwitchMapper": lambda: [used_sections, section_id_sections], + "MatrixImpedanceFuseEquipmentMapper": lambda: [], + "MatrixImpedanceFuseMapper": lambda: [used_sections, section_id_sections], + "MatrixImpedanceRecloserEquipmentMapper": lambda: [], + "MatrixImpedanceRecloserMapper": lambda: [used_sections, section_id_sections], + "GeometryBranchByPhaseEquipmentMapper": lambda: [ + read_cyme_data(equipment_file, "SPACING TABLE FOR LINE", index_col="ID") + ], + "DistributionTransformerThreeWindingMapper": lambda: [ + used_sections, + section_id_sections, + read_cyme_data( + equipment_file, "THREE WINDING TRANSFORMER", index_col="ID" + ).to_dict("index"), + ], + "DistributionTransformerMapper": lambda: [ + used_sections, + section_id_sections, + read_cyme_data(equipment_file, "TRANSFORMER", index_col="ID").to_dict( + "index" + ), + ], + "DistributionTransformerByPhaseMapper": lambda: [ + used_sections, + section_id_sections, + read_cyme_data(equipment_file, "TRANSFORMER", index_col="ID").to_dict( + "index" + ), + ], + "MatrixImpedanceBranchEquipmentMapper": lambda: [], + "MatrixImpedanceBranchMapper": lambda: [ + used_sections, + section_id_sections, + cyme_section, + ], + } + + args = argument_handler.get(mapper_name, lambda: [])() + + def parse_row(row): + model_entry = mapper.parse(row, *args) + return model_entry + + if mapper_name in phase_elements: + phases = [] + for phase in ["A", "B", "C"]: + phases.append(phase) + args = [phases] + components = data.apply(parse_row, axis=1) + components = [c for c in components if c is not None] + components = [ + item + for c in components + for item in (c if isinstance(c, list) else [c]) + ] + self.system.add_components(*components) + else: + components = data.apply(parse_row, axis=1) + components = [c for c in components if c is not None] + components = [ + item for c in components for item in (c if isinstance(c, list) else [c]) + ] + self.system.add_components(*components) + + self.assign_bus_voltages() + + if substation_names is not None or feeder_names is not None: + self.system = network_truncation( + self.system, substation_names=substation_names, feeder_names=feeder_names + ) + print("Finished truncation") + + for component_type in self.system.get_component_types(): + components = self.system.get_components(component_type) + self._add_components(components) + + self._validate_model() + + def _add_components(self, components: list[Component]): + """Internal method to add components to the system.""" + + if components: + for component in components: + try: + component.__class__.model_validate(component.model_dump()) + except ValidationError as e: + for error in e.errors(): + self.validation_errors.append( + [ + component.name, + component.__class__.__name__, + error["loc"][0] if error["loc"] else "On model validation", + error["type"], + error["msg"], + ] + ) + + def _validate_model(self): + if self.validation_errors: + error_table = Table(title="Validation warning summary") + error_table.add_column("Model", justify="right", style="cyan", no_wrap=True) + error_table.add_column("Type", style="green") + error_table.add_column("Field", justify="right", style="bright_magenta") + error_table.add_column("Error", style="bright_red") + error_table.add_column("Message", justify="right", style="turquoise2") + + for row in self.validation_errors: + print(row) + error_table.add_row(*row) + + console = Console() + console.print(error_table) + raise Exception( + "Validations errors occurred when running the script. See the table above" + ) + + def _prepare_data( + self, cyme_file, cyme_section, load_model_id, network_file, equipment_file, load_file + ): + if cyme_file == "Network": + data = read_cyme_data(network_file, cyme_section) + elif cyme_file == "Equipment": + data = read_cyme_data(equipment_file, cyme_section) + elif cyme_file == "Load": + data = read_cyme_data(load_file, cyme_section) + if load_model_id is not None: + data = data[data["LoadModelID"] == load_model_id] + logger.info(f"Filtered Load data by LoadModelID: {load_model_id}") + else: + if len(data["LoadModelID"].unique()) > 1: + raise ValueError( + f"Multiple LoadModelIDs found in load data: {data['LoadModelID'].unique()}. Please specify load_model_id" + ) + else: + raise ValueError(f"Unknown CYME file {cyme_file}") + + return data + + def get_system(self) -> DistributionSystem: + return self.system + + def assign_bus_voltages(self): + observed_buses = set() + observed_components = set() + + bus_obj_map = self._create_bus_obj_map() + + bus_queue = self._start_queue_w_voltage_sources() + + while bus_queue: + current_bus_name = bus_queue.popleft() + + current_bus = self.system.get_component(DistributionBus, name=current_bus_name) + current_voltage = current_bus.rated_voltage + current_voltage_type = current_bus.voltage_type + observed_buses.add(current_bus.name) + + conn_objs = bus_obj_map[current_bus.name] + for obj in conn_objs: + if obj.name in observed_components: + continue + observed_components.add(obj.name) + component_type = obj.__class__.__name__ + + for j, bus in enumerate(obj.buses): + if bus.name == current_bus.name: + continue + + if component_type == "DistributionTransformer": + for i, winding in enumerate(obj.equipment.windings): + voltage = winding.rated_voltage + voltage_type = winding.voltage_type + + if i == j and voltage != current_voltage: + bus.voltage_type = voltage_type + bus.rated_voltage = voltage + + else: + bus.rated_voltage = current_voltage + bus.voltage_type = current_voltage_type + + if bus.name not in observed_buses: + bus_queue.append(bus.name) + + def _start_queue_w_voltage_sources(self): + bus_queue = deque() + + voltage_sources = list(self.system.get_components(DistributionVoltageSource)) + + for vsource in voltage_sources: + vsource.bus.rated_voltage = ( + vsource.equipment.sources[0].voltage * 1.732 + if len(vsource.phases) > 1 + else vsource.equipment.sources[0].voltage + ) + bus_queue.append(vsource.bus.name) + + return bus_queue + + def _create_bus_obj_map(self): + bus_obj_map = defaultdict(list) + for component_type in self.system.get_component_types(): + component_list = list(self.system.get_components(component_type)) + for comp in component_list: + if hasattr(comp, "buses"): + for bus in comp.buses: + bus_obj_map[bus.name].append(comp) + + return bus_obj_map diff --git a/src/ditto/readers/cyme/utils.py b/src/ditto/readers/cyme/utils.py new file mode 100644 index 0000000..83c7c7f --- /dev/null +++ b/src/ditto/readers/cyme/utils.py @@ -0,0 +1,184 @@ +import pandas as pd +from gdm.distribution.components.distribution_feeder import DistributionFeeder +from gdm.distribution.components.distribution_substation import DistributionSubstation +from gdm.distribution.distribution_system import DistributionSystem +from gdm.distribution.components.distribution_bus import DistributionBus +from infrasys.exceptions import ISAlreadyAttached + +from functools import partial + + +def read_cyme_data( + cyme_file, + cyme_section, + index_col=None, + node_feeder_map=None, + node_substation_map=None, + parse_feeders=False, + parse_substation=False, +): + all_data = [] + headers = None + with open(cyme_file) as f: + reading = False + feeder_id = None + feeder_object_map = {} + substation_id = None + substation_object_map = {} + + for line in f: + if line.startswith(f"[{cyme_section}]"): + reading = True + continue + if not reading: + continue + if line.strip() == "": + break + + if line.startswith(f"FORMAT_{cyme_section.replace(' ','')}"): + headers = line.split("=")[1].strip().split(",") + continue + elif ( + line.startswith("FORMAT") + or line.startswith("FEEDER") + or line.startswith("SUBSTATION") + ): + feeder_id, substation_id = _parse_context_line( + line, parse_feeders, parse_substation + ) + continue + else: + try: + line = line.strip() + line_data = line.split(",") + if cyme_section == "SECTION": + _track_section_nodes( + line_data, + feeder_id, + substation_id, + parse_feeders, + parse_substation, + node_feeder_map, + feeder_object_map, + node_substation_map, + substation_object_map, + ) + all_data.append(line_data) + except Exception as e: + raise Exception(f"Failed to parse line: {line}. Error: {e}") + + data = pd.DataFrame(all_data, columns=headers) + if index_col is not None: + data.set_index(index_col, inplace=True, drop=False) + return data + + +def _parse_context_line(line, parse_feeders, parse_substation): + """Extract feeder_id / substation_id from FEEDER= or SUBSTATION= lines.""" + feeder_id = None + substation_id = None + if parse_substation and line.startswith("SUBSTATION"): + substation_id = line.split(",")[0].split("=")[1].strip() + if parse_feeders and line.startswith("FEEDER"): + feeder_id = line.split(",")[0].split("=")[1].strip() + return feeder_id, substation_id + + +def _track_section_nodes( + line_data, + feeder_id, + substation_id, + parse_feeders, + parse_substation, + node_feeder_map, + feeder_object_map, + node_substation_map, + substation_object_map, +): + """Map SECTION nodes to their feeder/substation objects.""" + node1 = line_data[1].strip() + node2 = line_data[3].strip() + if parse_feeders and (feeder_id is not None): + feeder = feeder_object_map.get(feeder_id, DistributionFeeder(name=feeder_id)) + node_feeder_map[node1] = feeder + node_feeder_map[node2] = feeder + feeder_object_map[feeder_id] = feeder + if parse_substation and (substation_id is not None): + substation = substation_object_map.get( + substation_id, + DistributionSubstation(name=substation_id, feeders=[]), + ) + node_substation_map[node1] = substation + node_substation_map[node2] = substation + substation_object_map[substation_id] = substation + + +def network_truncation(system, substation_names=None, feeder_names=None): + trunc_dist_sys = DistributionSystem(auto_add_composed_components=True) + + bus_set = _collect_connected_buses( + system, substation_names=substation_names, feeder_names=feeder_names + ) + + print(f"Truncating to {len(bus_set)} buses") + types = list(system.get_component_types()) + for component_type in types: + components = list(system.get_components(component_type)) + length = len(components) + print(f"Truncating components of type {component_type.__name__}, total: {length}") + for i, comp in enumerate(components): + print(f"Truncating component {i+1} of {length}", end="\r", flush=True) + if hasattr(comp, "bus"): + if comp.bus.name in bus_set: + _safe_add_component(trunc_dist_sys, comp) + elif hasattr(comp, "buses"): + for bus in comp.buses: + if bus.name in bus_set: + _safe_add_component(trunc_dist_sys, comp) + break + else: + break + + return trunc_dist_sys + + +def _collect_connected_buses(system, substation_names=None, feeder_names=None): + buses = list( + system.get_components( + DistributionBus, + filter_func=partial(filter_substation, substation_names=substation_names), + ) + ) + buses.extend( + list( + system.get_components( + DistributionBus, filter_func=partial(filter_feeder, feeder_names=feeder_names) + ) + ) + ) + bus_set = set(bus.name for bus in buses) + + return bus_set + + +def _safe_add_component(trunc_dist_sys, comp): + try: + trunc_dist_sys.add_component(comp) + except ISAlreadyAttached: + pass + + +def filter_feeder(object, feeder_names=None): + if not hasattr(object.feeder, "name"): + return False + if object.feeder.name in feeder_names: + return True + return False + + +def filter_substation(object, substation_names=None): + if not hasattr(object.substation, "name"): + return False + if object.substation.name in substation_names: + return True + return False diff --git a/tests/test_cyme/test_cyme_reader.py b/tests/test_cyme/test_cyme_reader.py new file mode 100644 index 0000000..66feb83 --- /dev/null +++ b/tests/test_cyme/test_cyme_reader.py @@ -0,0 +1,50 @@ +""" Module for testing parsers.""" +from pathlib import Path +import pytest +from ditto.readers.cyme.reader import Reader +from ditto.writers.opendss.write import Writer +import sys +from loguru import logger + +logger.add(sys.stderr, level="WARNING") + +base_path = Path(__file__).parent.parent +cyme_circuit_models = base_path / "data" / "cyme_test_cases" +assert cyme_circuit_models.exists, f"{cyme_circuit_models} does not exist" + +# Require all models to be called Model.mdb and Equipment.mdb for testing + +cyme_network_name = "Network.txt" +cyme_equipment_name = "Equipment.txt" +cyme_load_name = "Load.txt" + +target_files = set([cyme_network_name, cyme_equipment_name]) +matching_folders = [] +for folder in Path(cyme_circuit_models).rglob("*"): + if folder.is_dir(): + files_in_folder = set(f.name for f in folder.iterdir() if f.is_file()) + if target_files.issubset(files_in_folder): + matching_folders.append(folder) + + +@pytest.mark.parametrize("cyme_folder", matching_folders) +def test_cyme_reader(cyme_folder: Path, tmp_path): + export_path = base_path / "dump_from_tests" / "cyme" / cyme_folder.name + if not export_path.exists(): + export_path.mkdir(parents=True, exist_ok=True) + + load_model_id = "1" + reader = Reader( + cyme_folder / cyme_network_name, + cyme_folder / cyme_equipment_name, + cyme_folder / cyme_load_name, + load_model_id, + ) + writer = Writer(reader.get_system()) + writer.write(export_path / "opendss", separate_substations=False, separate_feeders=False) + system = reader.get_system() + json_path = (export_path / cyme_folder.stem.lower()).with_suffix(".json") + system.to_json(json_path, overwrite=True, indent=4) + system.to_geojson(export_path / (cyme_folder.stem.lower() + ".geojson")) + + assert json_path.exists(), "Failed to export the json file"