diff --git a/src/ditto/readers/synergi/__init__.py b/src/ditto/readers/synergi/__init__.py index 694dfec..01928ac 100644 --- a/src/ditto/readers/synergi/__init__.py +++ b/src/ditto/readers/synergi/__init__.py @@ -1,3 +1,6 @@ +from ditto.readers.synergi.components.distribution_feeder import DistributionFeederMapper +from ditto.readers.synergi.components.distribution_vsource import DistributionVoltageSourceMapper +from ditto.readers.synergi.components.distribution_substation import DistributionSubstationMapper from ditto.readers.synergi.components.distribution_bus import DistributionBusMapper from ditto.readers.synergi.components.distribution_capacitor import DistributionCapacitorMapper from ditto.readers.synergi.components.distribution_load import DistributionLoadMapper @@ -5,4 +8,13 @@ from ditto.readers.synergi.equipment.distribution_transformer_equipment import DistributionTransformerEquipmentMapper from ditto.readers.synergi.equipment.conductor_equipment import ConductorEquipmentMapper from ditto.readers.synergi.equipment.geometry_branch_equipment import GeometryBranchEquipmentMapper -from ditto.readers.synergi.components.geometry_branch import GeometryBranchMapper +from ditto.readers.synergi.equipment.matrix_impedance_branch_equipment import MatrixImpedanceBranchEquipmentMapper +from ditto.readers.synergi.components.line_section import LineSectionMapper +from ditto.readers.synergi.components.matrix_impedance_switch import SwitchMapper, BreakerMapper +from ditto.readers.synergi.components.matrix_impedance_fuse import FuseMapper +from ditto.readers.synergi.components.matrix_impedance_recloser import RecloserMapper +from ditto.readers.synergi.components.solar import DistributionSolarMapper +from ditto.readers.synergi.components.generator import GeneratorMapper +from ditto.readers.synergi.components.battery import DistributionBatteryMapper +from ditto.readers.synergi.components.regulator import DistributionRegulatorMapper +from ditto.readers.synergi.equipment.generator_equipment import GeneratorEquipmentMapper diff --git a/src/ditto/readers/synergi/components/battery.py b/src/ditto/readers/synergi/components/battery.py new file mode 100644 index 0000000..711d04f --- /dev/null +++ b/src/ditto/readers/synergi/components/battery.py @@ -0,0 +1,137 @@ +from gdm.distribution.components.distribution_battery import DistributionBattery +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.enums import Phase, VoltageTypes +from gdm.distribution.equipment.battery_equipment import BatteryEquipment +from gdm.distribution.equipment.inverter_equipment import InverterEquipment +from gdm.quantities import ApparentPower, ReactivePower, EnergyDC +from infrasys.quantities import ActivePower, Voltage +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import parse_phases, phases_without_neutral, sanitize_name, safe_float +from loguru import logger + +_BATTERY_KW = {"battery", "batt"} + + +def _is_battery(gen_type: str, gen_equipment: dict) -> bool: + lower = gen_type.lower() + if any(kw in lower for kw in _BATTERY_KW): + return True + dev = gen_equipment.get(gen_type, {}) + return dev.get("GeneratorType", "").lower() == "battery" + + +class DistributionBatteryMapper(SynergiMapper): + """Maps InstGenerators rows classified as batteries to DistributionBattery components.""" + + synergi_table = "InstGenerators" + synergi_database = "Model" + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections, gen_equipment=None): + gen_equipment = gen_equipment or {} + gen_type = str(row.get("GeneratorType", "")).strip() + if not _is_battery(gen_type, gen_equipment): + return None + + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id) + if section is None: + return None + + bus = self.map_bus(row, section_id_sections) + if bus is None: + return None + + phases = self.map_phases(row, bus) + if not phases: + return None + + dev = gen_equipment.get(gen_type, {}) + kw_rating = self.map_kw_rating(dev) + kv_rating = self.map_kv_rating(dev) + batt_kwhr = self.map_energy(row, dev) + eff_dis = self.map_eff_discharge(dev) + eff_chg = self.map_eff_charge(dev) + + to_id = str(section.get("ToNodeId", "")).strip() + feeder, substation = self._lookup_feeder_substation(to_id) + + return DistributionBattery( + name=self.map_name(row), + bus=bus, + phases=phases, + equipment=self.map_equipment(row, kw_rating, kv_rating, batt_kwhr, eff_dis, eff_chg), + inverter=self.map_inverter(row, kw_rating, eff_dis), + active_power=self.map_active_power(row, kw_rating), + reactive_power=ReactivePower(0, "kilovar"), + controller=None, + substation=substation, + feeder=feeder, + ) + + def map_name(self, row): + device_id = str(row.get("UniqueDeviceId", row["SectionId"])).strip() + return f"batt_{sanitize_name(device_id)}" + + def map_bus(self, row, section_id_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + to_id = sanitize_name(str(section.get("ToNodeId", "")).strip()) + try: + return self.system.get_component(DistributionBus, to_id) + except Exception: + device_id = str(row.get("UniqueDeviceId", section_id)).strip() + logger.warning(f"Battery {device_id}: bus {to_id} not found") + return None + + def map_phases(self, row, bus): + phases = phases_without_neutral(parse_phases(str(row.get("ConnectedPhases", "ABC")))) + bus_phases = {p for p in bus.phases if p != Phase.N} + return [p for p in phases if p in bus_phases] + + def map_kw_rating(self, dev): + return safe_float(dev.get("KwRating"), 1000) or 1000 + + def map_kv_rating(self, dev): + return safe_float(dev.get("KvRating"), 13.2) or 13.2 + + def map_energy(self, row, dev): + kwhr = safe_float(row.get("BattRatedKwHr"), 0) or 0 + if kwhr <= 0: + kwhr = safe_float(dev.get("BattRatkWhr"), 4000) or 4000 + return max(kwhr, 1) + + def map_eff_discharge(self, dev): + return safe_float(dev.get("BattRatPctEffDis"), 84) or 84 + + def map_eff_charge(self, dev): + return safe_float(dev.get("BattRatPctEffChg"), 82) or 82 + + def map_equipment(self, row, kw_rating, kv_rating, batt_kwhr, eff_dis, eff_chg): + device_id = str(row.get("UniqueDeviceId", row["SectionId"])).strip() + return BatteryEquipment( + name=f"batt_equip_{sanitize_name(device_id)}", + rated_energy=EnergyDC(batt_kwhr, "kilowatthour"), + rated_power=ActivePower(kw_rating, "kilowatt"), + charging_efficiency=eff_chg, + discharging_efficiency=eff_dis, + idling_efficiency=99.0, + rated_voltage=Voltage(kv_rating, "kilovolt"), + voltage_type=VoltageTypes.LINE_TO_LINE, + ) + + def map_inverter(self, row, kw_rating, eff_dis): + device_id = str(row.get("UniqueDeviceId", row["SectionId"])).strip() + return InverterEquipment( + name=f"batt_inverter_{sanitize_name(device_id)}", + rated_apparent_power=ApparentPower(kw_rating, "kilova"), + rise_limit=None, + fall_limit=None, + dc_to_ac_efficiency=eff_dis, + cutin_percent=5.0, + cutout_percent=5.0, + eff_curve=None, + ) + + def map_active_power(self, row, kw_rating): + output_pct = safe_float(row.get("OutputPowerPercentage"), 100) or 100 + return ActivePower(kw_rating * output_pct / 100.0, "kilowatt") diff --git a/src/ditto/readers/synergi/components/distribution_bus.py b/src/ditto/readers/synergi/components/distribution_bus.py index 2813992..c178db9 100644 --- a/src/ditto/readers/synergi/components/distribution_bus.py +++ b/src/ditto/readers/synergi/components/distribution_bus.py @@ -1,76 +1,58 @@ +import math from infrasys.location import Location from gdm.distribution.components.distribution_bus import DistributionBus -from gdm import VoltageTypes, Phase, PositiveVoltage +from gdm.distribution.components.distribution_feeder import DistributionFeeder +from gdm.distribution.components.distribution_substation import DistributionSubstation +from gdm.distribution.enums import VoltageTypes, Phase +from gdm.quantities import Voltage from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import parse_phases, sort_phases, sanitize_name -class DistributionBusMapper(SynergiMapper): - def __init__(self, system): - super().__init__(system) +class DistributionBusMapper(SynergiMapper): synergi_table = "Node" synergi_database = "Model" def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): - name = self.map_name(row) - coordinate = self.map_coordinate(row) - nominal_voltage = self.map_nominal_voltage(row) - phases = self.map_phases(row, from_node_sections, to_node_sections) - voltage_limits = self.map_voltagelimits(row) - voltage_type = self.map_voltage_type(row) - return DistributionBus(name=name, - coordinate=coordinate, - nominal_voltage=nominal_voltage, - phases=phases, - voltagelimits=voltage_limits, - voltage_type=voltage_type) - - def map_name(self, row): - name = row["NodeId"] - return name + node_id = str(row["NodeId"]).strip() + feeder_info = self.node_feeder_map.get(node_id, {}) + + feeder = None + substation = None + if feeder_info: + try: + feeder = self.system.get_component(DistributionFeeder, name=sanitize_name(feeder_info["feeder_id"])) + except Exception: + pass + try: + substation = self.system.get_component(DistributionSubstation, name=sanitize_name(feeder_info["sub_id"])) + except Exception: + pass + + return DistributionBus( + name=sanitize_name(node_id), + coordinate=self.map_coordinate(row), + rated_voltage=self.map_nominal_voltage(feeder_info), + phases=self.map_phases(node_id, from_node_sections, to_node_sections), + voltagelimits=[], + voltage_type=VoltageTypes.LINE_TO_GROUND, + substation=substation, + feeder=feeder, + ) def map_coordinate(self, row): - X, Y = row["X"], row["Y"] - #crs = SAI_Control.ProjectionWKT - crs = None - location = Location(x = X, y = Y, crs = crs) - return location - - # Nominal voltage is only defined by transformers - def map_nominal_voltage(self, row): - return PositiveVoltage(12.47, "kilovolts") - - def map_phases(self, row, from_node_sections, to_node_sections): - node_id = row["NodeId"] - section = None - all_phases = set() - if node_id in from_node_sections: - for section in from_node_sections[node_id]: - phases = section["SectionPhases"].replace(" ","") - 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["SectionPhases"].replace(" ","") - for phase in phases: - all_phases.add(phase) - - all_phases = sorted(list(all_phases)) - phases = [] - if "A" in all_phases: - phases.append(Phase.A) - if "B" in all_phases: - phases.append(Phase.B) - if "C" in all_phases: - phases.append(Phase.C) - if "N" in all_phases: - phases.append(Phase.N) - - return phases - - def map_voltagelimits(self, row): - return [] - - def map_voltage_type(self, row): - return VoltageTypes.LINE_TO_LINE.value + return Location(x=row["X"], y=row["Y"]) + + def map_nominal_voltage(self, feeder_info: dict) -> Voltage: + nominal_kvll = feeder_info.get("nominal_kvll", 12.47) or 12.47 + return Voltage(nominal_kvll / math.sqrt(3), "kilovolt") + + def map_phases(self, node_id, from_node_sections, to_node_sections): + all_phases: set[Phase] = set() + for section in from_node_sections.get(node_id, []): + all_phases.update(parse_phases(section["SectionPhases"])) + for section in to_node_sections.get(node_id, []): + all_phases.update(parse_phases(section["SectionPhases"])) + return sort_phases(all_phases) diff --git a/src/ditto/readers/synergi/components/distribution_capacitor.py b/src/ditto/readers/synergi/components/distribution_capacitor.py index fb0a45b..068df84 100644 --- a/src/ditto/readers/synergi/components/distribution_capacitor.py +++ b/src/ditto/readers/synergi/components/distribution_capacitor.py @@ -1,72 +1,67 @@ from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import parse_phases, phases_without_neutral, sanitize_name from ditto.readers.synergi.equipment.capacitor_equipment import CapacitorEquipmentMapper from gdm.distribution.components.distribution_bus import DistributionBus from gdm.distribution.components.distribution_capacitor import DistributionCapacitor -from gdm import Phase +from gdm.distribution.enums import Phase from loguru import logger + class DistributionCapacitorMapper(SynergiMapper): - def __init__(self, system): - super().__init__(system) synergi_table = "InstCapacitors" synergi_database = "Model" def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): - name = self.map_name(row) - bus = self.map_bus(row, section_id_sections) - phases = self.map_phases(row) - controllers = self.map_controllers(row) - equipment = self.map_equipment(row) - return DistributionCapacitor(name=name, - bus=bus, - phases=phases, - controllers=controllers, - equipment=equipment) + section_id = str(row.get("SectionId", "")).strip() + section = section_id_sections.get(section_id) + if section is None: + logger.warning(f"Capacitor {section_id}: section not found") + return None - def map_name(self, row): - return row["UniqueDeviceId"] + bus = self.map_bus(section_id, section) + if bus is None: + return None - def map_phases(self, row): - phase_info = row["ConnectedPhases"] - phases = [] - for phase in phase_info: - if phase == "A": - phases.append(Phase.A) - if phase == "B": - phases.append(Phase.B) - if phase == "C": - phases.append(Phase.C) - return phases + phases = self.map_phases(row, bus) + if not phases: + return None - 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 - try: - from_bus = self.system.get_component(component_type=DistributionBus,name=from_bus_name) - except Exception as e: - pass + to_node_id = str(section.get("ToNodeId", "")).strip() + feeder, substation = self._lookup_feeder_substation(to_node_id) - try: - to_bus = self.system.get_component(component_type=DistributionBus,name=to_bus_name) - except: - pass + return DistributionCapacitor( + name=self.map_name(row), + bus=bus, + phases=phases, + controllers=[], + equipment=self.map_equipment(row, phases), + in_service=True, + feeder=feeder, + substation=substation, + ) - if from_bus is None: - return to_bus - if from_bus is None: - logger.warning(f"Load {section_id} has no bus") - return from_bus + def map_name(self, row): + device_id = str(row.get("UniqueDeviceId", row.get("SectionId", ""))).strip() + return sanitize_name(f"cap_{device_id}") + def map_bus(self, section_id, section): + to_id = sanitize_name(str(section.get("ToNodeId", "")).strip()) + from_id = sanitize_name(str(section.get("FromNodeId", "")).strip()) + for bus_name in (to_id, from_id): + try: + return self.system.get_component(DistributionBus, bus_name) + except Exception: + pass + logger.warning(f"Capacitor {section_id}: bus not found (tried {to_id}, {from_id})") + return None - def map_controllers(self, row): - return [] + def map_phases(self, row, bus): + phases = phases_without_neutral(parse_phases(str(row.get("ConnectedPhases", "ABC")))) + if not phases: + phases = [p for p in bus.phases if p != Phase.N] + bus_phases = {p for p in bus.phases if p != Phase.N} + return [p for p in phases if p in bus_phases] - def map_equipment(self, row): - mapper = CapacitorEquipmentMapper(self.system) - equipment = mapper.parse(row) - return equipment + def map_equipment(self, row, phases): + return CapacitorEquipmentMapper(self.system).parse(row, phases) diff --git a/src/ditto/readers/synergi/components/distribution_feeder.py b/src/ditto/readers/synergi/components/distribution_feeder.py new file mode 100644 index 0000000..5666426 --- /dev/null +++ b/src/ditto/readers/synergi/components/distribution_feeder.py @@ -0,0 +1,13 @@ +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import sanitize_name +from gdm.distribution.components.distribution_feeder import DistributionFeeder + + +class DistributionFeederMapper(SynergiMapper): + + synergi_table = "InstFeeders" + synergi_database = "Model" + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): + fid = str(row["FeederId"]).strip() + return DistributionFeeder(name=sanitize_name(fid)) diff --git a/src/ditto/readers/synergi/components/distribution_load.py b/src/ditto/readers/synergi/components/distribution_load.py index e826e4e..12443f3 100644 --- a/src/ditto/readers/synergi/components/distribution_load.py +++ b/src/ditto/readers/synergi/components/distribution_load.py @@ -1,13 +1,12 @@ from ditto.readers.synergi.synergi_mapper import SynergiMapper from ditto.readers.synergi.equipment.load_equipment import LoadEquipmentMapper +from ditto.readers.synergi.utils import sanitize_name, safe_float from gdm.distribution.components.distribution_bus import DistributionBus from gdm.distribution.components.distribution_load import DistributionLoad -from gdm import Phase +from gdm.distribution.enums import Phase from loguru import logger class DistributionLoadMapper(SynergiMapper): - def __init__(self, system): - super().__init__(system) synergi_table = "Loads" synergi_database = "Model" @@ -15,62 +14,67 @@ def __init__(self, system): def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): name = self.map_name(row) + section = section_id_sections.get(str(row["SectionId"]).strip(), {}) bus = self.map_bus(row, section_id_sections) - phases = self.map_phases(row) - equipment = self.map_equipment(row) + if bus is None: + return None + phases = self.map_phases(row, bus) if len(phases) == 0: logger.warning(f"Load {name} has no kW values. Skipping...") return None + z, i, p = self.map_zip(row, section) + equipment = self.map_equipment(row, z, i, p) + feeder, substation = self._lookup_feeder_substation(str(section.get("FromNodeId", "")).strip()) return DistributionLoad(name=name, bus=bus, phases=phases, - equipment=equipment) + equipment=equipment, + substation=substation, + feeder=feeder) def map_name(self, row): - return row["SectionId"] + return sanitize_name(f"load_{row['SectionId']}") 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 - try: - from_bus = self.system.get_component(component_type=DistributionBus,name=from_bus_name) - except Exception as e: - pass - - try: - to_bus = self.system.get_component(component_type=DistributionBus,name=to_bus_name) - except: - pass + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + for node_key in ("ToNodeId", "FromNodeId"): + bus_name = sanitize_name(str(section.get(node_key, "")).strip()) + try: + return self.system.get_component(DistributionBus, bus_name) + except Exception: + pass + logger.warning(f"Load {section_id}: no bus found, skipping") + return None - if from_bus is None: - return to_bus - if from_bus is None: - logger.warning(f"Load {section_id} has no bus") - return from_bus - - def map_phases(self, row): - nonzero_phases = [] - for phase in range(1,4): - kw_column = f"Phase{phase}Kw" - if row[kw_column] > 0: - nonzero_phases.append(phase) + def map_phases(self, row, bus): + bus_wire_phases = {ph for ph in bus.phases if ph != Phase.N} + phase_map = {1: Phase.A, 2: Phase.B, 3: Phase.C} phases = [] - for phase in nonzero_phases: - if phase == 1: - phases.append(Phase.A) - if phase == 2: - phases.append(Phase.B) - if phase == 3: - phases.append(Phase.C) + for ph_idx, phase in phase_map.items(): + kw = safe_float(row.get(f"Phase{ph_idx}Kw"), 0.0) + kvar = safe_float(row.get(f"Phase{ph_idx}Kvar"), 0.0) + customers = safe_float(row.get(f"Phase{ph_idx}Customers"), 0.0) + if (kw != 0.0 or kvar != 0.0 or customers > 0) and phase in bus_wire_phases: + phases.append(phase) return phases - def map_equipment(self, row): + def map_zip(self, row, section): + is_spot = bool(row.get("IsSpotLoad", False)) + if is_spot: + pct_z = safe_float(section.get("PercentSpotLoadConstImpedance"), 0.0) + pct_i = safe_float(section.get("PercentSpotLoadConstCurrent"), 0.0) + else: + pct_z = safe_float(section.get("PercentDistLoadConstImpedance"), 0.0) + pct_i = safe_float(section.get("PercentDistLoadConstCurrent"), 0.0) + z = max(0.0, min(1.0, pct_z / 100.0)) + i = max(0.0, min(1.0, pct_i / 100.0)) + p = max(0.0, 1.0 - z - i) + return z, i, p + + def map_equipment(self, row, z, i, p): mapper = LoadEquipmentMapper(self.system) - equipment = mapper.parse(row) + equipment = mapper.parse(row, z, i, p) return equipment diff --git a/src/ditto/readers/synergi/components/distribution_substation.py b/src/ditto/readers/synergi/components/distribution_substation.py new file mode 100644 index 0000000..80992a5 --- /dev/null +++ b/src/ditto/readers/synergi/components/distribution_substation.py @@ -0,0 +1,34 @@ +from collections import defaultdict + +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import sanitize_name +from gdm.distribution.components.distribution_feeder import DistributionFeeder +from gdm.distribution.components.distribution_substation import DistributionSubstation + + +class DistributionSubstationMapper(SynergiMapper): + + synergi_table = "InstFeeders" + synergi_database = "Model" + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): + # Substations need all their feeders at once — use parse_all() instead. + return None + + def parse_all(self, table_data, unit_type, section_id_sections, from_node_sections, to_node_sections): + """Create DistributionSubstation objects grouped by SubstationId.""" + sub_feeders: dict[str, list[DistributionFeeder]] = defaultdict(list) + + for _, row in table_data.iterrows(): + fid = sanitize_name(str(row["FeederId"]).strip()) + sub_id = str(row.get("SubstationId", "Unknown") or "Unknown").strip() + try: + feeder = self.system.get_component(DistributionFeeder, name=fid) + sub_feeders[sub_id].append(feeder) + except Exception: + pass + + return [ + DistributionSubstation(name=sanitize_name(sub_id), feeders=feeders) + for sub_id, feeders in sub_feeders.items() + ] diff --git a/src/ditto/readers/synergi/components/distribution_transformer.py b/src/ditto/readers/synergi/components/distribution_transformer.py index 966ee7e..c37cfc3 100644 --- a/src/ditto/readers/synergi/components/distribution_transformer.py +++ b/src/ditto/readers/synergi/components/distribution_transformer.py @@ -1,82 +1,78 @@ +import math + from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import parse_phases, phases_without_neutral, sanitize_name from gdm.distribution.equipment.distribution_transformer_equipment import DistributionTransformerEquipment from gdm.distribution.components.distribution_transformer import DistributionTransformer from gdm.distribution.components.distribution_bus import DistributionBus - -from gdm import Phase, ConnectionType +from gdm.distribution.enums import Phase, ConnectionType, VoltageTypes +from gdm.quantities import Voltage from loguru import logger + class DistributionTransformerMapper(SynergiMapper): - def __init__(self, system): - super().__init__(system) - - synergi_table = "InstDTrans" + + synergi_table = "InstPrimaryTransformers" synergi_database = "Model" def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): + equipment = self.map_equipment(row) + if equipment is None: + return None name = self.map_name(row) buses = self.map_bus(row, section_id_sections) + if buses[0] is None or buses[1] is None: + return None winding_phases = self.map_winding_phases(row) - equipment = self.map_equipment(row) - - # Set the voltages for the buses - voltage_1 = round(equipment.windings[0].nominal_voltage,5) - voltage_2 = round(equipment.windings[1].nominal_voltage,5) + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + from_node = str(section.get("FromNodeId", "")).strip() + feeder, substation = self._lookup_feeder_substation(from_node) - buses[0].nominal_voltage = voltage_1 - buses[1].nominal_voltage = voltage_2 + for bus, wdg in zip(buses, equipment.windings): + kv = wdg.rated_voltage.to("kilovolt").magnitude + if wdg.voltage_type == VoltageTypes.LINE_TO_LINE: + kv = kv / math.sqrt(3) + bus.rated_voltage = Voltage(round(kv, 5), "kilovolt") - return DistributionTransformer(name=name, - buses=buses, - winding_phases=winding_phases, - equipment=equipment) + return DistributionTransformer( + name=name, + buses=buses, + winding_phases=winding_phases, + equipment=equipment, + substation=substation, + feeder=feeder, + ) def map_name(self, row): - return row["DTranId"] + device_id = str(row.get("UniqueDeviceId", row["SectionId"])).strip() + return sanitize_name(device_id) - def map_winding_phases(self,row): - input_phases = row["ConnPhases"].replace(" ","") - winding_phases = [] - # Assume 2 windings - for i in range(1,3): - phases = [] - if 'A' in input_phases: - phases.append(Phase.A) - if 'B' in input_phases: - phases.append(Phase.B) - if 'C' in input_phases: - phases.append(Phase.C) - winding_phases.append(phases) - return winding_phases + def map_winding_phases(self, row): + phases = phases_without_neutral(parse_phases(row["ConnectedPhases"])) + return [phases, phases] def map_bus(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"] - to_bus = None + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + from_bus_name = sanitize_name(str(section.get("FromNodeId", "")).strip()) + to_bus_name = sanitize_name(str(section.get("ToNodeId", "")).strip()) from_bus = None + to_bus = None try: - from_bus = self.system.get_component(component_type=DistributionBus,name=from_bus_name) - except Exception as e: - pass - + from_bus = self.system.get_component(component_type=DistributionBus, name=from_bus_name) + except Exception: + logger.warning(f"Transformer {section_id}: from bus {from_bus_name} not found") try: - to_bus = self.system.get_component(component_type=DistributionBus,name=to_bus_name) - except: - pass - - if from_bus is None: - logger.warning(f"Transformer {section_id} has no from bus") - if from_bus is None: - logger.warning(f"Transformer {section_id} has no to bus") - return [from_bus,to_bus] + to_bus = self.system.get_component(component_type=DistributionBus, name=to_bus_name) + except Exception: + logger.warning(f"Transformer {section_id}: to bus {to_bus_name} not found") + return [from_bus, to_bus] def map_equipment(self, row): - equipment_name = row["TransformerType"] + equipment_name = str(row["TransformerType"]).strip() try: - equipment = self.system.get_component(component_type=DistributionTransformerEquipment, name=equipment_name) - except Exception as e: - logger.warning(f"Equipment {equipment_name} not found. Skipping") + return self.system.get_component(component_type=DistributionTransformerEquipment, name=equipment_name) + except Exception: + logger.warning(f"Transformer equipment {equipment_name!r} not found, skipping transformer") return None - return equipment diff --git a/src/ditto/readers/synergi/components/distribution_vsource.py b/src/ditto/readers/synergi/components/distribution_vsource.py new file mode 100644 index 0000000..619c345 --- /dev/null +++ b/src/ditto/readers/synergi/components/distribution_vsource.py @@ -0,0 +1,118 @@ +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.components.distribution_vsource import DistributionVoltageSource +from gdm.distribution.enums import Phase, VoltageTypes +from gdm.distribution.equipment.voltagesource_equipment import VoltageSourceEquipment +from gdm.distribution.equipment.phase_voltagesource_equipment import PhaseVoltageSourceEquipment +from gdm.quantities import Reactance +from infrasys.quantities import Voltage, Resistance, Angle +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import sanitize_name, safe_float +from loguru import logger + +_DEFAULT_V_PU = 1.02 # Synergi treats 120V on 120V base as "unset"; use 1.02 pu + + +class DistributionVoltageSourceMapper(SynergiMapper): + + synergi_table = "InstFeeders" + synergi_database = "Model" + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): + feeder_id = str(row["FeederId"]).strip() + + head_node_id = self.map_head_node(feeder_id, section_id_sections) + if head_node_id is None: + logger.warning(f"VSource {feeder_id}: no head node found, skipping") + return None + + bus = self.map_bus(head_node_id) + if bus is None: + return None + + feeder, substation = self._lookup_feeder_substation(head_node_id) + equipment = self.map_equipment(row, feeder_id) + + return DistributionVoltageSource( + name=sanitize_name(f"vsource_{feeder_id}"), + bus=bus, + phases=[Phase.A, Phase.B, Phase.C], + equipment=equipment, + substation=substation, + feeder=feeder, + ) + + def map_head_node(self, feeder_id, section_id_sections): + """Return the physical source bus node ID for this feeder. + + Mirrors synergi_converter's FeederTopology._find_head_node(): + 1. If the feeder name itself is a virtual head node (appears as FromNodeId + but never as ToNodeId), the source bus is its one downstream neighbour. + 2. Single candidate -> use it directly. + 3. Multiple candidates -> pick by most outgoing sections. + """ + feeder_sections = [ + s for s in section_id_sections.values() + if str(s.get("FeederId", "")).strip() == feeder_id + ] + from_ids = {str(s["FromNodeId"]).strip() for s in feeder_sections} + to_ids = {str(s["ToNodeId"]).strip() for s in feeder_sections} + candidates = from_ids - to_ids + if not candidates: + return None + + # Case 1: feeder name is a virtual head node + if feeder_id in candidates: + for s in feeder_sections: + if str(s["FromNodeId"]).strip() == feeder_id: + return str(s["ToNodeId"]).strip() + + # Case 2: single physical candidate + if len(candidates) == 1: + return next(iter(candidates)) + + # Case 3: multiple candidates — pick most connected + from_count = {n: sum(1 for s in feeder_sections if str(s["FromNodeId"]).strip() == n) + for n in candidates} + best = max(from_count, key=from_count.get) + logger.warning(f"Feeder {feeder_id}: {len(candidates)} head candidates, chose {best!r}") + return best + + def map_bus(self, head_node_id): + bus_name = sanitize_name(head_node_id) + try: + return self.system.get_component(DistributionBus, bus_name) + except Exception: + logger.warning(f"VSource: head bus {bus_name!r} not found, skipping") + return None + + def map_equipment(self, row, feeder_id): + nominal_kvll = safe_float(row.get("NominalKvll"), 12.47) + r1 = safe_float(row.get("PosSequenceResistance"), 0.1) + x1 = safe_float(row.get("PosSequenceReactance"), 0.5) + r0 = safe_float(row.get("ZeroSequenceResistance"), r1) + x0 = safe_float(row.get("ZeroSequenceReactance"), x1) + + raw = [ + (Phase.A, safe_float(row.get("ByPhVoltLevelPh1"), 120.0), safe_float(row.get("ByPhVoltDegPh1"), 0.0)), + (Phase.B, safe_float(row.get("ByPhVoltLevelPh2"), 120.0), safe_float(row.get("ByPhVoltDegPh2"), -120.0)), + (Phase.C, safe_float(row.get("ByPhVoltLevelPh3"), 120.0), safe_float(row.get("ByPhVoltDegPh3"), 120.0)), + ] + + phase_sources = [] + for phase, v120, angle in raw: + v_pu = _DEFAULT_V_PU if v120 == 120.0 else v120 / 120.0 + phase_sources.append(PhaseVoltageSourceEquipment( + name=sanitize_name(f"vsrc_{feeder_id}_{phase.value}"), + r1=Resistance(r1, "ohm"), + x1=Reactance(x1, "ohm"), + r0=Resistance(r0, "ohm"), + x0=Reactance(x0, "ohm"), + voltage=Voltage(nominal_kvll * v_pu, "kilovolt"), + voltage_type=VoltageTypes.LINE_TO_LINE, + angle=Angle(angle, "degree"), + )) + + return VoltageSourceEquipment( + name=sanitize_name(f"vsrc_equip_{feeder_id}"), + sources=phase_sources, + ) diff --git a/src/ditto/readers/synergi/components/generator.py b/src/ditto/readers/synergi/components/generator.py new file mode 100644 index 0000000..dcef670 --- /dev/null +++ b/src/ditto/readers/synergi/components/generator.py @@ -0,0 +1,107 @@ +from gdm.distribution.components.distribution_solar import DistributionSolar +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.enums import Phase, VoltageTypes +from gdm.distribution.equipment.solar_equipment import SolarEquipment +from gdm.distribution.equipment.inverter_equipment import InverterEquipment +from gdm.quantities import ApparentPower, ReactivePower, Irradiance +from infrasys.quantities import ActivePower +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import parse_phases, phases_without_neutral, sanitize_name, safe_float +from loguru import logger + + +class GeneratorMapper(SynergiMapper): + """Maps InstGenerators rows to DistributionSolar. + + All generator types are modelled as solar for now since distributed + solar is the only DG type in scope. Equipment specs are looked up + from GeneratorEquipmentMapper-populated SolarEquipment objects. + """ + + synergi_table = "InstGenerators" + synergi_database = "Model" + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id) + if section is None: + return None + + bus = self.map_bus(row, section_id_sections) + if bus is None: + return None + + phases = self.map_phases(row, bus) + if not phases: + return None + + solar_equip = self.map_equipment(row) + if solar_equip is None: + return None + + to_id = str(section.get("ToNodeId", "")).strip() + feeder, substation = self._lookup_feeder_substation(to_id) + + return DistributionSolar( + name=self.map_name(row), + bus=bus, + phases=phases, + equipment=solar_equip, + inverter=self.map_inverter(row, solar_equip), + irradiance=Irradiance(1000, "watt/meter**2"), + active_power=self.map_active_power(row, solar_equip), + reactive_power=ReactivePower(0, "kilovar"), + controller=None, + substation=substation, + feeder=feeder, + ) + + def map_name(self, row): + device_id = str(row.get("UniqueDeviceId", row["SectionId"])).strip() + return f"solar_{sanitize_name(device_id)}" + + def map_bus(self, row, section_id_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + to_id = sanitize_name(str(section.get("ToNodeId", "")).strip()) + try: + return self.system.get_component(DistributionBus, to_id) + except Exception: + device_id = str(row.get("UniqueDeviceId", section_id)).strip() + logger.warning(f"Generator {device_id}: bus {to_id} not found") + return None + + def map_phases(self, row, bus): + phases = phases_without_neutral(parse_phases(str(row.get("ConnectedPhases", "ABC")))) + bus_phases = {p for p in bus.phases if p != Phase.N} + return [p for p in phases if p in bus_phases] + + def map_equipment(self, row): + gen_type = str(row.get("GeneratorType", "")).strip() + equip_name = f"solar_equip_{sanitize_name(gen_type)}" + try: + return self.system.get_component(SolarEquipment, equip_name) + except Exception: + logger.warning(f"Generator equipment {gen_type!r} not found in system, skipping generator") + return None + + def map_inverter(self, row, solar_equip): + device_id = str(row.get("UniqueDeviceId", row["SectionId"])).strip() + kw_rating = solar_equip.rated_power.to("kilowatt").magnitude + pf_pct = safe_float(row.get("PQPowerFactorPercentage"), 95) or 95 + kva_rating = kw_rating / (pf_pct / 100.0) if pf_pct > 0 else kw_rating + return InverterEquipment( + name=f"inverter_{sanitize_name(device_id)}", + rated_apparent_power=ApparentPower(kva_rating, "kilova"), + rise_limit=None, + fall_limit=None, + dc_to_ac_efficiency=95.0, + cutin_percent=5.0, + cutout_percent=5.0, + eff_curve=None, + ) + + def map_active_power(self, row, solar_equip): + kw_rating = solar_equip.rated_power.to("kilowatt").magnitude + output_pct = safe_float(row.get("OutputPowerPercentage"), 100) or 100 + return ActivePower(kw_rating * output_pct / 100.0, "kilowatt") diff --git a/src/ditto/readers/synergi/components/geometry_branch.py b/src/ditto/readers/synergi/components/geometry_branch.py index 28c4c49..defbbad 100644 --- a/src/ditto/readers/synergi/components/geometry_branch.py +++ b/src/ditto/readers/synergi/components/geometry_branch.py @@ -1,10 +1,12 @@ from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import parse_phases, phases_without_neutral from gdm.distribution.components.geometry_branch import GeometryBranch from gdm.distribution.equipment.geometry_branch_equipment import GeometryBranchEquipment from gdm.distribution.components.distribution_bus import DistributionBus from gdm.distribution.equipment.bare_conductor_equipment import BareConductorEquipment from gdm.distribution.equipment.concentric_cable_equipment import ConcentricCableEquipment -from gdm import PositiveDistance, Phase +from gdm.distribution.enums import Phase +from gdm.quantities import Distance from loguru import logger from ditto.readers.synergi.length_units import length_units @@ -27,8 +29,8 @@ def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node length=length, phases=phases, equipment=equipment) - except: - import pdb;pdb.set_trace() + except Exception as e: + logger.warning(f"Failed to parse geometry branch: {e}") def map_name(self, row): return row["SectionId"] @@ -59,22 +61,10 @@ def map_buses(self, row, section_id_sections): def map_length(self, row, unit_type): unit = length_units[unit_type]["MUL"] length = row["SectionLength_MUL"] - return PositiveDistance(length, unit).to("m") + return Distance(length, unit).to("m") def map_phases(self, row): - input_phases = row["SectionPhases"].replace(" ","") - phases = [] - for i in range(1,3): - phases = [] - if 'A' in input_phases: - phases.append(Phase.A) - if 'B' in input_phases: - phases.append(Phase.B) - if 'C' in input_phases: - phases.append(Phase.C) - if 'N' in input_phases: - phases.append(Phase.N) - return phases + return parse_phases(row["SectionPhases"]) def map_equipment(self, row): @@ -200,9 +190,8 @@ def map_conductors(self, row): conductors.append(bare_equipment) elif concentric_equipment is not None: conductors.append(concentric_equipment) - else: + else: logger.warning(f"Conductor {conductor} not found. Skipping") - import pdb;pdb.set_trace() return conductors diff --git a/src/ditto/readers/synergi/components/line_section.py b/src/ditto/readers/synergi/components/line_section.py new file mode 100644 index 0000000..5fd254c --- /dev/null +++ b/src/ditto/readers/synergi/components/line_section.py @@ -0,0 +1,127 @@ +import re + +from gdm.distribution.components.geometry_branch import GeometryBranch +from gdm.distribution.components.matrix_impedance_branch import MatrixImpedanceBranch +from gdm.distribution.equipment.geometry_branch_equipment import GeometryBranchEquipment +from gdm.distribution.equipment.matrix_impedance_branch_equipment import MatrixImpedanceBranchEquipment +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.quantities import Distance +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import parse_phases, phases_without_neutral, sanitize_name, safe_float +from ditto.readers.synergi.length_units import length_units +from loguru import logger + + +class LineSectionMapper(SynergiMapper): + + synergi_table = "InstSection" + synergi_database = "Model" + + def __init__(self, system, node_feeder_map=None): + super().__init__(system, node_feeder_map) + self._matrix_equip_cache = None + + def _build_matrix_equip_cache(self): + cache = {} + for equip in self.system.get_components(MatrixImpedanceBranchEquipment): + m = re.search(r'_(\d+)ph$', equip.name) + if not m: + continue + n_ph = int(m.group(1)) + base = equip.name[: equip.name.rfind(f'_{n_ph}ph')] + key = (base.lower(), n_ph) + if key not in cache: + cache[key] = equip + return cache + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections, devices_on_section=None): + section_id = str(row["SectionId"]).strip() + if devices_on_section and section_id in devices_on_section: + return None + from_node_id = str(row["FromNodeId"]).strip() + to_node_id = str(row["ToNodeId"]).strip() + + try: + from_bus = self.system.get_component(DistributionBus, sanitize_name(from_node_id)) + except Exception: + logger.warning(f"Section {section_id}: from bus {from_node_id} not found") + return None + + try: + to_bus = self.system.get_component(DistributionBus, sanitize_name(to_node_id)) + except Exception: + logger.warning(f"Section {section_id}: to bus {to_node_id} not found") + return None + + phases = parse_phases(row["SectionPhases"]) + wire_phases = phases_without_neutral(phases) + if not wire_phases: + logger.warning(f"Section {section_id}: no wire phases found") + return None + + length_val = safe_float(row.get("SectionLength_MUL"), 1.0) or 1.0 + length = Distance(length_val, length_units[unit_type]["MUL"]).to("m") + feeder, substation = self._lookup_feeder_substation(from_node_id) + + geometry_equip = self._find_geometry_equipment(row, phases) + if geometry_equip is not None: + return GeometryBranch( + name=sanitize_name(section_id), + buses=[from_bus, to_bus], + length=length, + phases=phases, + equipment=geometry_equip, + substation=substation, + feeder=feeder, + ) + + matrix_equip = self._find_matrix_equipment(row, wire_phases) + if matrix_equip is None: + logger.warning(f"Section {section_id}: no equipment found, skipping") + return None + + return MatrixImpedanceBranch( + name=sanitize_name(section_id), + buses=[from_bus, to_bus], + length=length, + phases=wire_phases, + equipment=matrix_equip, + substation=substation, + feeder=feeder, + ) + + def _find_geometry_equipment(self, row, phases): + config_id = str(row.get("ConfigurationId", "")).strip() + if not config_id or config_id == "Unknown": + return None + + conductor_names = [] + for phase_char in str(row["SectionPhases"]).replace(" ", ""): + if phase_char == "N": + conductor_names.append(str(row["NeutralConductorId"]).strip()) + else: + conductor_names.append(str(row["PhaseConductorId"]).strip()) + + equip_name = config_id + "_" + "_".join(conductor_names) + try: + equip = self.system.get_component(GeometryBranchEquipment, equip_name) + if len(equip.horizontal_positions) == len(phases): + return equip + logger.warning( + f"Section {row['SectionId']}: geometry {config_id} has wrong position count " + f"({len(equip.horizontal_positions)} vs {len(phases)} phases), falling back to matrix impedance" + ) + except Exception: + logger.warning( + f"Section {row['SectionId']}: geometry {config_id} not found, falling back to matrix impedance" + ) + return None + + def _find_matrix_equipment(self, row, wire_phases): + if self._matrix_equip_cache is None: + self._matrix_equip_cache = self._build_matrix_equip_cache() + conductor_name = str(row["PhaseConductorId"]).strip() + n_cond = len(wire_phases) + base_key = sanitize_name(conductor_name).lower() + return self._matrix_equip_cache.get((base_key, n_cond)) + diff --git a/src/ditto/readers/synergi/components/matrix_impedance_fuse.py b/src/ditto/readers/synergi/components/matrix_impedance_fuse.py new file mode 100644 index 0000000..02075da --- /dev/null +++ b/src/ditto/readers/synergi/components/matrix_impedance_fuse.py @@ -0,0 +1,100 @@ +import numpy as np +from gdm.distribution.components.matrix_impedance_fuse import MatrixImpedanceFuse +from gdm.distribution.equipment.matrix_impedance_fuse_equipment import MatrixImpedanceFuseEquipment +from gdm.distribution.common.curve import TimeCurrentCurve +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.enums import LineType +from gdm.quantities import ResistancePULength, ReactancePULength, CapacitancePULength +from infrasys.quantities import Current, Distance, Time +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import parse_phases, phases_without_neutral, sanitize_name, safe_float +from loguru import logger + + +class FuseMapper(SynergiMapper): + + synergi_table = "InstFuses" + synergi_database = "Model" + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id) + if section is None: + return None + buses = self.map_buses(row, section_id_sections) + if buses[0] is None or buses[1] is None: + return None + phases = self.map_phases(row, section_id_sections) + if not phases: + return None + amp_rating = self.map_amp_rating(row) + feeder, substation = self._lookup_feeder_substation(str(section["FromNodeId"]).strip()) + return MatrixImpedanceFuse( + name=self.map_name(row), + buses=buses, + length=self.map_length(), + phases=phases, + is_closed=self.map_is_closed(row, phases), + equipment=self.map_equipment(row, phases, amp_rating), + substation=substation, + feeder=feeder, + ) + + def map_name(self, row): + section_id = str(row["SectionId"]).strip() + device_id = str(row.get("UniqueDeviceId", section_id)).strip() + return sanitize_name(f"fuse_{device_id}_{section_id}") + + def map_buses(self, row, section_id_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + from_bus = None + to_bus = None + try: + from_bus = self.system.get_component(DistributionBus, sanitize_name(str(section["FromNodeId"]).strip())) + except Exception: + logger.warning(f"Fuse {section_id}: from bus not found") + try: + to_bus = self.system.get_component(DistributionBus, sanitize_name(str(section["ToNodeId"]).strip())) + except Exception: + logger.warning(f"Fuse {section_id}: to bus not found") + return [from_bus, to_bus] + + def map_phases(self, row, section_id_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + phases = phases_without_neutral(parse_phases(section.get("SectionPhases", ""))) + return phases or parse_phases(section.get("SectionPhases", "")) + + def map_is_closed(self, row, phases): + is_open = bool(safe_float(row.get("FuseIsOpen", 0))) + return [not is_open] * len(phases) + + def map_amp_rating(self, row): + return safe_float(row.get("AmpRating"), 200.0) or 200.0 + + def map_equipment(self, row, phases, amp_rating): + section_id = str(row["SectionId"]).strip() + device_id = str(row.get("UniqueDeviceId", section_id)).strip() + n = len(phases) + r = np.eye(n) * 1e-4 + x = np.eye(n) * 1e-4 + c = np.eye(n) * 1e-3 + tcc = TimeCurrentCurve( + name=sanitize_name(f"tcc_fuse_{device_id}_{section_id}"), + curve_x=Current(np.array([amp_rating * m for m in [1.5, 2.0, 5.0, 10.0]]), "ampere"), + curve_y=Time(np.array([300.0, 10.0, 0.1, 0.01]), "second"), + ) + return MatrixImpedanceFuseEquipment( + name=sanitize_name(f"fuse_equip_{device_id}_{section_id}"), + construction=LineType.OVERHEAD, + r_matrix=ResistancePULength(r, "ohm/km"), + x_matrix=ReactancePULength(x, "ohm/km"), + c_matrix=CapacitancePULength(c, "nF/km"), + ampacity=Current(amp_rating, "ampere"), + delay=Time(0.01, "second"), + tcc_curve=tcc, + ) + + def map_length(self): + return Distance(1, "m") diff --git a/src/ditto/readers/synergi/components/matrix_impedance_recloser.py b/src/ditto/readers/synergi/components/matrix_impedance_recloser.py new file mode 100644 index 0000000..f688689 --- /dev/null +++ b/src/ditto/readers/synergi/components/matrix_impedance_recloser.py @@ -0,0 +1,126 @@ +import numpy as np +from gdm.distribution.components.matrix_impedance_recloser import MatrixImpedanceRecloser +from gdm.distribution.equipment.matrix_impedance_recloser_equipment import MatrixImpedanceRecloserEquipment +from gdm.distribution.equipment.recloser_controller_equipment import RecloserControllerEquipment +from gdm.distribution.controllers.distribution_recloser_controller import DistributionRecloserController +from gdm.distribution.common.curve import TimeCurrentCurve +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.enums import LineType +from gdm.quantities import ResistancePULength, ReactancePULength, CapacitancePULength +from infrasys.quantities import Current, Distance, Time +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import parse_phases, phases_without_neutral, sanitize_name, safe_float +from loguru import logger + + +class RecloserMapper(SynergiMapper): + + synergi_table = "InstReclosers" + synergi_database = "Model" + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id) + if section is None: + return None + buses = self.map_buses(row, section_id_sections) + if buses[0] is None or buses[1] is None: + return None + phases = self.map_phases(row, section_id_sections) + if not phases: + return None + amp_rating = self.map_amp_rating(row) + feeder, substation = self._lookup_feeder_substation(str(section["FromNodeId"]).strip()) + return MatrixImpedanceRecloser( + name=self.map_name(row), + buses=buses, + length=self.map_length(), + phases=phases, + is_closed=self.map_is_closed(row, phases), + equipment=self.map_equipment(row, phases, amp_rating), + controller=self.map_controller(row, amp_rating), + substation=substation, + feeder=feeder, + ) + + def map_name(self, row): + section_id = str(row["SectionId"]).strip() + device_id = str(row.get("UniqueDeviceId", section_id)).strip() + return sanitize_name(f"recloser_{device_id}_{section_id}") + + def map_buses(self, row, section_id_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + from_bus = None + to_bus = None + try: + from_bus = self.system.get_component(DistributionBus, sanitize_name(str(section["FromNodeId"]).strip())) + except Exception: + logger.warning(f"Recloser {section_id}: from bus not found") + try: + to_bus = self.system.get_component(DistributionBus, sanitize_name(str(section["ToNodeId"]).strip())) + except Exception: + logger.warning(f"Recloser {section_id}: to bus not found") + return [from_bus, to_bus] + + def map_phases(self, row, section_id_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + phases = phases_without_neutral(parse_phases(section.get("SectionPhases", ""))) + return phases or parse_phases(section.get("SectionPhases", "")) + + def map_is_closed(self, row, phases): + is_open = bool(safe_float(row.get("RecloserIsOpen", 0))) + return [not is_open] * len(phases) + + def map_amp_rating(self, row): + return safe_float(row.get("AmpRating"), 560.0) or 560.0 + + def map_equipment(self, row, phases, amp_rating): + section_id = str(row["SectionId"]).strip() + device_id = str(row.get("UniqueDeviceId", section_id)).strip() + n = len(phases) + r = np.eye(n) * 1e-4 + x = np.eye(n) * 1e-4 + c = np.eye(n) * 1e-3 + return MatrixImpedanceRecloserEquipment( + name=sanitize_name(f"recloser_equip_{device_id}_{section_id}"), + construction=LineType.OVERHEAD, + r_matrix=ResistancePULength(r, "ohm/km"), + x_matrix=ReactancePULength(x, "ohm/km"), + c_matrix=CapacitancePULength(c, "nF/km"), + ampacity=Current(amp_rating, "ampere"), + ) + + def map_controller(self, row, amp_rating): + section_id = str(row["SectionId"]).strip() + device_id = str(row.get("UniqueDeviceId", section_id)).strip() + safe_did = sanitize_name(f"{device_id}_{section_id}") + tcc_fast = TimeCurrentCurve( + name=f"tcc_fast_{safe_did}", + curve_x=Current(np.array([amp_rating * m for m in [1.5, 2.0, 5.0, 10.0]]), "ampere"), + curve_y=Time(np.array([30.0, 5.0, 0.5, 0.05]), "second"), + ) + tcc_delayed = TimeCurrentCurve( + name=f"tcc_delayed_{safe_did}", + curve_x=Current(np.array([amp_rating * m for m in [1.5, 2.0, 5.0, 10.0]]), "ampere"), + curve_y=Time(np.array([60.0, 15.0, 1.0, 0.1]), "second"), + ) + ctrl_equip = RecloserControllerEquipment(name=f"recloser_ctrl_equip_{safe_did}") + num_shots = 4 + return DistributionRecloserController( + name=f"recloser_ctrl_{safe_did}", + delay=Time(0.0, "second"), + ground_delayed=tcc_delayed, + ground_fast=tcc_fast, + phase_delayed=tcc_delayed, + phase_fast=tcc_fast, + num_fast_ops=1, + num_shots=num_shots, + reclose_intervals=Time(np.array([1.0] * (num_shots - 1)), "second"), + reset_time=Time(15.0, "second"), + equipment=ctrl_equip, + ) + + def map_length(self): + return Distance(1, "m") diff --git a/src/ditto/readers/synergi/components/matrix_impedance_switch.py b/src/ditto/readers/synergi/components/matrix_impedance_switch.py new file mode 100644 index 0000000..28c888a --- /dev/null +++ b/src/ditto/readers/synergi/components/matrix_impedance_switch.py @@ -0,0 +1,100 @@ +import numpy as np +from gdm.distribution.components.matrix_impedance_switch import MatrixImpedanceSwitch +from gdm.distribution.equipment.matrix_impedance_switch_equipment import MatrixImpedanceSwitchEquipment +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.enums import LineType +from gdm.quantities import ResistancePULength, ReactancePULength, CapacitancePULength +from infrasys.quantities import Current, Distance +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import parse_phases, phases_without_neutral, sanitize_name, safe_float +from loguru import logger + + +class _SwitchBaseMapper(SynergiMapper): + + synergi_database = "Model" + _is_open_field = "SwitchIsOpen" + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id) + if section is None: + return None + buses = self.map_buses(row, section_id_sections) + if buses[0] is None or buses[1] is None: + return None + phases = self.map_phases(row, section_id_sections) + if not phases: + return None + feeder, substation = self._lookup_feeder_substation(str(section["FromNodeId"]).strip()) + return MatrixImpedanceSwitch( + name=self.map_name(row), + buses=buses, + length=self.map_length(), + phases=phases, + is_closed=self.map_is_closed(row, phases), + equipment=self.map_equipment(row, phases), + substation=substation, + feeder=feeder, + ) + + def map_name(self, row): + section_id = str(row["SectionId"]).strip() + device_id = str(row.get("UniqueDeviceId", section_id)).strip() + return sanitize_name(f"sw_{device_id}_{section_id}") + + def map_buses(self, row, section_id_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + from_bus = None + to_bus = None + try: + from_bus = self.system.get_component(DistributionBus, sanitize_name(str(section["FromNodeId"]).strip())) + except Exception: + logger.warning(f"Switch {section_id}: from bus not found") + try: + to_bus = self.system.get_component(DistributionBus, sanitize_name(str(section["ToNodeId"]).strip())) + except Exception: + logger.warning(f"Switch {section_id}: to bus not found") + return [from_bus, to_bus] + + def map_phases(self, row, section_id_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + phases = phases_without_neutral(parse_phases(section.get("SectionPhases", ""))) + return phases or parse_phases(section.get("SectionPhases", "")) + + def map_is_closed(self, row, phases): + is_open = bool(safe_float(row.get(self._is_open_field, 0))) + return [not is_open] * len(phases) + + def map_equipment(self, row, phases): + section_id = str(row["SectionId"]).strip() + device_id = str(row.get("UniqueDeviceId", section_id)).strip() + n = len(phases) + r = np.eye(n) * 1e-4 + x = np.eye(n) * 1e-4 + c = np.eye(n) * 1e-3 + return MatrixImpedanceSwitchEquipment( + name=sanitize_name(f"sw_equip_{device_id}_{section_id}"), + construction=LineType.OVERHEAD, + r_matrix=ResistancePULength(r, "ohm/km"), + x_matrix=ReactancePULength(x, "ohm/km"), + c_matrix=CapacitancePULength(c, "nF/km"), + ampacity=Current(400, "ampere"), + ) + + def map_length(self): + return Distance(1, "m") + + +class SwitchMapper(_SwitchBaseMapper): + + synergi_table = "InstSwitches" + _is_open_field = "SwitchIsOpen" + + +class BreakerMapper(_SwitchBaseMapper): + + synergi_table = "InstBreakers" + _is_open_field = "BreakerIsOpen" diff --git a/src/ditto/readers/synergi/components/regulator.py b/src/ditto/readers/synergi/components/regulator.py new file mode 100644 index 0000000..f8ddc8f --- /dev/null +++ b/src/ditto/readers/synergi/components/regulator.py @@ -0,0 +1,196 @@ +from gdm.distribution.components.distribution_regulator import DistributionRegulator +from gdm.distribution.controllers.distribution_regulator_controller import RegulatorController +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.enums import Phase, ConnectionType, TransformerMounting +from gdm.distribution.equipment.distribution_transformer_equipment import ( + DistributionTransformerEquipment, + WindingEquipment, +) +from gdm.distribution.common.sequence_pair import SequencePair +from gdm.quantities import ApparentPower +from infrasys.quantities import Voltage, Current, Time +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import parse_phases, phases_without_neutral, sanitize_name, safe_float +from loguru import logger + +_PH_IDX = {"A": "1", "B": "2", "C": "3"} +_REG_PCT_Z = 0.01 + + +class DistributionRegulatorMapper(SynergiMapper): + + synergi_table = "InstRegulators" + synergi_database = "Model" + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections, reg_equipment=None): + reg_equipment = reg_equipment or {} + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id) + if section is None: + return None + + buses = self.map_buses(row, section_id_sections) + if buses[0] is None or buses[1] is None: + return None + + phases = self.map_phases(row) + if not phases: + return None + + reg_type = str(row.get("RegulatorType", "")).strip() + dev = reg_equipment.get(reg_type, {}) + + reg_kva = self.map_reg_kva(dev) + num_taps = self.map_num_taps(dev) + tap_range = self.map_tap_range(dev) + pt_ratio = self.map_pt_ratio(dev) + ct_rating = self.map_ct_rating(dev) + + min_tap = 1.0 - tap_range / 100.0 + max_tap = 1.0 + tap_range / 100.0 + tap_step = tap_range / (num_taps / 2) if num_taps > 0 else 0.625 + + tap_positions = { + Phase.A: safe_float(row.get("TapPositionPhase1"), 0), + Phase.B: safe_float(row.get("TapPositionPhase2"), 0), + Phase.C: safe_float(row.get("TapPositionPhase3"), 0), + } + + from_id = str(section.get("FromNodeId", "")).strip() + feeder, substation = self._lookup_feeder_substation(from_id) + + regulators = [] + for phase in phases: + ph_letter = phase.value + ph_idx = _PH_IDX[ph_letter] + tap_pu = 1.0 + tap_positions[phase] * tap_step / 100.0 + safe_id = sanitize_name(f"{str(row.get('UniqueDeviceId', section_id)).strip()}_{section_id}_{ph_letter}") + + equip = self.map_equipment(safe_id, buses[0], reg_kva, num_taps, min_tap, max_tap, tap_pu) + controller = self.map_controller(row, ph_letter, ph_idx, buses[1], phase, pt_ratio, ct_rating, num_taps) + + regulators.append(DistributionRegulator( + name=f"reg_{safe_id}", + buses=list(buses), + winding_phases=[[phase], [phase]], + equipment=equip, + controllers=[controller], + substation=substation, + feeder=feeder, + )) + + return regulators + + def map_buses(self, row, section_id_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + from_id = sanitize_name(str(section.get("FromNodeId", "")).strip()) + to_id = sanitize_name(str(section.get("ToNodeId", "")).strip()) + from_bus = None + to_bus = None + try: + from_bus = self.system.get_component(DistributionBus, from_id) + except Exception: + logger.warning(f"Regulator {section_id}: from bus {from_id} not found") + try: + to_bus = self.system.get_component(DistributionBus, to_id) + except Exception: + logger.warning(f"Regulator {section_id}: to bus {to_id} not found") + return [from_bus, to_bus] + + def map_phases(self, row): + return phases_without_neutral(parse_phases(str(row.get("ConnectedPhases", "ABC")))) + + def map_reg_kva(self, dev): + return safe_float(dev.get("RegulatorRatedKva"), 500) or 500 + + def map_num_taps(self, dev): + return int(safe_float(dev.get("NumberOfTaps"), 32) or 32) + + def map_tap_range(self, dev): + return safe_float(dev.get("RaiseAndLowerMaxPercentage"), 10) or 10 + + def map_pt_ratio(self, dev): + return safe_float(dev.get("PTRatio"), 60) or 60 + + def map_ct_rating(self, dev): + return safe_float(dev.get("CTRating"), 100) or 100 + + def map_winding(self, name, bus, reg_kva, num_taps, min_tap, max_tap, tap_pu): + return WindingEquipment( + name=name, + resistance=_REG_PCT_Z / 2.0, + is_grounded=True, + rated_voltage=bus.rated_voltage, + voltage_type=bus.voltage_type, + rated_power=ApparentPower(reg_kva, "kilova"), + num_phases=1, + connection_type=ConnectionType.STAR, + tap_positions=[tap_pu], + total_taps=num_taps, + min_tap_pu=min_tap, + max_tap_pu=max_tap, + ) + + def map_equipment(self, safe_id, from_bus, reg_kva, num_taps, min_tap, max_tap, tap_pu): + w1 = self.map_winding(f"reg_w1_{safe_id}", from_bus, reg_kva, num_taps, min_tap, max_tap, 1.0) + w2 = self.map_winding(f"reg_w2_{safe_id}", from_bus, reg_kva, num_taps, min_tap, max_tap, tap_pu) + return DistributionTransformerEquipment( + name=f"reg_equip_{safe_id}", + mounting=TransformerMounting.POLE_MOUNT, + pct_no_load_loss=0.0, + pct_full_load_loss=_REG_PCT_Z, + windings=[w1, w2], + coupling_sequences=[SequencePair(0, 1)], + winding_reactances=[_REG_PCT_Z * 0.99], + is_center_tapped=False, + ) + + def map_controller(self, row, ph_letter, ph_idx, to_bus, phase, pt_ratio, ct_rating, num_taps): + section_id = str(row["SectionId"]).strip() + device_id = str(row.get("UniqueDeviceId", section_id)).strip() + safe_id = sanitize_name(f"{device_id}_{section_id}_{ph_letter}") + + fwd_v = self.map_forward_voltage(row, ph_idx) + fwd_bw = self.map_bandwidth(row, ph_idx) + fwd_r = self.map_ldc_r(row, ph_idx) + fwd_x = self.map_ldc_x(row, ph_idx) + delay = self.map_delay(row) + is_reversible = self.map_is_reversible(row) + + return RegulatorController( + name=f"reg_ctrl_{safe_id}", + delay=delay, + v_setpoint=Voltage(fwd_v, "volt"), + min_v_limit=Voltage(max(fwd_v - fwd_bw / 2, 0.001), "volt"), + max_v_limit=Voltage(fwd_v + fwd_bw / 2, "volt"), + pt_ratio=pt_ratio, + use_ldc=(fwd_r != 0 or fwd_x != 0), + is_reversible=is_reversible, + ldc_R=Voltage(fwd_r, "volt"), + ldc_X=Voltage(fwd_x, "volt"), + ct_primary=Current(ct_rating, "ampere"), + max_step=num_taps // 2, + bandwidth=Voltage(fwd_bw, "volt"), + controlled_bus=to_bus, + controlled_phase=phase, + ) + + def map_forward_voltage(self, row, ph_idx): + return safe_float(row.get(f"ForwardVoltageSettingPhase{ph_idx}"), 124) or 124 + + def map_bandwidth(self, row, ph_idx): + return safe_float(row.get(f"ForwardBWDialPhase{ph_idx}"), 2) or 2 + + def map_ldc_r(self, row, ph_idx): + return safe_float(row.get(f"ForwardRDialPhase{ph_idx}"), 0) or 0 + + def map_ldc_x(self, row, ph_idx): + return safe_float(row.get(f"ForwardXDialPhase{ph_idx}"), 0) or 0 + + def map_delay(self, row): + delay_sec = safe_float(row.get("TimeDelaySec"), 120) or 120 + return Time(delay_sec, "second") + + def map_is_reversible(self, row): + return str(row.get("ReverseSensingMode", "NR")).strip() != "NR" diff --git a/src/ditto/readers/synergi/components/solar.py b/src/ditto/readers/synergi/components/solar.py new file mode 100644 index 0000000..ce49f74 --- /dev/null +++ b/src/ditto/readers/synergi/components/solar.py @@ -0,0 +1,119 @@ +from gdm.distribution.components.distribution_solar import DistributionSolar +from gdm.distribution.components.distribution_bus import DistributionBus +from gdm.distribution.enums import Phase, VoltageTypes +from gdm.distribution.equipment.solar_equipment import SolarEquipment +from gdm.distribution.equipment.inverter_equipment import InverterEquipment +from gdm.quantities import ApparentPower, ReactivePower, Irradiance +from infrasys.quantities import ActivePower, Voltage +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import parse_phases, phases_without_neutral, sanitize_name, safe_float +from loguru import logger + +_PHASE_KEYS = [("Phase1Kw", Phase.A), ("Phase2Kw", Phase.B), ("Phase3Kw", Phase.C)] + + +class DistributionSolarMapper(SynergiMapper): + + synergi_table = "InstDGens" + synergi_database = "Model" + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id) + if section is None: + return None + + bus = self.map_bus(row, section_id_sections) + if bus is None: + return None + + phases = self.map_phases(row, bus) + if not phases: + return None + + inverter_kva = self.map_inverter_kva(row, phases) + to_id = str(section.get("ToNodeId", "")).strip() + feeder, substation = self._lookup_feeder_substation(to_id) + + return DistributionSolar( + name=self.map_name(row), + bus=bus, + phases=phases, + equipment=self.map_equipment(row, bus, phases, inverter_kva), + inverter=self.map_inverter(row, inverter_kva), + irradiance=Irradiance(1000, "watt/meter**2"), + active_power=self.map_active_power(row), + reactive_power=self.map_reactive_power(row), + controller=None, + in_service=self.map_in_service(row), + substation=substation, + feeder=feeder, + ) + + def map_name(self, row): + return f"solar_{sanitize_name(str(row['SectionId']).strip())}" + + def map_bus(self, row, section_id_sections): + section_id = str(row["SectionId"]).strip() + section = section_id_sections.get(section_id, {}) + to_id = sanitize_name(str(section.get("ToNodeId", "")).strip()) + try: + return self.system.get_component(DistributionBus, to_id) + except Exception: + logger.warning(f"Solar (DGen) on section {section_id}: bus {to_id} not found") + return None + + def map_phases(self, row, bus): + phases = [ph for key, ph in _PHASE_KEYS if safe_float(row.get(key), 0) != 0] + if not phases: + phases = [p for p in bus.phases if p != Phase.N] + bus_phases = {p for p in bus.phases if p != Phase.N} + return [p for p in phases if p in bus_phases] + + def map_inverter_kva(self, row, phases): + kva = safe_float(row.get("InvertRat_Kva"), None) + if not kva or kva <= 0: + total_kw = sum(safe_float(row.get(k), 0) for k, _ in _PHASE_KEYS) + kva = max(total_kw, 1.0) + return kva + + def map_equipment(self, row, bus, phases, inverter_kva): + section_id = str(row["SectionId"]).strip() + pct_kw = safe_float(row.get("InvertRat_PctKw"), 100) or 100 + rated_kw = max(inverter_kva * pct_kw / 100.0, 0.001) + bus_kv = bus.rated_voltage.to("kilovolt").magnitude + voltage_type = VoltageTypes.LINE_TO_GROUND if len(phases) == 1 else VoltageTypes.LINE_TO_LINE + return SolarEquipment( + name=f"solar_equip_{sanitize_name(section_id)}", + rated_power=ActivePower(rated_kw, "kilowatt"), + resistance=0.0, + reactance=0.0, + rated_voltage=Voltage(bus_kv, "kilovolt"), + voltage_type=voltage_type, + ) + + def map_inverter(self, row, inverter_kva): + section_id = str(row["SectionId"]).strip() + return InverterEquipment( + name=f"inverter_{sanitize_name(section_id)}", + rated_apparent_power=ApparentPower(inverter_kva, "kilova"), + rise_limit=None, + fall_limit=None, + dc_to_ac_efficiency=95.0, + cutin_percent=5.0, + cutout_percent=5.0, + eff_curve=None, + ) + + def map_active_power(self, row): + total_kw = sum(safe_float(row.get(k), 0) for k, _ in _PHASE_KEYS) + return ActivePower(max(total_kw, 0.0), "kilowatt") + + def map_reactive_power(self, row): + total_kvar = sum(safe_float(row.get(f"Phase{i}Kvar"), 0) for i in range(1, 4)) + return ReactivePower(total_kvar, "kilovar") + + def map_in_service(self, row): + # DGenIsOn reflects Synergi dispatch state (often 0 even for installed DGs); + # treat installed DGs as in-service for power flow studies + return True diff --git a/src/ditto/readers/synergi/equipment/capacitor_equipment.py b/src/ditto/readers/synergi/equipment/capacitor_equipment.py index 9b2aab8..63eb5bb 100644 --- a/src/ditto/readers/synergi/equipment/capacitor_equipment.py +++ b/src/ditto/readers/synergi/equipment/capacitor_equipment.py @@ -1,105 +1,77 @@ +import math + from ditto.readers.synergi.synergi_mapper import SynergiMapper -from gdm.quantities import PositiveReactivePower, PositiveResistance, PositiveReactance +from ditto.readers.synergi.utils import sanitize_name, safe_float +from gdm.quantities import ReactivePower, Reactance from gdm.distribution.equipment.phase_capacitor_equipment import PhaseCapacitorEquipment from gdm.distribution.equipment.capacitor_equipment import CapacitorEquipment -from gdm import ConnectionType +from gdm.distribution.enums import ConnectionType, VoltageTypes, Phase +from infrasys.quantities import Resistance, Voltage + class CapacitorEquipmentMapper(SynergiMapper): - def __init__(self, system): - super().__init__(system) synergi_table = "InstCapacitors" synergi_database = "Model" - def parse(self, row): - name = self.map_name(row) - phase_capacitors = self.map_phase_capacitors(row) - connection_type = self.map_connection_type(row) - return CapacitorEquipment(name=name, - phase_capacitors=phase_capacitors, - connection_type=connection_type) - - - def map_name(self, row): - return row["UniqueDeviceId"] - - def map_phase_capacitors(self, row): - phase_capacitors = [] - for phase in range(1, 4): - mapper = PhaseCapacitorEquipmentMapper(self.system) - phase_capacitor = mapper.parse(row, phase) - phase_capacitors.append(phase_capacitor) - return phase_capacitors - + def parse(self, row, phases): + device_id = str(row.get("UniqueDeviceId", row.get("SectionId", ""))).strip() + safe_did = sanitize_name(device_id) + return CapacitorEquipment( + name=f"cap_equip_{safe_did}", + phase_capacitors=self.map_phase_capacitors(row, phases, safe_did), + connection_type=self.map_connection_type(row), + rated_voltage=self.map_rated_voltage(row), + voltage_type=VoltageTypes.LINE_TO_GROUND, + ) def map_connection_type(self, row): - value = row["ConnectionType"] - if value == "YG": - return ConnectionType.STAR.value - if value == "D": - return ConnectionType.DELTA.value - - -class PhaseCapacitorEquipmentMapper(SynergiMapper): - def __init__(self, system): - super().__init__(system) - - synergi_table = "InstCapacitors" - synergi_database = "Model" - - def parse(self, row, phase): - name = self.map_name(row, phase) - resistance = self.map_resistance(row, phase) - reactance = self.map_reactance(row, phase) - rated_capacity = self.map_rated_capacity(row, phase) - num_banks_on = self.map_num_banks_on(row, phase) - num_banks = self.map_num_banks(row, phase) - return PhaseCapacitorEquipment(name = name, - resistance=resistance, - reactance=reactance, - rated_capacity=rated_capacity, - num_banks_on=num_banks_on, - num_banks=num_banks) - - def map_name(self, row, phase): - if phase == 1: - return row["UniqueDeviceId"] + "_A" - if phase == 2: - return row["UniqueDeviceId"] + "_B" - if phase == 3: - return row["UniqueDeviceId"] + "_C" - - # Resistance and Reactance not included for capacitors - def map_resistance(self, row, phase): - return PositiveResistance(0,'ohm') - - # Resistance and Reactance not included for capacitors - def map_reactance(self, row, phase): - return PositiveReactance(0,'ohm') - - # TODO: This doesn't make sense. We should have fixed and switched values - def map_rated_capacity(self, row, phase): - total_capacity = 0 - fixed_key = f"FixedKvarPhase{phase}" - if row[fixed_key] > 0: - total_capacity += row[fixed_key] - activated_key = f"Module{phase}Activated" - if row[activated_key] == 1: - switched_key = f"Module{phase}KvarPerPhase" - total_capacity += row[switched_key] - return PositiveReactivePower(total_capacity,'kilovar') - - # TODO: This doesn't make sense. This should indicate if the bank is switched - def map_num_banks_on(self, row, phase): - num_banks_on = 0 - activated_key = f"Module{phase}Activated" - if row[activated_key] == 1: - num_banks_on += 1 - return num_banks_on - - #TODO: This doesn't make sense. This should indicate how many banks are switched - def map_num_banks(self, row, phase): - num_banks = 1 - return num_banks - - + value = str(row.get("ConnectionType", "YG")).strip() + return ConnectionType.DELTA if value == "D" else ConnectionType.STAR + + def map_rated_voltage(self, row): + rated_kvll = safe_float(row.get("RatedKv"), 12.47) + if rated_kvll <= 0: + rated_kvll = 12.47 + return Voltage(rated_kvll / math.sqrt(3), "kilovolt") + + def map_phase_capacitors(self, row, phases, safe_did): + fixed_kvar = { + Phase.A: safe_float(row.get("FixedKvarPhase1"), 0.0), + Phase.B: safe_float(row.get("FixedKvarPhase2"), 0.0), + Phase.C: safe_float(row.get("FixedKvarPhase3"), 0.0), + } + # Switched module banks (Module1..3 each add kvar per phase when on) + modules = [ + (safe_float(row.get("Module1On"), 0.0), safe_float(row.get("Module1KvarPerPhase"), 0.0)), + (safe_float(row.get("Module2On"), 0.0), safe_float(row.get("Module2KvarPerPhase"), 0.0)), + (safe_float(row.get("Module3On"), 0.0), safe_float(row.get("Module3KvarPerPhase"), 0.0)), + ] + + phase_caps = [] + for phase in phases: + fkvar = fixed_kvar.get(phase, 0.0) + total_kvar = fkvar + num_banks = 1 if fkvar > 0 else 0 + num_banks_on = num_banks + + for m_on, m_kvar in modules: + if m_on and m_kvar > 0: + total_kvar += m_kvar + num_banks += 1 + num_banks_on += 1 + + if num_banks == 0: + num_banks = 1 + if total_kvar <= 0: + total_kvar = 100.0 + + phase_caps.append(PhaseCapacitorEquipment( + name=f"phcap_{safe_did}_{phase.value}", + resistance=Resistance(0.0, "ohm"), + reactance=Reactance(0.0, "ohm"), + rated_reactive_power=ReactivePower(total_kvar, "kilovar"), + num_banks_on=num_banks_on, + num_banks=num_banks, + )) + return phase_caps diff --git a/src/ditto/readers/synergi/equipment/conductor_equipment.py b/src/ditto/readers/synergi/equipment/conductor_equipment.py index 170cc1e..d1e29b8 100644 --- a/src/ditto/readers/synergi/equipment/conductor_equipment.py +++ b/src/ditto/readers/synergi/equipment/conductor_equipment.py @@ -1,12 +1,12 @@ +import re + from ditto.readers.synergi.synergi_mapper import SynergiMapper from gdm.distribution.equipment.concentric_cable_equipment import ConcentricCableEquipment from gdm.distribution.equipment.bare_conductor_equipment import BareConductorEquipment -from gdm import PositiveCurrent, PositiveDistance, PositiveResistancePULength +from gdm.quantities import Current, Distance, ResistancePULength, Voltage from ditto.readers.synergi.length_units import length_units class ConductorEquipmentMapper(SynergiMapper): - def __init__(self, system): - super().__init__(system) synergi_table = "DevConductors" synergi_database = "Equipment" @@ -29,16 +29,15 @@ def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node num_neutral_strands = self.map_num_neutral_strands(row) rated_voltage = self.map_rated_voltage(row) - if insulation_thickness == 0: + if insulation_thickness.magnitude <= 0: return BareConductorEquipment(name=name, conductor_diameter=conductor_diameter, conductor_gmr=conductor_gmr, ampacity=ampacity, emergency_ampacity=emergency_ampacity, ac_resistance=phase_ac_resistance, - dc_resistance=phase_ac_resistance, - loading_limit=None) - else: + dc_resistance=phase_ac_resistance) + elif rated_voltage is not None: return ConcentricCableEquipment(name=name, strand_diameter=strand_diameter, conductor_diameter=conductor_diameter, @@ -46,13 +45,14 @@ def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node insulation_thickness=insulation_thickness, insulation_diameter=insulation_diameter, ampacity=ampacity, - emergency_ampacity=emergency_ampacity, conductor_gmr=conductor_gmr, strand_gmr=strand_gmr, phase_ac_resistance=phase_ac_resistance, strand_ac_resistance=strand_ac_resistance, num_neutral_strands=num_neutral_strands, rated_voltage=rated_voltage) + else: + return None def map_name(self, row): @@ -61,67 +61,82 @@ def map_name(self, row): def map_strand_diameter(self, row, unit_type): value = row["CableConNeutStrandDiameter_SUL"] * self.MAGIC_NUMBER_2 unit = length_units[unit_type]["SUL"] - return PositiveDistance(value, unit).to("mm") + return Distance(value, unit).to("mm") def map_conductor_diameter(self, row, unit_type): - value = row["CableDiamConductor_SUL"] + # TODO: Zero diameter indicates missing data; should be resolved with correct source values + value = row["CableDiamConductor_SUL"] or row["Diameter_SUL"] or 0.001 unit = length_units[unit_type]["SUL"] - return PositiveDistance(value, unit).to("mm") + return Distance(value, unit).to("mm") def map_cable_diameter(self, row, unit_type): value = row["CableDiamOutside_SUL"] * self.MAGIC_NUMBER_1 unit = length_units[unit_type]["SUL"] - return PositiveDistance(value, unit).to("mm") + return Distance(value, unit).to("mm") def map_insulation_thickness(self, row, unit_type): outside = row["CableDiamOutside_SUL"] inside = row["CableDiamOverInsul_SUL"] thickness = (outside - inside)/2 unit = length_units[unit_type]["SUL"] - return PositiveDistance(thickness, unit).to("mm") + return Distance(thickness, unit).to("mm") def map_insulation_diameter(self, row, unit_type): value = row["CableDiamOverInsul_SUL"] * self.MAGIC_NUMBER_1 unit = length_units[unit_type]["SUL"] - return PositiveDistance(value, unit).to("mm") + return Distance(value, unit).to("mm") def map_ampacity(self, row): - value = row["ContinuousCurrentRating"] - return PositiveCurrent(value, "ampere") + # TODO: Zero ampacity likely indicates missing data; should be resolved with correct source values + value = row["ContinuousCurrentRating"] or 600 + return Current(value, "ampere") def map_emergency_ampacity(self, row): - value = row["InterruptCurrentRating"] - return PositiveCurrent(value, "ampere") + # TODO: Zero emergency ampacity indicates missing data; should be resolved with correct source values + value = row["InterruptCurrentRating"] or 600 + return Current(value, "ampere") def map_conductor_gmr(self, row, unit_type): value = row["CableGMR_MUL"] + if not value: + # Estimate GMR from conductor diameter: GMR ≈ 0.7788 * radius + diameter = row["CableDiamConductor_SUL"] or row["Diameter_SUL"] + sul_unit = length_units[unit_type]["SUL"] + # TODO: Zero diameter/GMR indicates missing data; should be resolved with correct source values + return Distance(max(diameter / 2 * 0.7788, 0.001), sul_unit).to("mm") unit = length_units[unit_type]["MUL"] - return PositiveDistance(value, unit).to("mm") + return Distance(value, unit).to("mm") def map_strand_gmr(self, row, unit_type): value = row["CableConNeutStrandDiameter_SUL"] - value = value /2 * 0.7788 # OpenDSS estimate is 0.7788 * radius + value = value / 2 * 0.7788 # OpenDSS estimate is 0.7788 * radius + # TODO: Zero strand GMR indicates missing data; should be resolved with correct source values + value = max(value, 0.001) unit = length_units[unit_type]["SUL"] - return PositiveDistance(value, unit).to("mm") + return Distance(value, unit).to("mm") def map_phase_ac_resistance(self, row, unit_type): - value = row["CableResistance_PerLUL"] + # TODO: Zero resistance indicates missing data; should be resolved with correct source values + value = row["CableResistance_PerLUL"] or row["PosSequenceResistance_PerLUL"] or 0.001 unit = length_units[unit_type]["PerLUL"] - return PositiveResistancePULength(value, unit).to("ohm/km") + return ResistancePULength(value, unit).to("ohm/km") def map_strand_ac_resistance(self, row, unit_type): - value = row["CableConNeutResistance_PerLUL"] + # TODO: Zero strand resistance indicates missing data; should be resolved with correct source values + value = row["CableConNeutResistance_PerLUL"] or 0.001 unit = length_units[unit_type]["PerLUL"] - return PositiveResistancePULength(value, unit).to("ohm/km") + return ResistancePULength(value, unit).to("ohm/km") #Need a field for number of phase strands too... def map_num_neutral_strands(self, row): value = row["CableConNeutStrandCount"] return value - # We should remove this field def map_rated_voltage(self, row): - return 0 + match = re.search(r"(\d+(?:\.\d+)?)\s*kV", row["ConductorName"], re.IGNORECASE) + if match: + return Voltage(float(match.group(1)), "kilovolt") + return None def map_loading_limit(self, row): return None diff --git a/src/ditto/readers/synergi/equipment/distribution_transformer_equipment.py b/src/ditto/readers/synergi/equipment/distribution_transformer_equipment.py index 86431e4..a63bc79 100644 --- a/src/ditto/readers/synergi/equipment/distribution_transformer_equipment.py +++ b/src/ditto/readers/synergi/equipment/distribution_transformer_equipment.py @@ -2,13 +2,12 @@ from ditto.readers.synergi.equipment.winding_equipment import WindingEquipmentMapper from gdm.distribution.equipment.distribution_transformer_equipment import DistributionTransformerEquipment -from gdm import Phase, ConnectionType, SequencePair, PositiveReactance +from gdm.distribution.enums import Phase, ConnectionType +from gdm.distribution.common import SequencePair class DistributionTransformerEquipmentMapper(SynergiMapper): - def __init__(self, system): - super().__init__(system) synergi_table = "DevTransformers" synergi_database = "Equipment" diff --git a/src/ditto/readers/synergi/equipment/generator_equipment.py b/src/ditto/readers/synergi/equipment/generator_equipment.py new file mode 100644 index 0000000..ee2ce28 --- /dev/null +++ b/src/ditto/readers/synergi/equipment/generator_equipment.py @@ -0,0 +1,39 @@ +from gdm.distribution.enums import VoltageTypes +from gdm.distribution.equipment.solar_equipment import SolarEquipment +from infrasys.quantities import ActivePower, Voltage +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import sanitize_name, safe_float + + +class GeneratorEquipmentMapper(SynergiMapper): + + synergi_table = "DevGenerators" + synergi_database = "Equipment" + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): + return SolarEquipment( + name=self.map_name(row), + rated_power=self.map_rated_power(row), + resistance=0.0, + reactance=self.map_reactance(row), + rated_voltage=self.map_rated_voltage(row), + voltage_type=self.map_voltage_type(row), + ) + + def map_name(self, row): + gen_name = str(row.get("GeneratorName", "")).strip() + return f"solar_equip_{sanitize_name(gen_name)}" + + def map_rated_power(self, row): + kw = safe_float(row.get("KwRating"), 1000) or 1000 + return ActivePower(kw, "kilowatt") + + def map_reactance(self, row): + return safe_float(row.get("PosSequenceReactance"), 0.5) or 0.5 + + def map_rated_voltage(self, row): + kv = safe_float(row.get("KvRating"), 13.2) or 13.2 + return Voltage(kv, "kilovolt") + + def map_voltage_type(self, row): + return VoltageTypes.LINE_TO_LINE diff --git a/src/ditto/readers/synergi/equipment/geometry_branch_equipment.py b/src/ditto/readers/synergi/equipment/geometry_branch_equipment.py index d16a792..24cd4d2 100644 --- a/src/ditto/readers/synergi/equipment/geometry_branch_equipment.py +++ b/src/ditto/readers/synergi/equipment/geometry_branch_equipment.py @@ -3,13 +3,10 @@ from gdm.distribution.equipment.bare_conductor_equipment import BareConductorEquipment from gdm.distribution.equipment.concentric_cable_equipment import ConcentricCableEquipment from gdm.quantities import Distance -from gdm import PositiveDistance from ditto.readers.synergi.length_units import length_units from loguru import logger class GeometryBranchEquipmentMapper(SynergiMapper): - def __init__(self, system): - super().__init__(system) synergi_table = "DevConfig" synergi_database = "Equipment" @@ -52,9 +49,8 @@ def map_conductors(self, conducor_list): conductors.append(bare_equipment) elif concentric_equipment is not None: conductors.append(concentric_equipment) - else: + else: logger.warning(f"Conductor {conductor} not found. Skipping") - import pdb;pdb.set_trace() return conductors diff --git a/src/ditto/readers/synergi/equipment/load_equipment.py b/src/ditto/readers/synergi/equipment/load_equipment.py index 3724986..f868e17 100644 --- a/src/ditto/readers/synergi/equipment/load_equipment.py +++ b/src/ditto/readers/synergi/equipment/load_equipment.py @@ -1,75 +1,62 @@ from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import sanitize_name from gdm.distribution.equipment.load_equipment import LoadEquipment from gdm.distribution.equipment.phase_load_equipment import PhaseLoadEquipment -from gdm import ConnectionType +from gdm.distribution.enums import ConnectionType from gdm.quantities import ActivePower, ReactivePower class LoadEquipmentMapper(SynergiMapper): - def __init__(self, system): - super().__init__(system) synergi_table = "Loads" synergi_database = "Model" - def parse(self, row): + def parse(self, row, z=1.0, i=0.0, p=0.0): name = self.map_name(row) - phase_loads = self.map_phase_loads(row) + phase_loads = self.map_phase_loads(row, z, i, p) return LoadEquipment(name=name, - phase_loads=phase_loads) + phase_loads=phase_loads, + connection_type=ConnectionType.STAR) - #NOTE: Names may not be unique. Should we append a number to the name? def map_name(self, row): - return row['SectionId'] + return sanitize_name(f"load_equip_{row['SectionId']}") # No connection type information is included def map_connection_type(self, row): - return ConnectionType.WYE + return ConnectionType.STAR - def map_phase_loads(self,row): + def map_phase_loads(self, row, z=1.0, i=0.0, p=0.0): phase_loads = [] - for phase in range(1,4): - if row[f"Phase{phase}Kw"] > 0: + for phase in range(1, 4): + kw = row[f"Phase{phase}Kw"] + kvar = row[f"Phase{phase}Kvar"] + customers = row[f"Phase{phase}Customers"] + if kw != 0 or kvar != 0 or customers > 0: mapper = PhaseLoadEquipmentMapper(self.system) - phase_load = mapper.parse(row, phase) + phase_load = mapper.parse(row, phase, z, i, p) phase_loads.append(phase_load) return phase_loads class PhaseLoadEquipmentMapper(SynergiMapper): - def __init__(self, system): - super().__init__(system) synergi_table = "Loads" synergi_database = "Model" - def parse(self, row, phase): + def parse(self, row, phase, z=1.0, i=0.0, p=0.0): name = self.map_name(row, phase) real_power = self.map_real_power(row, phase) reactive_power = self.map_reactive_power(row, phase) - 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) num_customers = self.map_num_customers(row, phase) 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, + z_real=z, z_imag=z, + i_real=i, i_imag=i, + p_real=p, p_imag=p, num_customers=num_customers) def map_name(self, row, phase): - if phase == 1: - return row['SectionId'] + "_A" - if phase == 2: - return row['SectionId'] + "_B" - if phase == 3: - return row['SectionId'] + "_C" + suffix = {1: "A", 2: "B", 3: "C"}[phase] + return sanitize_name(f"phload_{row['SectionId']}_{suffix}") def map_real_power(self, row, phase): kw = row[f"Phase{phase}Kw"] @@ -79,28 +66,10 @@ def map_reactive_power(self,row, phase): kvar = row[f"Phase{phase}Kvar"] return ReactivePower(kvar, 'kilovar') - 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 - def map_num_customers(self, row, phase): - customers = row[f"Phase{phase}Customers"] + customers = int(round(row[f"Phase{phase}Customers"])) if customers == 0: customers = 1 - return customers + return customers diff --git a/src/ditto/readers/synergi/equipment/matrix_impedance_branch_equipment.py b/src/ditto/readers/synergi/equipment/matrix_impedance_branch_equipment.py new file mode 100644 index 0000000..dad49a7 --- /dev/null +++ b/src/ditto/readers/synergi/equipment/matrix_impedance_branch_equipment.py @@ -0,0 +1,70 @@ +import numpy as np +from gdm.distribution.equipment.matrix_impedance_branch_equipment import MatrixImpedanceBranchEquipment +from gdm.distribution.enums import LineType +from gdm.quantities import ResistancePULength, ReactancePULength, CapacitancePULength +from gdm.distribution.equipment.bare_conductor_equipment import BareConductorEquipment +from infrasys.quantities import Current +from ditto.readers.synergi.synergi_mapper import SynergiMapper +from ditto.readers.synergi.utils import sanitize_name, safe_float +from ditto.readers.synergi.length_units import length_units + + +class MatrixImpedanceBranchEquipmentMapper(SynergiMapper): + + synergi_table = "DevConductors" + synergi_database = "Equipment" + + def parse(self, row, unit_type, section_id_sections, from_node_sections, to_node_sections): + conductor_name = str(row["ConductorName"]).strip() + per_lul_unit = length_units[unit_type]["PerLUL"] + lul_unit = length_units[unit_type]["LUL"] + + r1 = safe_float(row.get("PosSequenceResistance_PerLUL"), 0.5) or 0.5 + x1 = safe_float(row.get("PosSequenceReactance_PerLUL"), 0.5) or 0.5 + r0_raw = safe_float(row.get("ZeroSequenceResistance_PerLUL")) + x0_raw = safe_float(row.get("ZeroSequenceReactance_PerLUL")) + r0 = r0_raw if r0_raw else r1 * 2 + x0 = x0_raw if x0_raw else x1 * 3 + c1_raw = safe_float(row.get("PosSequenceAdmittance_PerLUL"), 10.0) + c1 = c1_raw if c1_raw > 0 else 6.21 + + # Convert to ohm/km and nF/km for matrix storage + r1_km = ResistancePULength(r1, per_lul_unit).to("ohm/km").magnitude + x1_km = ResistancePULength(x1, per_lul_unit).to("ohm/km").magnitude + r0_km = ResistancePULength(r0, per_lul_unit).to("ohm/km").magnitude + x0_km = ResistancePULength(x0, per_lul_unit).to("ohm/km").magnitude + c1_km = CapacitancePULength(c1, f"nF/{lul_unit}").to("nF/km").magnitude + + # Sequence-to-phase transformation + r_self = (2 * r1_km + r0_km) / 3 + x_self = (2 * x1_km + x0_km) / 3 + r_mut = (r0_km - r1_km) / 3 + x_mut = (x0_km - x1_km) / 3 + + ampacity = safe_float(row.get("ContinuousCurrentRating"), 400.0) or 400.0 + + # Determine construction by checking whether the conductor is bare (OH) or cable (UG) + try: + self.system.get_component(BareConductorEquipment, conductor_name) + construction = LineType.OVERHEAD + except Exception: + construction = LineType.UNDERGROUND + + equipment_list = [] + for n_cond in (1, 2, 3): + r_matrix = np.full((n_cond, n_cond), r_mut) + np.fill_diagonal(r_matrix, r_self) + x_matrix = np.full((n_cond, n_cond), x_mut) + np.fill_diagonal(x_matrix, x_self) + c_matrix = np.eye(n_cond) * c1_km + + equipment_list.append(MatrixImpedanceBranchEquipment( + name=sanitize_name(f"{conductor_name}_{n_cond}ph"), + construction=construction, + r_matrix=ResistancePULength(r_matrix, "ohm/km"), + x_matrix=ReactancePULength(x_matrix, "ohm/km"), + c_matrix=CapacitancePULength(c_matrix, "nF/km"), + ampacity=Current(ampacity, "ampere"), + )) + + return equipment_list diff --git a/src/ditto/readers/synergi/equipment/winding_equipment.py b/src/ditto/readers/synergi/equipment/winding_equipment.py index 3c97145..f600504 100644 --- a/src/ditto/readers/synergi/equipment/winding_equipment.py +++ b/src/ditto/readers/synergi/equipment/winding_equipment.py @@ -1,6 +1,7 @@ from ditto.readers.synergi.synergi_mapper import SynergiMapper from gdm.distribution.equipment.distribution_transformer_equipment import WindingEquipment -from gdm import VoltageTypes, ConnectionType, PositiveVoltage +from gdm.distribution.enums import VoltageTypes, ConnectionType +from gdm.quantities import Voltage class WindingEquipmentMapper(SynergiMapper): def __init__(self, system): @@ -13,7 +14,7 @@ def parse(self, row, winding_number): name = self.map_name(row, winding_number) resistance = self.map_resistance(row, winding_number) is_grounded = self.map_is_grounded(row, winding_number) - nominal_voltage = self.map_nominal_voltage(row, winding_number) + rated_voltage = self.map_nominal_voltage(row, winding_number) voltage_type = self.map_voltage_type(row) rated_power = self.map_rated_power(row) num_phases = self.num_phases(row) @@ -26,7 +27,7 @@ def parse(self, row, winding_number): return WindingEquipment(name=name, resistance=resistance, is_grounded=is_grounded, - nominal_voltage=nominal_voltage, + rated_voltage=rated_voltage, voltage_type=voltage_type, rated_power=rated_power, num_phases=num_phases, @@ -56,9 +57,9 @@ def map_is_grounded(self, row, winding_number): def map_nominal_voltage(self, row, winding_number): if winding_number == 1: - return PositiveVoltage(row["HighSideRatedKv"], "kilovolt") + return Voltage(row["HighSideRatedKv"], "kilovolt") else: - return PositiveVoltage(row["LowSideRatedKv"], "kilovolt") + return Voltage(row["LowSideRatedKv"], "kilovolt") def map_voltage_type(self, row): return VoltageTypes.LINE_TO_LINE diff --git a/src/ditto/readers/synergi/reader.py b/src/ditto/readers/synergi/reader.py index af9e9e7..0c191b5 100644 --- a/src/ditto/readers/synergi/reader.py +++ b/src/ditto/readers/synergi/reader.py @@ -1,26 +1,33 @@ -from gdm.distribution.components.base.distribution_component_base import DistributionComponentBase -from gdm.distribution.equipment.geometry_branch_equipment import GeometryBranchEquipment -from gdm.distribution.equipment.bare_conductor_equipment import BareConductorEquipment -from gdm.distribution.equipment.concentric_cable_equipment import ConcentricCableEquipment -from gdm import DistributionSystem -from gdm.quantities import Distance +from gdm.distribution import DistributionSystem from ditto.readers.reader import AbstractReader -from ditto.readers.synergi.utils import read_synergi_data, download_mdbtools +from ditto.readers.synergi.utils import read_synergi_data, download_mdbtools, build_node_feeder_map import ditto.readers.synergi as synergi_mapper from loguru import logger class Reader(AbstractReader): - # Order matters here + # Order matters — feeders/substations first, equipment before components that reference them component_types = [ + "DistributionFeeder", + "DistributionSubstation", "DistributionBus", + "DistributionVoltageSource", "DistributionCapacitor", "DistributionLoad", -# "DistributionTransformerEquipment", -# "DistributionTransformer", "ConductorEquipment", "GeometryBranchEquipment", - "GeometryBranch", + "MatrixImpedanceBranchEquipment", + "DistributionTransformerEquipment", + "GeneratorEquipment", + "DistributionTransformer", + "Switch", + "Breaker", + "Fuse", + "Recloser", + "DistributionRegulator", + "Generator", + "DistributionSolar", + "LineSection", ] def __init__(self, model_file, equipment_file): @@ -28,212 +35,110 @@ def __init__(self, model_file, equipment_file): self.system = DistributionSystem(auto_add_composed_components=True) self.read(model_file, equipment_file) - def create_geometry_defaults(self, model_file): - - # TODO: Add delta-configured lines as well - all_conductors = set() - section_data = read_synergi_data(model_file,"InstSection") - for idx, row in section_data.iterrows(): - conductor_n = row["NeutralConductorId"] - conductor = row["PhaseConductorId"] - all_conductors.add((conductor,conductor_n)) - default_geometries = [] - for conductor,conductor_n in all_conductors: - wire = None - cable = None - wire_n = None - cable_n = None - try: - wire = self.system.get_component(component_type=BareConductorEquipment,name=conductor) - except: - pass - try: - wire_n = self.system.get_component(component_type=BareConductorEquipment,name=conductor_n) - except: - pass - try: - cable = self.system.get_component(component_type=ConcentricCableEquipment,name=conductor) - except: - pass - try: - cable_n = self.system.get_component(component_type=ConcentricCableEquipment,name=conductor_n) - except: - pass - if wire is not None and wire_n is not None: - conductors = [wire, wire, wire, wire_n] - conductor_names = [wire.name, wire.name, wire.name, wire_n.name] - default_geometries.append(GeometryBranchEquipment( - name="Default_OH_3PH_"+"_".join(conductor_names), - conductors = conductors, - horizontal_positions = Distance([0,0.5,1.0, 1.5],"m"), - vertical_positions = Distance([6,6,6,6],"m"))) - - conductors = [wire, wire, wire] - conductor_names = [wire.name, wire.name, wire.name] - default_geometries.append(GeometryBranchEquipment( - name="Default_OH_3PH_Delta_"+"_".join(conductor_names), - conductors = conductors, - horizontal_positions = Distance([0,0.5,1.0],"m"), - vertical_positions = Distance([6,6,6],"m"))) - - conductors = [wire, wire, wire_n] - conductor_names = [wire.name, wire.name, wire_n.name] - default_geometries.append(GeometryBranchEquipment( - name="Default_OH_2PH_"+"_".join(conductor_names), - conductors = conductors, - horizontal_positions = Distance([0,0.5,1.0],"m"), - vertical_positions = Distance([6,6,6],"m"))) - - conductors = [wire, wire] - conductor_names = [wire.name, wire.name] - default_geometries.append(GeometryBranchEquipment( - name="Default_OH_2PH_Delta_"+"_".join(conductor_names), - conductors = conductors, - horizontal_positions = Distance([0,0.5],"m"), - vertical_positions = Distance([6,6],"m"))) - - conductors = [wire, wire_n] - conductor_names = [wire.name, wire_n.name] - default_geometries.append(GeometryBranchEquipment( - name="Default_OH_1PH_"+ "_".join(conductor_names), - conductors = conductors, - horizontal_positions = Distance([0,0.5],"m"), - vertical_positions = Distance([6,6],"m"))) - - conductors = [wire] - conductor_names = [wire.name] - default_geometries.append(GeometryBranchEquipment( - name="Default_OH_1PH_Delta_"+ "_".join(conductor_names), - conductors = conductors, - horizontal_positions = Distance([0],"m"), - vertical_positions = Distance([6],"m"))) - if cable is not None and cable_n is not None: - conductors = [cable, cable, cable, cable_n] - conductor_names = [cable.name, cable.name, cable.name, cable_n.name] - default_geometries.append(GeometryBranchEquipment( - name="Default_UG_3PH_"+"_".join(conductor_names), - conductors = conductors, - horizontal_positions = Distance([0,0.5,1.0, 1.5],"m"), - vertical_positions = Distance([-1,-1,-1,-1],"m"))) - - conductors = [cable, cable, cable] - conductor_names = [cable.name, cable.name, cable.name] - default_geometries.append(GeometryBranchEquipment( - name="Default_UG_3PH_Delta_"+"_".join(conductor_names), - conductors = conductors, - horizontal_positions = Distance([0,0.5,1.0],"m"), - vertical_positions = Distance([-1,-1,-1],"m"))) - - conductors = [cable, cable, cable_n] - conductor_names = [cable.name, cable.name, cable_n.name] - default_geometries.append(GeometryBranchEquipment( - name="Default_UG_2PH_"+"_".join(conductor_names), - conductors = conductors, - horizontal_positions = Distance([0,0.5,1.0],"m"), - vertical_positions = Distance([-1,-1,-1],"m"))) - - conductors = [cable, cable] - conductor_names = [cable.name, cable.name] - default_geometries.append(GeometryBranchEquipment( - name="Default_UG_2PH_Delta_"+"_".join(conductor_names), - conductors = conductors, - horizontal_positions = Distance([0,0.5],"m"), - vertical_positions = Distance([-1,-1],"m"))) - - conductors = [cable, cable_n] - conductor_names = [cable.name, cable_n.name] - default_geometries.append(GeometryBranchEquipment( - name="Default_UG_1PH_"+ "_".join(conductor_names), - conductors = conductors, - horizontal_positions = Distance([0,0.5],"m"), - vertical_positions = Distance([-1,-1],"m"))) - - conductors = [cable] - conductor_names = [cable.name] - default_geometries.append(GeometryBranchEquipment( - name="Default_UG_1PH_Delta_"+ "_".join(conductor_names), - conductors = conductors, - horizontal_positions = Distance([0],"m"), - vertical_positions = Distance([-1],"m"))) - - - self.system.add_components(*default_geometries) - def read(self, model_file, equipment_file): # Read the measurement unit - unit_type = read_synergi_data(model_file,"SAI_Control").iloc[0]["LengthUnits"] + unit_type = read_synergi_data(model_file, "SAI_Control").iloc[0]["LengthUnits"] - # Section data read separately as it links to other tables + # Build section lookup dicts and geometry conductor map section_id_sections = {} from_node_sections = {} to_node_sections = {} geometry_conductors = {} - section_data = read_synergi_data(model_file,"InstSection") + section_data = read_synergi_data(model_file, "InstSection") for idx, row in section_data.iterrows(): section_id = row["SectionId"] section_id_sections[section_id] = row from_node = row["FromNodeId"] to_node = row["ToNodeId"] - if not from_node in from_node_sections: + if from_node not in from_node_sections: from_node_sections[from_node] = [] - from_node_sections[from_node].append(row) - if not to_node in to_node_sections: + from_node_sections[from_node].append(row) + if to_node not in to_node_sections: to_node_sections[to_node] = [] - to_node_sections[to_node].append(row) + to_node_sections[to_node].append(row) geometry = row["ConfigurationId"] - phases = row["SectionPhases"].replace(' ',"") + phases = row["SectionPhases"].replace(' ', "") conductor_names = [] for phase in phases: - conductor = None - if phase == "N": - conductor = row["NeutralConductorId"] - else: - # Sample model has missing conductors for PhaseConductor2Id, PhaseConductor3Id - # Use PhaseConductorId for all non-neutral conductors - conductor = row["PhaseConductorId"] - conductor_names.append(conductor) + conductor = row["NeutralConductorId"] if phase == "N" else row["PhaseConductorId"] + conductor_names.append(conductor) if geometry not in geometry_conductors: geometry_conductors[geometry] = set() - geometry_conductors[geometry].add(tuple(conductor_names)) + geometry_conductors[geometry].add(tuple(conductor_names)) + + # Collect section IDs that have devices so LineSectionMapper can skip them + devices_on_section = set() + for device_table in ["InstSwitches", "InstBreakers", "InstFuses", "InstReclosers", + "InstPrimaryTransformers", "InstRegulators"]: + try: + dev_data = read_synergi_data(model_file, device_table) + for _, drow in dev_data.iterrows(): + sid = str(drow.get("SectionId", "")).strip() + if sid: + devices_on_section.add(sid) + except Exception: + pass + # Pre-load DevRegulators for DistributionRegulatorMapper + reg_equipment = {} + try: + for _, row in read_synergi_data(equipment_file, "DevRegulators").iterrows(): + name = str(row.get("RegulatorName", "")).strip() + if name: + reg_equipment[name] = row + except Exception: + pass + # Build node→feeder lookup for voltage and context (used by DistributionBusMapper) + feeder_data = read_synergi_data(model_file, "InstFeeders") + node_feeder_map = build_node_feeder_map(feeder_data, section_data) - # TODO: Base this off of the components in the init file for component_type in self.component_types: - if component_type == "GeometryBranchEquipment": - self.create_geometry_defaults(model_file) - - mapper_name = component_type+ "Mapper" + mapper_name = component_type + "Mapper" if not hasattr(synergi_mapper, mapper_name): logger.warning(f"Mapper for {mapper_name} not found. Skipping") continue - mapper = getattr(synergi_mapper, mapper_name)(self.system) + mapper = getattr(synergi_mapper, mapper_name)(self.system, node_feeder_map=node_feeder_map) table_name = mapper.synergi_table database = mapper.synergi_database - table_data = None if database == "Model": - table_data = read_synergi_data(model_file,table_name) + table_data = read_synergi_data(model_file, table_name) elif database == "Equipment": - table_data = read_synergi_data(equipment_file,table_name) + table_data = read_synergi_data(equipment_file, table_name) else: raise ValueError("Invalid database type") + # DistributionSubstation needs all rows at once to group by SubstationId + if component_type == "DistributionSubstation": + components = mapper.parse_all(table_data, unit_type, section_id_sections, from_node_sections, to_node_sections) + self.system.add_components(*components) + continue + components = [] - for idx,row in table_data.iterrows(): - mapper_name = component_type+ "Mapper" - if component_type == "GeometryBranchEquipment": - model_entries = mapper.parse(row, unit_type, section_id_sections, from_node_sections, to_node_sections, geometry_conductors) - for model_entry in model_entries: - components.append(model_entry) - else: - model_entry = mapper.parse(row, unit_type, section_id_sections, from_node_sections, to_node_sections) - if model_entry is not None: - components.append(model_entry) + for idx, row in table_data.iterrows(): + try: + if component_type == "GeometryBranchEquipment": + result = mapper.parse(row, unit_type, section_id_sections, from_node_sections, to_node_sections, geometry_conductors) + elif component_type == "LineSection": + result = mapper.parse(row, unit_type, section_id_sections, from_node_sections, to_node_sections, devices_on_section) + elif component_type == "DistributionRegulator": + result = mapper.parse(row, unit_type, section_id_sections, from_node_sections, to_node_sections, reg_equipment) + else: + result = mapper.parse(row, unit_type, section_id_sections, from_node_sections, to_node_sections) + + if result is None: + continue + if isinstance(result, list): + components.extend(result) + else: + components.append(result) + except Exception as e: + logger.warning(f"Failed to parse {component_type} row {idx}: {e}") self.system.add_components(*components) def get_system(self) -> DistributionSystem: - return self.system + return self.system diff --git a/src/ditto/readers/synergi/synergi_mapper.py b/src/ditto/readers/synergi/synergi_mapper.py index ac3d6d1..941b7cc 100644 --- a/src/ditto/readers/synergi/synergi_mapper.py +++ b/src/ditto/readers/synergi/synergi_mapper.py @@ -1,8 +1,27 @@ -from abc import ABC, abstractproperty +from abc import ABC +from gdm.distribution.components.distribution_feeder import DistributionFeeder +from gdm.distribution.components.distribution_substation import DistributionSubstation +from ditto.readers.synergi.utils import sanitize_name -class SynergiMapper(ABC): +class SynergiMapper(ABC): - def __init__(self, system): + def __init__(self, system, node_feeder_map=None): self.system = system + self.node_feeder_map = node_feeder_map or {} + + def _lookup_feeder_substation(self, node_id: str): + feeder = None + substation = None + feeder_info = self.node_feeder_map.get(node_id, {}) + if feeder_info: + try: + feeder = self.system.get_component(DistributionFeeder, sanitize_name(feeder_info["feeder_id"])) + except Exception: + pass + try: + substation = self.system.get_component(DistributionSubstation, sanitize_name(feeder_info["sub_id"])) + except Exception: + pass + return feeder, substation diff --git a/src/ditto/readers/synergi/utils.py b/src/ditto/readers/synergi/utils.py index b79fefa..5c21c96 100644 --- a/src/ditto/readers/synergi/utils.py +++ b/src/ditto/readers/synergi/utils.py @@ -1,3 +1,84 @@ +import math +import re +from collections import defaultdict + +from gdm.distribution.enums import Phase +from gdm.distribution.components.distribution_feeder import DistributionFeeder +from gdm.distribution.components.distribution_substation import DistributionSubstation + +_POS_MAP = {0: Phase.A, 1: Phase.B, 2: Phase.C, 3: Phase.N} +_PHASE_ORDER = {Phase.A: 0, Phase.B: 1, Phase.C: 2, Phase.N: 3} + + +def parse_phases(phase_str: str) -> list[Phase]: + """Parse a Synergi positional phase string into GDM Phase values. + + Position 0 → A, 1 → B, 2 → C, 3 → N. A space means absent. + Examples: + "ABCN" → [A, B, C, N] + " B N" → [B, N] + " C " → [C] + """ + phases = [] + for i, char in enumerate(phase_str): + if i in _POS_MAP and char.strip(): + phases.append(_POS_MAP[i]) + return phases + + +def phases_without_neutral(phases: list[Phase]) -> list[Phase]: + return [p for p in phases if p != Phase.N] + + +def sort_phases(phases: list[Phase] | set[Phase]) -> list[Phase]: + return sorted(phases, key=lambda p: _PHASE_ORDER.get(p, 99)) + + +import re + + +def sanitize_name(name: str) -> str: + name = str(name).strip() + name = name.replace(" - ", "_") + name = re.sub(r"[^\w.]", "_", name) + name = re.sub(r"_+", "_", name) + return name.strip("_") + + +def safe_float(value, default: float = 0.0) -> float: + if value is None: + return default + try: + return float(str(value).strip()) + except (ValueError, TypeError): + return default + + +def build_node_feeder_map(feeder_data, section_data) -> dict: + """Build a mapping from node_id to feeder info for voltage and context lookups. + + Returns dict[node_id, {"feeder_id": str, "nominal_kvll": float, "sub_id": str}] + """ + feeder_info = {} + for _, row in feeder_data.iterrows(): + fid = str(row["FeederId"]).strip() + nominal_kvll = safe_float(row.get("NominalKvll"), 12.47) or 12.47 + sub_id = str(row.get("SubstationId", "Unknown") or "Unknown").strip() + feeder_info[fid] = {"nominal_kvll": nominal_kvll, "sub_id": sub_id} + + node_feeder_map: dict = {} + for _, row in section_data.iterrows(): + fid = str(row.get("FeederId", "")).strip() + from_node = str(row["FromNodeId"]).strip() + to_node = str(row["ToNodeId"]).strip() + if fid in feeder_info: + info = {"feeder_id": fid, **feeder_info[fid]} + node_feeder_map.setdefault(from_node, info) + node_feeder_map.setdefault(to_node, info) + + return node_feeder_map + + # Downloading mdbtools import subprocess import platform