from dataclasses import dataclass
from typing import Any
import networkx as nx
import numpy as np
from npap.exceptions import PartitioningError, ValidationError
from npap.interfaces import PartitioningStrategy
from npap.logging import LogCategory, log_debug, log_info, log_warning
from npap.utils import (
compute_geographical_distances,
create_partition_map,
run_hierarchical,
run_kmedoids,
validate_partition,
validate_required_attributes,
with_runtime_config,
)
[docs]
@dataclass
class VAGeographicalConfig:
"""
Configuration for voltage-aware geographical partitioning.
Attributes
----------
voltage_tolerance : float
Tolerance for voltage comparison (kV). Nodes with voltages within
this tolerance are considered the same voltage level. Default is 1.0 kV.
infinite_distance : float
Value used to represent "infinite" distance between nodes at different
voltage levels or AC islands. Using a large finite value instead of
np.inf to avoid numerical issues in clustering algorithms.
proportional_clustering : bool
If False (default), runs clustering on full matrix with infinite
distances between voltage levels. If True, clusters each voltage
island independently with proportional cluster distribution.
hierarchical_linkage : str
Linkage criterion for hierarchical clustering. Must be 'complete',
'average', or 'single'. Note: 'ward' is NOT supported with precomputed
distances. Default is 'complete' which works well with infinite
distances (prevents cross-voltage merging).
"""
voltage_tolerance: float = 1.0
infinite_distance: float = 1e4
proportional_clustering: bool = False
hierarchical_linkage: str = "complete"
[docs]
class VAGeographicalPartitioning(PartitioningStrategy):
"""
Voltage-Aware Geographical Partitioning Strategy with AC Island Support.
This strategy partitions nodes based on geographical distance while
respecting both AC island boundaries and voltage level boundaries.
Notes
-----
**Constraint Hierarchy**
1. AC Islands: Nodes in different AC islands are assigned infinite distance.
AC islands represent separate AC networks connected only via DC links.
2. Voltage Levels: Within each AC island, nodes at different voltage levels
are also assigned infinite distance.
This ensures clustering only occurs within the same AC island AND
the same voltage level, which is physically meaningful for power networks.
**Clustering Modes**
Two clustering modes are available:
*Standard mode* (default):
- Builds full NxN distance matrix
- Sets d(i,j) = inf if ac_island(i) != ac_island(j) OR voltage(i) != voltage(j)
- Runs single clustering algorithm on entire matrix
- Algorithm handles infinite distances to respect boundaries
*Proportional mode*:
- Groups nodes by (ac_island, voltage_level) combination
- Distributes n_clusters proportionally among groups
- Runs clustering independently on each group
- Guaranteed balanced distribution per group
**Supported Algorithms**
- ``kmedoids``: K-Medoids clustering (works naturally with precomputed distances)
- ``hierarchical``: Agglomerative clustering with precomputed distances
(uses 'complete' linkage by default, configurable)
**Configuration**
Configuration can be provided at:
- Instantiation time (via ``config`` parameter in __init__)
- Partition time (via ``config`` or individual parameters in partition())
Partition-time parameters override instance defaults for that call only.
"""
SUPPORTED_ALGORITHMS = ["kmedoids", "hierarchical"]
SUPPORTED_DISTANCE_METRICS = ["euclidean", "haversine"]
SUPPORTED_LINKAGES = ["complete", "average", "single"]
# Config parameter names for runtime override detection
_CONFIG_PARAMS = {
"voltage_tolerance",
"infinite_distance",
"proportional_clustering",
"hierarchical_linkage",
}
[docs]
def __init__(
self,
algorithm: str = "kmedoids",
distance_metric: str = "euclidean",
voltage_attr: str = "voltage",
ac_island_attr: str = "ac_island",
config: VAGeographicalConfig | None = None,
):
"""
Initialize voltage-aware geographical partitioning strategy.
Parameters
----------
algorithm : str, default='kmedoids'
Clustering algorithm ('kmedoids', 'hierarchical').
distance_metric : str, default='euclidean'
Distance metric ('euclidean', 'haversine').
voltage_attr : str, default='voltage'
Node attribute name containing voltage level.
ac_island_attr : str, default='ac_island'
Node attribute name containing AC island ID.
config : VAGeographicalConfig, optional
Configuration parameters for the algorithm.
Raises
------
ValueError
If unsupported algorithm, distance metric, or linkage.
"""
self.algorithm = algorithm
self.distance_metric = distance_metric
self.voltage_attr = voltage_attr
self.ac_island_attr = ac_island_attr
self.config = config or VAGeographicalConfig()
if algorithm not in self.SUPPORTED_ALGORITHMS:
raise ValueError(
f"Unsupported algorithm: {algorithm}. "
f"Supported: {', '.join(self.SUPPORTED_ALGORITHMS)}"
)
if distance_metric not in self.SUPPORTED_DISTANCE_METRICS:
raise ValueError(
f"Unsupported distance metric: {distance_metric}. "
f"Supported: {', '.join(self.SUPPORTED_DISTANCE_METRICS)}"
)
if self.config.hierarchical_linkage not in self.SUPPORTED_LINKAGES:
raise ValueError(
f"Unsupported hierarchical linkage: {self.config.hierarchical_linkage}. "
f"Supported: {', '.join(self.SUPPORTED_LINKAGES)}. "
"Note: 'ward' linkage is not supported with precomputed distance matrices."
)
log_debug(
f"Initialized VAGeographicalPartitioning: algorithm={algorithm}, "
f"metric={distance_metric}, proportional={self.config.proportional_clustering}",
LogCategory.PARTITIONING,
)
@property
def required_attributes(self) -> dict[str, list[str]]:
"""Required node attributes for voltage-aware geographical partitioning."""
return {
"nodes": ["lat", "lon", self.voltage_attr, self.ac_island_attr],
"edges": [],
}
def _get_strategy_name(self) -> str:
"""Get descriptive strategy name for error messages."""
mode = "proportional" if self.config.proportional_clustering else "standard"
return f"va_geographical_{mode}_{self.algorithm}"
[docs]
@with_runtime_config(VAGeographicalConfig, _CONFIG_PARAMS)
@validate_required_attributes
def partition(self, graph: nx.Graph, **kwargs) -> dict[int, list[Any]]:
"""
Partition nodes based on AC island and voltage-aware geographical distance.
Parameters
----------
graph : nx.Graph
NetworkX graph with lat, lon, voltage, and ac_island attributes.
**kwargs : dict
Additional parameters:
- n_clusters : Number of clusters (required)
- random_state : Random seed for reproducibility (default: 42)
- max_iter : Maximum iterations for clustering (default: 300)
- config : VAGeographicalConfig instance to override instance config
- voltage_tolerance : Override config parameter
- infinite_distance : Override config parameter
- proportional_clustering : Override config parameter
- hierarchical_linkage : Override config parameter
Returns
-------
dict[int, list[Any]]
Dictionary mapping cluster_id -> list of node_ids.
Raises
------
PartitioningError
If partitioning fails.
"""
try:
# Get effective config (injected by decorator)
effective_config = kwargs.get("_effective_config", self.config)
# Validate hierarchical_linkage if overridden
if effective_config.hierarchical_linkage not in self.SUPPORTED_LINKAGES:
raise PartitioningError(
f"Unsupported hierarchical linkage: {effective_config.hierarchical_linkage}. "
f"Supported: {', '.join(self.SUPPORTED_LINKAGES)}",
strategy=self._get_strategy_name(),
)
n_clusters = kwargs.get("n_clusters")
if n_clusters is None or n_clusters <= 0:
raise PartitioningError(
"Voltage-aware geographical partitioning requires a positive 'n_clusters' parameter.",
strategy=self._get_strategy_name(),
)
# Extract node data
nodes = list(graph.nodes())
n_nodes = len(nodes)
if n_clusters > n_nodes:
raise PartitioningError(
f"Cannot create {n_clusters} clusters from {n_nodes} nodes.",
strategy=self._get_strategy_name(),
)
# Extract coordinates, voltages, and AC islands
coordinates, voltages, ac_islands = self._extract_node_data(graph, nodes)
# Get unique groups summary for validation
n_groups = self._count_unique_groups(ac_islands, voltages, effective_config)
# Count unique voltage levels using tolerance-based rounding
voltage_keys = set()
for v in voltages:
if v is not None and isinstance(v, (int, float)):
tol = max(effective_config.voltage_tolerance, 0.1)
voltage_keys.add(round(float(v) / tol) * tol)
else:
voltage_keys.add(v)
if len(voltage_keys) <= 1:
raise ValidationError(
"Voltage-aware geographical partitioning requires multiple voltage "
"levels, but only one was found. Use standard GeographicalPartitioning "
"for single-voltage networks.",
strategy=self._get_strategy_name(),
)
# Log summary
self._log_group_summary(ac_islands, voltages, n_groups)
if n_clusters < n_groups:
log_warning(
f"Requested {n_clusters} clusters but found {n_groups} "
f"distinct (ac_island, voltage_level) groups. Some groups may share clusters, "
f"but infinite distance constraints will be respected.",
LogCategory.PARTITIONING,
warn_user=False,
)
log_info(
f"Starting VA geographical partitioning: {self.algorithm}, "
f"n_clusters={n_clusters}, groups={n_groups}",
LogCategory.PARTITIONING,
)
# Choose clustering mode based on configuration
if effective_config.proportional_clustering:
partition_map = self._proportional_partition(
nodes, coordinates, voltages, ac_islands, effective_config, **kwargs
)
else:
partition_map = self._standard_partition(
nodes, coordinates, voltages, ac_islands, effective_config, **kwargs
)
# Validate using utility function
validate_partition(partition_map, n_nodes, self._get_strategy_name())
# Validate AC island and voltage consistency
self._validate_cluster_consistency(graph, partition_map, effective_config)
log_info(
f"VA geographical partitioning complete: {len(partition_map)} clusters",
LogCategory.PARTITIONING,
)
return partition_map
except Exception as e:
if isinstance(e, (PartitioningError, ValidationError)):
raise
raise PartitioningError(
f"Voltage-aware geographical partitioning failed: {e}",
strategy=self._get_strategy_name(),
graph_info={
"nodes": len(list(graph.nodes())),
"edges": len(graph.edges()),
},
) from e
def _standard_partition(
self,
nodes: list[Any],
coordinates: np.ndarray,
voltages: np.ndarray,
ac_islands: np.ndarray,
config: VAGeographicalConfig,
**kwargs,
) -> dict[int, list[Any]]:
"""
Partition using single clustering on full matrix with infinite distances.
This mode builds a distance matrix where nodes in different AC islands
OR different voltage levels have infinite distance.
Parameters
----------
nodes : list[Any]
List of node IDs.
coordinates : np.ndarray
Node coordinates [n x 2].
voltages : np.ndarray
Node voltage values.
ac_islands : np.ndarray
Node AC island IDs.
config : VAGeographicalConfig
Configuration instance.
**kwargs : dict
Clustering parameters.
Returns
-------
dict[int, list[Any]]
Partition mapping.
"""
log_debug("Using standard partitioning mode", LogCategory.PARTITIONING)
distance_matrix = self._build_aware_distance_matrix(
coordinates, voltages, ac_islands, config
)
# Run clustering algorithm
labels = self._run_clustering(distance_matrix, config, **kwargs)
# Create partition mapping using utility function
return create_partition_map(nodes, labels)
def _proportional_partition(
self,
nodes: list[Any],
coordinates: np.ndarray,
voltages: np.ndarray,
ac_islands: np.ndarray,
config: VAGeographicalConfig,
**kwargs,
) -> dict[int, list[Any]]:
"""
Partition each (ac_island, voltage) group independently with proportional distribution.
Parameters
----------
nodes : list[Any]
List of node IDs.
coordinates : np.ndarray
Node coordinates [n x 2].
voltages : np.ndarray
Node voltage values.
ac_islands : np.ndarray
Node AC island IDs.
config : VAGeographicalConfig
Configuration instance.
**kwargs : dict
Clustering parameters.
Returns
-------
dict[int, list[Any]]
Partition mapping.
"""
log_debug("Using proportional partitioning mode", LogCategory.PARTITIONING)
n_clusters = kwargs.get("n_clusters")
# Group nodes by (ac_island, voltage)
groups = self._group_by_island_and_voltage(ac_islands, voltages, config)
# Allocate clusters proportionally
allocation = self._allocate_clusters(groups, n_clusters)
log_debug(f"Cluster allocation: {allocation}", LogCategory.PARTITIONING)
partition_map: dict[int, list[Any]] = {}
cluster_offset = 0
for group_key, node_indices in groups.items():
n_clust = allocation[group_key]
group_nodes = [nodes[i] for i in node_indices]
group_coords = coordinates[node_indices]
# Handle edge cases: more clusters than nodes
if len(group_nodes) <= n_clust:
for node_id in group_nodes:
partition_map[cluster_offset] = [node_id]
cluster_offset += 1
continue
# Cluster this group using geographical distances only
distances = compute_geographical_distances(group_coords, self.distance_metric)
labels = self._run_clustering(
distances,
config,
n_clusters=n_clust,
random_state=kwargs.get("random_state", 42),
max_iter=kwargs.get("max_iter", 300),
)
for local_idx, label in enumerate(labels):
global_id = cluster_offset + int(label)
if global_id not in partition_map:
partition_map[global_id] = []
partition_map[global_id].append(group_nodes[local_idx])
cluster_offset += n_clust
return partition_map
def _run_clustering(
self, distance_matrix: np.ndarray, config: VAGeographicalConfig, **kwargs
) -> np.ndarray:
"""
Dispatch to appropriate clustering algorithm using utility functions.
Parameters
----------
distance_matrix : np.ndarray
Precomputed distance matrix (n x n).
config : VAGeographicalConfig
Configuration instance.
**kwargs : dict
Must include 'n_clusters'.
Returns
-------
np.ndarray
Array of cluster labels.
"""
n_clusters = kwargs.get("n_clusters")
if self.algorithm == "kmedoids":
return run_kmedoids(distance_matrix, n_clusters)
elif self.algorithm == "hierarchical":
return run_hierarchical(distance_matrix, n_clusters, config.hierarchical_linkage)
else:
raise PartitioningError(
f"Unknown algorithm: {self.algorithm}",
strategy=self._get_strategy_name(),
)
# =========================================================================
# DATA EXTRACTION METHODS
# =========================================================================
def _extract_node_data(
self, graph: nx.Graph, nodes: list[Any]
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Extract coordinates, voltage levels, and AC island IDs from nodes.
Parameters
----------
graph : nx.Graph
NetworkX graph.
nodes : list[Any]
List of node IDs.
Returns
-------
tuple[np.ndarray, np.ndarray, np.ndarray]
Tuple of (coordinates array [n x 2], voltages array [n], ac_islands array [n]).
Raises
------
PartitioningError
If required attributes are missing.
"""
coordinates = []
voltages = []
ac_islands = []
for node in nodes:
node_data = graph.nodes[node]
lat = node_data.get("lat")
lon = node_data.get("lon")
if lat is None or lon is None:
raise PartitioningError(
f"Node {node} missing latitude or longitude",
strategy=self._get_strategy_name(),
graph_info={"nodes": len(nodes)},
)
coordinates.append([lat, lon])
voltages.append(node_data.get(self.voltage_attr))
ac_island = node_data.get(self.ac_island_attr)
if ac_island is None:
raise PartitioningError(
f"Node {node} missing '{self.ac_island_attr}' attribute. "
"Ensure the graph was loaded with VoltageAwareStrategy (va_loader).",
strategy=self._get_strategy_name(),
graph_info={"nodes": len(nodes)},
)
ac_islands.append(ac_island)
return (
np.array(coordinates),
np.array(voltages, dtype=object),
np.array(ac_islands),
)
def _group_by_island_and_voltage(
self, ac_islands: np.ndarray, voltages: np.ndarray, config: VAGeographicalConfig
) -> dict[tuple[Any, Any], list[int]]:
"""
Group node indices by (ac_island, voltage_level) combination.
Parameters
----------
ac_islands : np.ndarray
Array of AC island IDs.
voltages : np.ndarray
Array of voltage values.
config : VAGeographicalConfig
Configuration instance.
Returns
-------
dict[tuple[Any, Any], list[int]]
Dict mapping (ac_island, voltage_level) -> list of node indices.
"""
groups: dict[tuple[Any, Any], list[int]] = {}
for idx in range(len(ac_islands)):
ac_island = ac_islands[idx]
voltage = voltages[idx]
# Find matching group key
matched_key = None
for existing_key in groups.keys():
existing_island, existing_voltage = existing_key
if self._islands_compatible(
ac_island, existing_island
) and self._voltages_compatible(voltage, existing_voltage, config):
matched_key = existing_key
break
if matched_key is not None:
groups[matched_key].append(idx)
else:
# Create new group key
island_key = ac_island if ac_island is not None else -1
voltage_key = voltage if voltage is not None else "unknown"
groups[(island_key, voltage_key)] = [idx]
return groups
@staticmethod
def _allocate_clusters(
groups: dict[tuple[Any, Any], list[int]], n_clusters: int
) -> dict[tuple[Any, Any], int]:
"""
Allocate clusters proportionally to groups.
Parameters
----------
groups : dict[tuple[Any, Any], list[int]]
Dict mapping (ac_island, voltage) -> node indices.
n_clusters : int
Total clusters to allocate.
Returns
-------
dict[tuple[Any, Any], int]
Dict mapping (ac_island, voltage) -> number of clusters.
"""
total_nodes = sum(len(indices) for indices in groups.values())
allocation: dict[tuple[Any, Any], int] = {}
# Sort by size (largest first) for stable allocation
sorted_keys = sorted(groups.keys(), key=lambda k: len(groups[k]), reverse=True)
remaining = n_clusters
for i, group_key in enumerate(sorted_keys):
n_nodes = len(groups[group_key])
if i == len(sorted_keys) - 1:
# Last group gets remaining clusters
allocation[group_key] = max(1, remaining)
else:
# Proportional allocation
proportion = n_nodes / total_nodes
allocated = max(1, int(round(n_clusters * proportion)))
allocated = min(allocated, n_nodes, remaining - (len(sorted_keys) - i - 1))
allocation[group_key] = allocated
remaining -= allocated
return allocation
@staticmethod
def _count_unique_groups(
ac_islands: np.ndarray, voltages: np.ndarray, config: VAGeographicalConfig
) -> int:
"""
Count unique (ac_island, voltage_level) combinations.
Parameters
----------
ac_islands : np.ndarray
Array of AC island IDs.
voltages : np.ndarray
Array of voltage values.
config : VAGeographicalConfig
Configuration instance.
Returns
-------
int
Number of unique groups.
"""
seen_groups = set()
for i in range(len(ac_islands)):
ac_island = ac_islands[i]
voltage = voltages[i]
# Create a hashable group key
# Round voltage for tolerance-based matching
if voltage is not None and isinstance(voltage, (int, float)):
voltage_key = round(voltage / max(config.voltage_tolerance, 0.1)) * max(
config.voltage_tolerance, 0.1
)
else:
voltage_key = voltage
group_key = (ac_island, voltage_key)
seen_groups.add(group_key)
return len(seen_groups)
# =========================================================================
# DISTANCE MATRIX METHODS
# =========================================================================
def _build_aware_distance_matrix(
self,
coordinates: np.ndarray,
voltages: np.ndarray,
ac_islands: np.ndarray,
config: VAGeographicalConfig,
) -> np.ndarray:
"""
Build distance matrix with AC island and voltage awareness.
For nodes in the same AC island AND same voltage level (within tolerance),
uses geographical distance. Otherwise, assigns infinite distance.
Constraint hierarchy:
1. Different AC islands -> infinite distance
2. Same AC island, different voltage -> infinite distance
3. Same AC island, same voltage -> geographical distance
Parameters
----------
coordinates : np.ndarray
Array of [lat, lon] coordinates (n x 2).
voltages : np.ndarray
Array of voltage levels (n).
ac_islands : np.ndarray
Array of AC island IDs (n).
config : VAGeographicalConfig
Configuration instance.
Returns
-------
np.ndarray
Distance matrix (n x n) where:
- d[i,j] = geographical_distance if same AC island AND same voltage
- d[i,j] = infinite_distance otherwise
- d[i,i] = 0 (diagonal)
"""
n_nodes = len(coordinates)
# Calculate geographical distances using utility function
geo_distances = compute_geographical_distances(coordinates, self.distance_metric)
# same_ac_island[i,j] = True if ac_islands[i] == ac_islands[j]
same_ac_island = ac_islands[:, np.newaxis] == ac_islands[np.newaxis, :]
# Handle None AC islands (incompatible with everything)
dc_not_none = np.array([island is not None for island in ac_islands])
dc_both_valid = dc_not_none[:, np.newaxis] & dc_not_none[np.newaxis, :]
dc_compatible = same_ac_island & dc_both_valid
# Voltage compatibility mask
voltage_compatible = self._build_voltage_compatibility_mask(voltages, config)
# Combine masks
compatible_mask = dc_compatible & voltage_compatible
# Build distance matrix using vectorized where
distance_matrix = np.where(compatible_mask, geo_distances, config.infinite_distance)
np.fill_diagonal(distance_matrix, 0.0)
# Log statistics (count compatible pairs, excluding diagonal)
compatible_pairs = (np.sum(compatible_mask) - n_nodes) // 2
log_debug(
f"Built aware distance matrix: {compatible_pairs} compatible pairs",
LogCategory.PARTITIONING,
)
return distance_matrix
@staticmethod
def _build_voltage_compatibility_mask(
voltages: np.ndarray, config: VAGeographicalConfig
) -> np.ndarray:
"""
Build voltage compatibility mask.
Handles three cases:
1. Both numeric: compatible if within tolerance
2. Both non-numeric (not None): compatible if exact match
3. None values: incompatible with everything
Parameters
----------
voltages : np.ndarray
Array of voltage values.
config : VAGeographicalConfig
Configuration instance.
Returns
-------
np.ndarray
Boolean mask (n x n) where True indicates voltage compatibility.
"""
# Categorize voltages
is_numeric = np.array([isinstance(v, (int, float)) for v in voltages])
is_none = np.array([v is None for v in voltages])
# None values are incompatible with everything
neither_none = ~is_none[:, np.newaxis] & ~is_none[np.newaxis, :]
# For numeric values: tolerance-based comparison
float_voltages = np.array(
[float(v) if isinstance(v, (int, float)) else 0.0 for v in voltages],
dtype=np.float64,
)
voltage_diff = np.abs(float_voltages[:, np.newaxis] - float_voltages[np.newaxis, :])
numeric_compatible = voltage_diff <= config.voltage_tolerance
both_numeric = is_numeric[:, np.newaxis] & is_numeric[np.newaxis, :]
# Initialize with numeric compatibility for numeric pairs
voltage_compatible = both_numeric & numeric_compatible
# Handle non-numeric values (not None): exact equality required
is_non_numeric = ~is_numeric & ~is_none
if np.any(is_non_numeric):
# For non-numeric pairs, check exact equality
both_non_numeric = is_non_numeric[:, np.newaxis] & is_non_numeric[np.newaxis, :]
if np.any(both_non_numeric):
# Build equality mask for non-numeric values only
non_numeric_indices = np.where(is_non_numeric)[0]
for i in non_numeric_indices:
for j in non_numeric_indices:
if voltages[i] == voltages[j]:
voltage_compatible[i, j] = True
# Apply None mask
voltage_compatible &= neither_none
return voltage_compatible
# =========================================================================
# COMPATIBILITY CHECK METHODS
# =========================================================================
@staticmethod
def _islands_compatible(island1: Any, island2: Any) -> bool:
"""
Check if two AC island IDs are compatible (same island).
Parameters
----------
island1 : Any
First AC island ID.
island2 : Any
Second AC island ID.
Returns
-------
bool
True if islands are the same, False otherwise.
"""
# Handle missing AC island IDs - isolated nodes
if island1 is None or island2 is None:
return False
# Direct comparison (AC island IDs should be integers from component detection)
return island1 == island2
@staticmethod
def _voltages_compatible(v1: Any, v2: Any, config: VAGeographicalConfig) -> bool:
"""
Check if two voltage levels are compatible (same voltage island).
Parameters
----------
v1 : Any
First voltage value.
v2 : Any
Second voltage value.
config : VAGeographicalConfig
Configuration instance.
Returns
-------
bool
True if voltages are compatible (within tolerance), False otherwise.
"""
# Handle missing voltages - nodes without voltage are isolated
if v1 is None or v2 is None:
return False
# Handle non-numeric voltages (exact match required)
if not isinstance(v1, (int, float)) or not isinstance(v2, (int, float)):
return v1 == v2
return abs(float(v1) - float(v2)) <= config.voltage_tolerance
# =========================================================================
# LOGGING AND VALIDATION METHODS
# =========================================================================
@staticmethod
def _log_group_summary(ac_islands: np.ndarray, voltages: np.ndarray, n_groups: int) -> None:
"""Log summary of (ac_island, voltage_level) groups found."""
n_islands = len(set(ac_islands))
n_voltages = len(set(voltages))
log_info(
f"Voltage-aware partitioning: {n_islands} AC island(s), "
f"{n_voltages} voltage level(s) -> {n_groups} group(s)",
LogCategory.PARTITIONING,
)
def _validate_cluster_consistency(
self,
graph: nx.Graph,
partition_map: dict[int, list[Any]],
config: VAGeographicalConfig,
) -> None:
"""
Validate that clusters don't mix incompatible AC islands or voltage levels.
With infinite distances, clusters should never mix:
1. Different AC islands
2. Different voltage levels within the same AC island
Parameters
----------
graph : nx.Graph
Original NetworkX graph.
partition_map : dict[int, list[Any]]
Resulting partition mapping.
config : VAGeographicalConfig
Configuration instance.
"""
for cluster_id, nodes in partition_map.items():
ac_islands_in_cluster = set()
voltages_in_cluster = set()
for node in nodes:
node_data = graph.nodes[node]
# Check AC island
ac_island = node_data.get(self.ac_island_attr)
if ac_island is not None:
ac_islands_in_cluster.add(ac_island)
# Check voltage
v = node_data.get(self.voltage_attr)
if v is None:
v = node_data.get("voltage", node_data.get("v_nom"))
if v is not None and isinstance(v, (int, float)):
v_rounded = round(v / max(config.voltage_tolerance, 0.1)) * max(
config.voltage_tolerance, 0.1
)
voltages_in_cluster.add(v_rounded)
elif v is not None:
voltages_in_cluster.add(v)
# Check for AC island mixing
if len(ac_islands_in_cluster) > 1:
log_warning(
f"Cluster {cluster_id} contains nodes from multiple AC islands: "
f"{ac_islands_in_cluster}. This should not happen with infinite distances.",
LogCategory.PARTITIONING,
warn_user=False,
)
# Check for voltage mixing
if len(voltages_in_cluster) > 1:
log_warning(
f"Cluster {cluster_id} contains multiple voltage levels: {voltages_in_cluster}.",
LogCategory.PARTITIONING,
warn_user=False,
)