Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions src/ditto/readers/cyme/components/distribution_capacitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
12 changes: 11 additions & 1 deletion src/ditto/readers/cyme/components/geometry_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/ditto/readers/cyme/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions src/ditto/readers/cyme/utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
)
Expand All @@ -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
Expand Down
5 changes: 2 additions & 3 deletions src/ditto/writers/opendss/components/distribution_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 0 additions & 49 deletions src/ditto/writers/opendss/components/distribution_bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 8 additions & 1 deletion src/ditto/writers/opendss/opendss_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions tests/test_cyme/test_cyme_roundtrip_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Loading