diff --git a/src/ditto/readers/cyme/components/distribution_capacitor.py b/src/ditto/readers/cyme/components/distribution_capacitor.py index 57ebe5c..b78d027 100644 --- a/src/ditto/readers/cyme/components/distribution_capacitor.py +++ b/src/ditto/readers/cyme/components/distribution_capacitor.py @@ -39,11 +39,17 @@ def map_phases(self, row, section_id_sections): phases = [] # Check which phases have non-zero kvar values - if "FixedKVARA" in row and float(row.get("FixedKVARA", 0.0)) > 0: + if ("FixedKVARA" in row and float(row.get("FixedKVARA", 0.0)) > 0) or ( + "SwitchedKVARA" in row and float(row.get("SwitchedKVARA", 0.0)) > 0 + ): phases.append(Phase.A) - if "FixedKVARB" in row and float(row.get("FixedKVARB", 0.0)) > 0: + if ("FixedKVARB" in row and float(row.get("FixedKVARB", 0.0)) > 0) or ( + "SwitchedKVARB" in row and float(row.get("SwitchedKVARB", 0.0)) > 0 + ): phases.append(Phase.B) - if "FixedKVARC" in row and float(row.get("FixedKVARC", 0.0)) > 0: + if ("FixedKVARC" in row and float(row.get("FixedKVARC", 0.0)) > 0) or ( + "SwitchedKVARC" in row and float(row.get("SwitchedKVARC", 0.0)) > 0 + ): phases.append(Phase.C) # Some CYME datasets (e.g. 13-node sample) encode capacitor phase in @@ -99,9 +105,12 @@ def _safe_float(self, value, default=0.0): def _phase_kvar_map_from_row(self, row): return { - Phase.A: self._safe_float(row.get("FixedKVARA", 0.0)), - Phase.B: self._safe_float(row.get("FixedKVARB", 0.0)), - Phase.C: self._safe_float(row.get("FixedKVARC", 0.0)), + Phase.A: self._safe_float(row.get("FixedKVARA", 0.0)) + + self._safe_float(row.get("SwitchedKVARA", 0.0)), + Phase.B: self._safe_float(row.get("FixedKVARB", 0.0)) + + self._safe_float(row.get("SwitchedKVARB", 0.0)), + Phase.C: self._safe_float(row.get("FixedKVARC", 0.0)) + + self._safe_float(row.get("SwitchedKVARC", 0.0)), } def _apply_aggregate_kvar_fallback(self, row, phases, phase_kvar_map): diff --git a/src/ditto/readers/cyme/components/geometry_branch.py b/src/ditto/readers/cyme/components/geometry_branch.py index 0cb5db0..6946901 100644 --- a/src/ditto/readers/cyme/components/geometry_branch.py +++ b/src/ditto/readers/cyme/components/geometry_branch.py @@ -66,7 +66,17 @@ def map_phases(self, row, section_id_sections, equipment, buses): phases.append(Phase.B) if "C" in phase: phases.append(Phase.C) - return phases + + if len(phases) == len(equipment.conductors): + return phases + elif len(phase) == len(equipment.conductors) - 1: + phases.append(Phase.N) + for bus in buses: + if bus.phases is not None and Phase.N not in bus.phases: + bus.phases.append(Phase.N) + return phases + else: + return phases def map_equipment(self, row, cyme_section): line_id = ( diff --git a/src/ditto/readers/cyme/equipment/matrix_impedance_branch_equipment.py b/src/ditto/readers/cyme/equipment/matrix_impedance_branch_equipment.py index f9b397e..9f48324 100644 --- a/src/ditto/readers/cyme/equipment/matrix_impedance_branch_equipment.py +++ b/src/ditto/readers/cyme/equipment/matrix_impedance_branch_equipment.py @@ -33,12 +33,13 @@ def _sequence_impedance_to_phase_impedance_matrix(self, r1, r0, phases=3): R = np.array([[r_s]], dtype=float) return R - def _reduce(self, matrix): - # Remove zero rows and columns to reduce matrix size to match number of phases + def _get_reduction_mask(self, matrix): non_zero_rows = ~np.all(matrix == 0, axis=1) non_zero_cols = ~np.all(matrix == 0, axis=0) - reduced_matrix = matrix[np.ix_(non_zero_rows, non_zero_cols)] - return reduced_matrix + return non_zero_rows | non_zero_cols + + def _reduce(self, matrix, mask): + return matrix[np.ix_(mask, mask)] def parse(self, row, phases): num_phases = len(phases) @@ -47,12 +48,13 @@ def parse(self, row, phases): x_matrix = self.map_x_matrix(row, num_phases) c_matrix = self.map_c_matrix(row, num_phases) ampacity = self.map_ampacity(row) + mask = self._get_reduction_mask(r_matrix) | self._get_reduction_mask(x_matrix) try: model = MatrixImpedanceBranchEquipment( name=name, - r_matrix=self._reduce(r_matrix), - x_matrix=self._reduce(x_matrix), - c_matrix=self._reduce(c_matrix), + r_matrix=self._reduce(r_matrix, mask), + x_matrix=self._reduce(x_matrix, mask), + c_matrix=self._reduce(c_matrix, mask), ampacity=ampacity, ) return model diff --git a/src/ditto/readers/cyme/reader.py b/src/ditto/readers/cyme/reader.py index b1979ac..68cec0b 100644 --- a/src/ditto/readers/cyme/reader.py +++ b/src/ditto/readers/cyme/reader.py @@ -212,9 +212,10 @@ def _parse_phase_components(self, mapper, data): return all_components def _post_process_network(self, substation_names, feeder_names): - self.assign_bus_voltages() self.serialize_parallel_branches() + self.assign_bus_voltages() + if substation_names is not None or feeder_names is not None: self.system = network_truncation( self.system, substation_names=substation_names, feeder_names=feeder_names diff --git a/src/ditto/readers/cyme/utils.py b/src/ditto/readers/cyme/utils.py index 85676f7..31d5f2e 100644 --- a/src/ditto/readers/cyme/utils.py +++ b/src/ditto/readers/cyme/utils.py @@ -1,3 +1,5 @@ +from loguru import logger + import pandas as pd from gdm.distribution.components.distribution_feeder import DistributionFeeder from gdm.distribution.components.distribution_substation import DistributionSubstation @@ -8,7 +10,7 @@ from functools import partial -def read_cyme_data( +def read_cyme_data( # noqa: C901 cyme_file, cyme_section, index_col=None, @@ -38,11 +40,7 @@ def read_cyme_data( if line.startswith(f"FORMAT_{cyme_section.replace(' ', '')}"): headers = line.split("=")[1].strip().split(",") continue - elif ( - line.startswith("FORMAT") - or line.startswith("FEEDER") - or line.startswith("SUBSTATION") - ): + elif line.startswith(("FORMAT", "FEEDER", "SUBSTATION")): feeder_id, substation_id = _parse_context_line( line, parse_feeders, parse_substation ) @@ -68,6 +66,11 @@ def read_cyme_data( raise Exception(f"Failed to parse line: {line}. Error: {e}") data = pd.DataFrame(all_data, columns=headers) + + if data.empty: + logger.info(f"No data found for section {cyme_section} in file {cyme_file}") + return data + if index_col is not None: data.set_index(index_col, inplace=True, drop=False) return data diff --git a/src/ditto/writers/opendss/components/distribution_branch.py b/src/ditto/writers/opendss/components/distribution_branch.py index a9fd852..a966a73 100644 --- a/src/ditto/writers/opendss/components/distribution_branch.py +++ b/src/ditto/writers/opendss/components/distribution_branch.py @@ -22,9 +22,8 @@ def map_buses(self): self.opendss_dict["Bus1"] = self.get_opendss_safe_name(self.model.buses[0].name) self.opendss_dict["Bus2"] = self.get_opendss_safe_name(self.model.buses[1].name) for phase in self.model.phases: - if phase != Phase.N: - self.opendss_dict["Bus1"] += self.phase_map[phase] - self.opendss_dict["Bus2"] += self.phase_map[phase] + self.opendss_dict["Bus1"] += self.phase_map[phase] + self.opendss_dict["Bus2"] += self.phase_map[phase] def map_length(self): self.opendss_dict["Length"] = self.model.length.magnitude diff --git a/src/ditto/writers/opendss/components/distribution_bus.py b/src/ditto/writers/opendss/components/distribution_bus.py index b2d0d44..b54af49 100644 --- a/src/ditto/writers/opendss/components/distribution_bus.py +++ b/src/ditto/writers/opendss/components/distribution_bus.py @@ -13,55 +13,6 @@ def __init__(self, model: Component, system: DistributionSystem): altdss_composition_name = None opendss_file = OpenDSSFileTypes.COORDINATE_FILE.value - def _bus_is_used(self) -> bool: - """Check if this bus is referenced by any component in the system. - - A bus is considered 'unused' (and should be skipped) only if: - - It's an orphaned intermediate bus created during parallel serialization - - These typically have names like "67_1_3" created from multi-phase bus splits - - AND it's not referenced by any actual component - - A bus is 'used' if: - - It's referenced by any non-DistributionBus component - - OR it has a simple name (no intermediate split pattern) - """ - bus_name = self.model.name - - # Check if this bus is referenced by other components - for component_type in self.system.get_component_types(): - # Skip DistributionBus components in the check to avoid circular references - if component_type.__name__ == "DistributionBus": - continue - - for component in self.system.get_components(component_type): - if hasattr(component, "buses"): - for bus in component.buses: - if bus.name == bus_name: - return True - elif hasattr(component, "bus") and component.bus is not None: - if component.bus.name == bus_name: - return True - - # If not referenced by components, check if it looks like an intermediate split bus - # Intermediate buses from parallel serialization have patterns like "67_1_3" or "XYZ_0_2" - # Real buses typically don't have this pattern - # If it doesn't match the orphan pattern, consider it used - parts = bus_name.split("_") - # If bus has 2+ underscores and middle parts are single digits, it's likely intermediate - if len(parts) >= 3 and all(p.isdigit() and len(p) == 1 for p in parts[1:-1]): - # This looks like an intermediate bus (e.g., "67_1_3") - mark as unused - return False - - # Otherwise consider it used (e.g., standalone buses or normal named buses) - return True - - def populate_opendss_dictionary(self): - """Skip writing coordinates for unused intermediate buses created during serialization.""" - if not self._bus_is_used(): - # Return empty dict so this bus doesn't get written to BusCoords.dss - return - super().populate_opendss_dictionary() - def map_name(self): self.opendss_dict["Name"] = self.get_opendss_safe_name(self.model.name) diff --git a/src/ditto/writers/opendss/opendss_mapper.py b/src/ditto/writers/opendss/opendss_mapper.py index 3df1342..fcb33c6 100644 --- a/src/ditto/writers/opendss/opendss_mapper.py +++ b/src/ditto/writers/opendss/opendss_mapper.py @@ -19,6 +19,7 @@ class OpenDSSMapper(ABC): "millimeter": "mm", } connection_map = {"STAR": "wye", "DELTA": "delta", "OPEN_DELTA": "delta", "OPEN_STAR": "wye"} + _warned_unsafe_chars = False @property @abstractmethod @@ -95,9 +96,15 @@ def get_opendss_safe_name(self, name: str) -> str: not_safe_characters = [" ", ".", "=", "!", "[", "]", "{", "}", "@", "%", "~"] + needs_fix = any(char in name for char in not_safe_characters) + if needs_fix and not OpenDSSMapper._warned_unsafe_chars: + logger.warning( + "Some component names contain characters unsafe for OpenDSS and will be sanitized." + ) + OpenDSSMapper._warned_unsafe_chars = True + for char in not_safe_characters: if char in name: - logger.debug(f"Replacing {char} in {name} with _ for OpenDSS compatibility.") name = name.replace(char, "_") return name diff --git a/tests/test_cyme/test_cyme_roundtrip_conversion.py b/tests/test_cyme/test_cyme_roundtrip_conversion.py index b278e0f..d2b2c3c 100644 --- a/tests/test_cyme/test_cyme_roundtrip_conversion.py +++ b/tests/test_cyme/test_cyme_roundtrip_conversion.py @@ -56,6 +56,8 @@ def test_cyme_to_opendss_ieee13(tmp_path): reference_metrics, converted_metrics, rtol=0.01, atol=0.01 ), f"CYME-to-OpenDSS conversion metrics differ from reference.\n{comparison.T}" + cyme_reader.get_system().kron_reduce() + def test_cyme_to_opendss_ieee123(tmp_path): """Convert CYME ieee_123_node to OpenDSS in .dump/converted and compare metrics.""" @@ -102,3 +104,5 @@ def test_cyme_to_opendss_ieee123(tmp_path): assert np.allclose( reference_metrics, converted_metrics, rtol=0.01, atol=0.01 ), f"CYME-to-OpenDSS conversion metrics differ from reference.\n{comparison.T}" + + cyme_reader.get_system().kron_reduce()