diff --git a/README.md b/README.md index 9e36d8c..c374b48 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ For comprehensive guides, API reference, and tutorials, visit the official docum - **Multiple Partitioning Algorithms** - K-means, K-medoids, DBSCAN, HDBSCAN, and hierarchical clustering - **Distance Metrics** - Euclidean for local coordinates, Haversine for geographic data - **Electrical Distance** - Partition based on PTDF-derived electrical proximity +- **Price-Based Clustering** - Partition based on Locational Marginal Price (LMP) profiles - **Voltage-Aware Clustering** - Respects voltage levels and transformer boundaries - **Flexible Aggregation** - Sum, average, or custom strategies for node/edge properties - **Extensible Design** - Easy to add your own partitioning or aggregation strategies diff --git a/docs/api/partitioning.rst b/docs/api/partitioning.rst index 1387439..437c5e3 100644 --- a/docs/api/partitioning.rst +++ b/docs/api/partitioning.rst @@ -33,3 +33,10 @@ Voltage-Aware Partitioning :members: :show-inheritance: :no-index: + +Locational Marginal Price Partitioning +-------------------------------------- +.. automodule:: npap.partitioning.locational_marginal_price + :members: + :show-inheritance: + :no-index: diff --git a/docs/user-guide/available-strategies.md b/docs/user-guide/available-strategies.md index 11aa856..4b32f2f 100644 --- a/docs/user-guide/available-strategies.md +++ b/docs/user-guide/available-strategies.md @@ -164,29 +164,43 @@ Combines electrical distance (PTDF approach) with voltage level and AC island co **Required attributes**: Nodes: `voltage`, `ac_island` | Edges: `x` (reactance) +### Locational Marginal Price Partitioning + +Clusters nodes based on nodal prices (LMP) or price profiles. + +| Strategy | Algorithm | Description | +|----------|-----------|-------------| +| `lmp_hierarchical_connected` | Hierarchical | Spatially contiguous price-based clustering | + +**Required attributes**: Nodes: `lmp` + ### Choosing a Partitioning Strategy ```{mermaid} flowchart TD A[Start] --> B{Multi-voltage?} B -->|Yes| C{Need electrical distance?} - B -->|No| D{Need electrical distance?} - C -->|Yes| E[va_electrical_*] - C -->|No| F[va_geographical_*] - D -->|Yes| G[electrical_*] - D -->|No| H{AC islands?} - H -->|Yes| I[geographical_kmedoids_*] - H -->|No| L[geographical_*] + B -->|No| D{Price-based?} + D -->|Yes| E[lmp_*] + D -->|No| F{Need electrical distance?} + C -->|Yes| G[va_electrical_*] + C -->|No| H[va_geographical_*] + F -->|Yes| I[electrical_*] + F -->|No| J{AC islands?} + J -->|Yes| K[geographical_kmedoids_*] + J -->|No| L[geographical_*] style A fill:#2993B5,stroke:#1d6f8a,color:#fff style B fill:#FFBF00,stroke:#cc9900,color:#1e293b style C fill:#FFBF00,stroke:#cc9900,color:#1e293b style D fill:#FFBF00,stroke:#cc9900,color:#1e293b style E fill:#0fad6b,stroke:#076b3f,color:#fff - style F fill:#0fad6b,stroke:#076b3f,color:#fff + style F fill:#FFBF00,stroke:#cc9900,color:#1e293b style G fill:#0fad6b,stroke:#076b3f,color:#fff - style H fill:#FFBF00,stroke:#cc9900,color:#1e293b + style H fill:#0fad6b,stroke:#076b3f,color:#fff style I fill:#0fad6b,stroke:#076b3f,color:#fff + style J fill:#FFBF00,stroke:#cc9900,color:#1e293b + style K fill:#0fad6b,stroke:#076b3f,color:#fff style L fill:#0fad6b,stroke:#076b3f,color:#fff ``` diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 11f0954..f39ecfa 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -142,11 +142,12 @@ G = nx.MultiDiGraph() Different strategies require specific node and edge attributes: -| Strategy Type | Node Attributes | Edge Attributes | -|---------------|-----------------|-----------------| -| Geographical | `lat`, `lon` | — | -| Electrical | `ac_island` | `x` (reactance) | -| Voltage-Aware | `lat`, `lon`, `voltage`, `ac_island` | `x`, `type` | +| Strategy Type | Node Attributes | Edge Attributes | +|---------------------------|-----------------|-----------------| +| Geographical | `lat`, `lon` | — | +| Electrical | `ac_island` | `x` (reactance) | +| Voltage-Aware | `lat`, `lon`, `voltage`, `ac_island` | `x`, `type` | +| Locational Marginal Price | `lmp` | — | ## Error Handling diff --git a/docs/user-guide/partitioning/index.md b/docs/user-guide/partitioning/index.md index 1feb5ce..31a893d 100644 --- a/docs/user-guide/partitioning/index.md +++ b/docs/user-guide/partitioning/index.md @@ -4,12 +4,13 @@ Partitioning divides a network into clusters of nodes based on distance metrics. ## Overview -| Strategy Family | Distance Metric | Use Case | -|-----------------|----------------------------|-------------------------------------------| -| Geographical | Euclidean / Haversine | Spatial proximity clustering | -| Electrical | PTDF-based | Electrical behavior clustering | -| VA Geographical | Geographical + Voltage aware | Multi-voltage-level networks | -| VA Electrical | PTDF + Voltage aware | Multi-voltage-level electrical clustering | +| Strategy Family | Distance Metric | Use Case | +|----------------------------------|-----------------------------------|-------------------------------------------| +| Geographical | Euclidean / Haversine | Spatial proximity clustering | +| Electrical | PTDF-based | Electrical behavior clustering | +| VA Geographical | Geographical + Voltage aware | Multi-voltage-level networks | +| VA Electrical | PTDF + Voltage aware | Multi-voltage-level electrical clustering | +| Locational Marginal Prices (LMP) | Duals of nodal balance constraint | Congestion sensitive clustering | ## Basic Usage @@ -47,6 +48,7 @@ print(partition_result.strategy_name) geographical electrical +locational_marginal_price va-geographical va-electrical ``` @@ -114,13 +116,14 @@ flowchart TD ### Recommendations by Use Case -| Use Case | Recommended Strategy | -|------------------------------|---------------------| -| Geographical clustering | `geographical_kmeans` | -| Geographical with AC islands | `geographical_kmedoids_haversine` | -| Electrical behavior grouping | `electrical_kmedoids` | -| Multi-voltage network | `va_geographical_kmedoids_haversine` | -| Unknown cluster count | `geographical_dbscan_*` or `geographical_hdbscan_*` | +| Use Case | Recommended Strategy | +|---------------------------------|-----------------------------------------------------| +| Geographical clustering | `geographical_kmeans` | +| Geographical with AC islands | `geographical_kmedoids_haversine` | +| Electrical behavior grouping | `electrical_kmedoids` | +| Multi-voltage network | `va_geographical_kmedoids_haversine` | +| Unknown cluster count | `geographical_dbscan_*` or `geographical_hdbscan_*` | +| Identification of Bidding Zones | `lmp_hierarchical_connected` | ### Performance Comparison diff --git a/docs/user-guide/partitioning/locational_marginal_price.md b/docs/user-guide/partitioning/locational_marginal_price.md new file mode 100644 index 0000000..96affd9 --- /dev/null +++ b/docs/user-guide/partitioning/locational_marginal_price.md @@ -0,0 +1,91 @@ +# Locational Marginal Price Partitioning + +Clusters nodes based on Locational Marginal Prices (LMP) or price profiles. The implementation of this strategy is based on the findings in the +paper "Congestion-Sensitive Grid Aggregation for DC Optimal Power Flow" by B. Stöckl et al. ([2025](https://doi.org/10.1109/PowerTech59965.2025.11180585)). + +## Required Attributes + +- **Nodes**: `lmp` (can be a scalar value or a time-series vector/array) + +## Available Strategies + +| Strategy | Algorithm | Description | AC-Island Aware | +|------------------------------|--------------|--------------------------------------------------------|-----------------| +| `lmp_hierarchical_connected` | Hierarchical | Agglomerative clustering with connectivity constraints | Yes | + +## Overview + +LMP-based partitioning is specifically designed for market-based network analysis. It identifies regions with similar price behaviors, which is often +used to define bidding zones or identify structural congestion in a power system. + +The strategy calculates the Euclidean distance between LMP profiles (vectors of prices over time) to determine nodal similarity. + +### Connectivity Constraint + +By default, the `lmp_hierarchical_connected` strategy uses the graph's topology as a constraint. This ensures that resulting clusters are **spatially +contiguous**—nodes in the same cluster must be physically connected in the network. This prevents "scattered" clusters that would be unrealistic for +geographical price zones. + +### Mathematical Background + +Locational Marginal Prices (LMPs) are often used in power systems to reflect the cost of supplying the next increment of load at a specific location, +considering generation costs and network constraints. + +In an optimal power flow (OPF) with a bus angle formulation, they can be mathematically defined as the dual variables $\lambda$ associated with the +nodal balance constraints. + +In an optimal power flow implementation based on PTDF power flows, the LMPs can be expressed as: +$$\mathrm{LMP} = \lambda_{slack} + \mathrm{PTDF}^\top (\bar\varphi - \underline\varphi)$$ +where: + +- $ \lambda_{slack} $ is the LMP at the slack bus (reference node). +- $ \mathrm{PTDF} $ is the Power Transfer Distribution Factor matrix, which describes how power injections at nodes affect line flows. +- $ \bar\varphi $ and $ \underline\varphi $ are the dual variables associated with the upper and lower line flow constraints, respectively. + +### AC-Island Awareness + +If the graph contains `ac_island` data (automatically set by `va_loader`), the strategy respects AC island boundaries by assigning infinite distance +between nodes in different islands. This ensures that price zones do not span across asynchronous regions. + +## Usage + +```python +import npap + +manager = npap.PartitionAggregatorManager() +manager.load_data("va_loader", node_file="nodes.csv", line_file="lines.csv") + +# Partition based on LMP profiles +partition = manager.partition( + strategy="lmp_hierarchical_connected", + n_clusters=15, + lmp_attr="lmp", # Node attribute containing prices + use_connectivity=True # Ensure contiguous clusters +) +``` + +## Configuration + +The LMP strategy uses {py:class}`~npap.partitioning.locational_marginal_price.LMPConfig`: + +```python +from npap.partitioning.locational_marginal_price import LMPConfig + +config = LMPConfig( + hierarchical_linkage="complete", + infinite_distance=1e4, + use_connectivity=True +) + +partition = manager.partition( + "lmp_hierarchical_connected", + n_clusters=15, + config=config +) +``` + +| Parameter | Default | Description | +|------------------------|--------------|---------------------------------------------------------------| +| `hierarchical_linkage` | `"complete"` | Linkage criterion for hierarchical clustering. | +| `infinite_distance` | `1e4` | Value used to separate nodes in different AC islands. | +| `use_connectivity` | `True` | Whether to use graph connectivity as a clustering constraint. | diff --git a/npap/managers.py b/npap/managers.py index ea6a485..ccc3354 100644 --- a/npap/managers.py +++ b/npap/managers.py @@ -14,7 +14,7 @@ TopologyStrategy, ) from npap.logging import LogCategory, log_debug, log_info, log_warning -from npap.partitioning import VAElectricalDistancePartitioning +from npap.partitioning import LMPPartitioning, VAElectricalDistancePartitioning class InputDataManager: @@ -156,6 +156,9 @@ def _register_default_strategies(self) -> None: algorithm="hierarchical" ) + # Locational Marginal Price partitioning strategies + self._strategies["lmp_hierarchical_connected"] = LMPPartitioning(algorithm="hierarchical") + log_debug("Registered default partitioning strategies", LogCategory.MANAGER) diff --git a/npap/partitioning/__init__.py b/npap/partitioning/__init__.py index 7101d5c..6354034 100644 --- a/npap/partitioning/__init__.py +++ b/npap/partitioning/__init__.py @@ -18,12 +18,14 @@ from .electrical import ElectricalDistancePartitioning from .geographical import GeographicalPartitioning +from .locational_marginal_price import LMPPartitioning from .va_electrical import VAElectricalDistancePartitioning from .va_geographical import VAGeographicalPartitioning __all__ = [ "ElectricalDistancePartitioning", "GeographicalPartitioning", + "LMPPartitioning", "VAElectricalDistancePartitioning", "VAGeographicalPartitioning", ] diff --git a/npap/partitioning/locational_marginal_price.py b/npap/partitioning/locational_marginal_price.py new file mode 100644 index 0000000..d90abbb --- /dev/null +++ b/npap/partitioning/locational_marginal_price.py @@ -0,0 +1,378 @@ +from dataclasses import dataclass +from typing import Any + +import networkx as nx +import numpy as np +from sklearn.metrics.pairwise import euclidean_distances + +from npap.exceptions import PartitioningError +from npap.interfaces import PartitioningStrategy +from npap.logging import LogCategory, log_debug, log_info +from npap.utils import ( + create_partition_map, + run_hierarchical, + validate_partition, + validate_required_attributes, + with_runtime_config, +) + + +@dataclass +class LMPConfig: + """ + Configuration parameters for LMP-based partitioning. + + Attributes + ---------- + hierarchical_linkage : str + Linkage criterion for hierarchical clustering + ('complete', 'average', 'single'). + infinite_distance : float + Value used to represent "infinite" distance between nodes in different + AC islands. + use_connectivity : bool + Whether to use graph connectivity as a constraint for clustering. + If True, only adjacent nodes in the graph can be merged into a cluster. + It is recommended to set this to True, since otherwise nodes might get clustered, which are at different positions in the network. + """ + + hierarchical_linkage: str = "complete" + infinite_distance: float = 1e4 + use_connectivity: bool = True + + +class LMPPartitioning(PartitioningStrategy): + """ + Partition nodes based on Locational Marginal Prices (LMP). + + This strategy clusters nodes based on their LMP values. LMPs can be single + values or time-series profiles (vectors). The distance between nodes is + calculated as the Euclidean distance between their LMP profiles. + + AC-Island Awareness: + When the graph contains AC island data (nodes have 'ac_island' attribute), + the strategy respects AC island boundaries by assigning infinite + distance between nodes in different AC islands. + + Connectivity Constraint: + When `use_connectivity` is enabled in the config, the clustering + algorithm is constrained by the graph's topology. Only nodes that are + directly connected by an edge (or belong to the same connected component) + can be clustered together. This ensures that resulting clusters are + spatially contiguous in the network. + + Supported algorithms: + - 'hierarchical': Agglomerative hierarchical clustering. + """ + + SUPPORTED_ALGORITHMS = ["hierarchical"] + + # Config parameter names for runtime override detection + _CONFIG_PARAMS = { + "hierarchical_linkage", + "infinite_distance", + "use_connectivity", + } + + def __init__( + self, + algorithm: str = "hierarchical", + lmp_attr: str = "lmp", + distance_metric: str = "euclidean", + ac_island_attr: str = "ac_island", + config: LMPConfig | None = None, + ): + """ + Initialize LMP partitioning strategy. + + Parameters + ---------- + algorithm : str, default='hierarchical' + Clustering algorithm. Currently only 'hierarchical' is supported. + lmp_attr : str, default='lmp' + Node attribute name containing LMP values. + ac_island_attr : str, default='ac_island' + Node attribute name containing AC island ID. + config : LMPConfig, optional + Configuration parameters for the algorithm. + + Raises + ------ + ValueError + If unsupported algorithm is specified. + """ + self.algorithm = algorithm + self.lmp_attr = lmp_attr + self.distance_metric = distance_metric + self.ac_island_attr = ac_island_attr + self.config = config or LMPConfig() + + if algorithm not in self.SUPPORTED_ALGORITHMS: + raise ValueError( + f"Unsupported algorithm: {algorithm}. " + f"Supported: {', '.join(self.SUPPORTED_ALGORITHMS)}" + ) + + log_debug( + f"Initialized LMPPartitioning: algorithm={algorithm}, lmp_attr={lmp_attr}", + LogCategory.PARTITIONING, + ) + + @property + def required_attributes(self) -> dict[str, list[str]]: + """Required node attributes for LMP partitioning.""" + return {"nodes": [self.lmp_attr], "edges": []} + + def _get_strategy_name(self) -> str: + """Get descriptive strategy name for error messages.""" + return f"lmp_{self.algorithm}" + + @with_runtime_config(LMPConfig, _CONFIG_PARAMS) + @validate_required_attributes + def partition(self, graph: nx.Graph, **kwargs) -> dict[int, list[Any]]: + """ + Partition nodes based on Locational Marginal Prices. + + Automatically detects and respects AC island boundaries when available. + + Parameters + ---------- + graph : nx.Graph + NetworkX graph with LMP attributes on nodes. + **kwargs : dict + Additional parameters: + + - n_clusters : Number of clusters (required for hierarchical) + - config : LMPConfig instance to override instance config + - hierarchical_linkage : Override config parameter + - infinite_distance : Override config parameter + - use_connectivity : Override config parameter + + Returns + ------- + dict[int, list[Any]] + Dictionary mapping cluster_id -> list of node_ids. + + Raises + ------ + PartitioningError + If partitioning fails. + """ + try: + effective_config = kwargs.get("_effective_config", self.config) + n_clusters = kwargs.get("n_clusters") + + # Extract LMP data + nodes = list(graph.nodes()) + lmps = self._extract_lmps(graph, nodes) + + # Auto-detect AC island data + ac_islands = None + has_ac_islands = self._has_ac_island_data(graph, nodes) + + if has_ac_islands: + ac_islands = self._extract_ac_islands(graph, nodes) + n_ac_islands = len(set(ac_islands)) + + log_info( + f"Starting AC-island-aware locational marginal prices partitioning: {self.algorithm}, " + f"n_clusters={n_clusters}, metric={self.distance_metric}, " + f"ac_islands={n_ac_islands}", + LogCategory.PARTITIONING, + ) + else: + log_info( + f"Starting LMP partitioning: {self.algorithm}, n_clusters={n_clusters}", + LogCategory.PARTITIONING, + ) + + # Perform clustering + labels = self._run_clustering( + graph, lmps, effective_config, ac_islands=ac_islands, **kwargs + ) + + # Create and validate partition + partition_map = create_partition_map(nodes, labels) + validate_partition(partition_map, len(nodes), self._get_strategy_name()) + + log_info( + f"LMP partitioning complete: {len(partition_map)} clusters", + LogCategory.PARTITIONING, + ) + + return partition_map + + except Exception as e: + if isinstance(e, PartitioningError): + raise + raise PartitioningError( + f"LMP partitioning failed: {e}", + strategy=self._get_strategy_name(), + graph_info={ + "nodes": len(list(graph.nodes())), + "edges": len(graph.edges()), + }, + ) from e + + def _extract_lmps(self, graph: nx.Graph, nodes: list[Any]) -> np.ndarray: + """ + Extract LMP values from graph nodes. + + Supports both scalar LMPs and time-series vectors. + + Parameters + ---------- + graph : nx.Graph + NetworkX graph. + nodes : list[Any] + List of node IDs. + + Returns + ------- + np.ndarray + Array of LMP profiles (n_nodes x n_timesteps). + """ + lmp_profiles = [] + + for node in nodes: + val = graph.nodes[node].get(self.lmp_attr) + if val is None: + raise PartitioningError( + f"Node {node} missing LMP attribute '{self.lmp_attr}'", + strategy=self._get_strategy_name(), + ) + + # Ensure data is numeric and array-like + if isinstance(val, (int, float)): + lmp_profiles.append([float(val)]) + else: + lmp_profiles.append(np.atleast_1d(val)) + + return np.array(lmp_profiles) + + def _has_ac_island_data(self, graph: nx.Graph, nodes: list[Any]) -> bool: + """Check if the graph has AC island data on nodes.""" + for node in nodes: + if self.ac_island_attr not in graph.nodes[node]: + return False + return True + + def _extract_ac_islands(self, graph: nx.Graph, nodes: list[Any]) -> np.ndarray: + """Extract AC island IDs from graph nodes.""" + return np.array([graph.nodes[node][self.ac_island_attr] for node in nodes]) + + def _build_ac_island_aware_distance_matrix( + self, + lmps: np.ndarray, + ac_islands: np.ndarray, + config: LMPConfig, + ) -> np.ndarray: + """ + Build distance matrix with AC island awareness. + + Parameters + ---------- + lmps : np.ndarray + Array of LMP profiles (n x m). + ac_islands : np.ndarray + Array of AC island IDs (n). + config : LMPConfig + Configuration instance. + + Returns + ------- + np.ndarray + Distance matrix (n x n). + """ + # Calculate Euclidean distances between LMP profiles + price_distances = euclidean_distances(lmps) + + # Apply AC island mask: infinite distance between different islands + same_island_mask = ac_islands[:, np.newaxis] == ac_islands[np.newaxis, :] + distance_matrix = np.where(same_island_mask, price_distances, config.infinite_distance) + + # Ensure diagonal is zero + np.fill_diagonal(distance_matrix, 0.0) + + return distance_matrix + + def _run_clustering( + self, graph: nx.Graph, lmps: np.ndarray, config: LMPConfig, **kwargs + ) -> np.ndarray: + """ + Dispatch to appropriate clustering algorithm. + + Parameters + ---------- + graph : nx.Graph + Original network graph. + lmps : np.ndarray + Array of LMP profiles. + config : LMPConfig + Configuration parameters. + **kwargs : dict + Additional clustering parameters. + + Returns + ------- + np.ndarray + Array of cluster labels. + """ + if self.algorithm == "hierarchical": + return self._hierarchical_clustering(graph, lmps, config, **kwargs) + else: + raise PartitioningError( + f"Unknown algorithm: {self.algorithm}", + strategy=self._get_strategy_name(), + ) + + def _hierarchical_clustering( + self, graph: nx.Graph, lmps: np.ndarray, config: LMPConfig, **kwargs + ) -> np.ndarray: + """ + Perform Hierarchical Clustering on LMP profiles. + + Parameters + ---------- + graph : nx.Graph + Original network graph. + lmps : np.ndarray + Array of LMP profiles. + config : LMPConfig + Configuration parameters. + **kwargs : dict + Must include 'n_clusters'. May include 'ac_islands'. + + Returns + ------- + np.ndarray + Array of cluster labels. + """ + n_clusters = kwargs.get("n_clusters") + if n_clusters is None or n_clusters <= 0: + raise PartitioningError( + "Hierarchical clustering requires a positive 'n_clusters' parameter.", + strategy=self._get_strategy_name(), + ) + + ac_islands = kwargs.get("ac_islands") + + if ac_islands is not None: + distance_matrix = self._build_ac_island_aware_distance_matrix(lmps, ac_islands, config) + else: + distance_matrix = euclidean_distances(lmps) + + # Handle connectivity constraint + connectivity = None + if config.use_connectivity: + log_debug( + "Using graph connectivity constraint for clustering", LogCategory.PARTITIONING + ) + connectivity = nx.adjacency_matrix(graph) + + return run_hierarchical( + distance_matrix, + n_clusters, + config.hierarchical_linkage, + connectivity=connectivity, + ) diff --git a/npap/utils.py b/npap/utils.py index f948730..f7a7374 100644 --- a/npap/utils.py +++ b/npap/utils.py @@ -413,7 +413,10 @@ def run_kmedoids(distance_matrix: np.ndarray, n_clusters: int) -> np.ndarray: def run_hierarchical( - distance_matrix: np.ndarray, n_clusters: int, linkage: str = "complete" + distance_matrix: np.ndarray, + n_clusters: int, + linkage: str = "complete", + connectivity: Any | None = None, ) -> np.ndarray: """ Perform hierarchical (agglomerative) clustering with precomputed distance matrix. @@ -429,6 +432,9 @@ def run_hierarchical( linkage : str, default='complete' Linkage criterion ('complete', 'average', 'single'). Note: 'ward' is NOT supported with precomputed distances. + connectivity : array-like or callable, optional + Connectivity matrix. Defines for each sample the neighboring samples + which can be merged together into a single cluster. Returns ------- @@ -459,7 +465,10 @@ def run_hierarchical( try: clustering = AgglomerativeClustering( - n_clusters=n_clusters, metric="precomputed", linkage=linkage + n_clusters=n_clusters, + metric="precomputed", + linkage=linkage, + connectivity=connectivity, ) return clustering.fit_predict(distance_matrix) diff --git a/test/conftest.py b/test/conftest.py index 4005a8d..bdb40b6 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -6,6 +6,7 @@ """ import networkx as nx +import numpy as np import pytest # ============================================================================= @@ -13,6 +14,114 @@ # ============================================================================= +@pytest.fixture +def lmp_graph() -> nx.DiGraph: + """ + 6-node graph with distinct LMP clusters for testing LMP partitioning. + + Structure: + Cluster A Cluster B + (LMP ~20) (LMP ~100) + + 0 ──→ 1 3 ──→ 4 + │ ↗ │ ↗ + ↓ / ↓ / + 2 ─────────────→ 3 ──→ 5 + (bridge) + + Cluster A (nodes 0,1,2): LMPs around 20.0 + Cluster B (nodes 3,4,5): LMPs around 100.0 + + The graph is connected, but Cluster A and B are separated by a single + bridge edge (2->3) to allow testing of connectivity constraints. + """ + G = nx.DiGraph() + + # Cluster A nodes + G.add_node(0, lmp=20.0, lat=0.0, lon=0.0) + G.add_node(1, lmp=21.0, lat=0.1, lon=0.1) + G.add_node(2, lmp=19.5, lat=0.2, lon=0.0) + + # Cluster B nodes + G.add_node(3, lmp=100.0, lat=10.0, lon=10.0) + G.add_node(4, lmp=105.0, lat=10.1, lon=10.1) + G.add_node(5, lmp=98.0, lat=10.2, lon=10.0) + + # Edges within Cluster A + G.add_edge(0, 1) + G.add_edge(1, 2) + G.add_edge(0, 2) + + # Edges within Cluster B + G.add_edge(3, 4) + G.add_edge(4, 5) + G.add_edge(3, 5) + + # Bridge edge between clusters + G.add_edge(2, 3) + + return G + + +@pytest.fixture +def lmp_profile_graph() -> nx.DiGraph: + """ + 3-node graph with time-series LMP profiles for multi-dimensional testing. + + Structure: + 0 ──→ 1 ──→ 2 + """ + G = nx.DiGraph() + + # Nodes with LMP vectors (profiles over 3 timesteps) + G.add_node(0, lmp=np.array([20.0, 22.0, 19.0])) + G.add_node(1, lmp=np.array([21.0, 23.0, 20.0])) + G.add_node(2, lmp=np.array([100.0, 110.0, 95.0])) + + G.add_edge(0, 1) + G.add_edge(1, 2) + + return G + + +@pytest.fixture +def lmp_ac_island_graph() -> nx.DiGraph: + """ + Graph with two AC islands for testing AC-island-aware LMP partitioning. + + Structure: + Island 0 Island 1 + (LMP ~55) (LMP ~55) + + 0 ──→ 1 ──╍╍╍╍──→ 2 ──→ 3 + (DC) + + Island 0: nodes 0, 1 + Island 1: nodes 2, 3 + + Even though LMPs are identical, nodes should NOT be clustered together + if AC-island awareness is active. + """ + G = nx.DiGraph() + + # AC Island 0 + G.add_node(0, lmp=60.0, ac_island=0) + G.add_node(1, lmp=50.0, ac_island=0) + + # AC Island 1 + G.add_node(2, lmp=60.0, ac_island=1) + G.add_node(3, lmp=50.0, ac_island=1) + + # Internal edges + G.add_edge(0, 1) + G.add_edge(2, 3) + + # Bridge between islands (DC link) + G.add_edge(1, 2, type="dc_link") + + return G + + @pytest.fixture def simple_digraph() -> nx.DiGraph: """ diff --git a/test/test_partitioning.py b/test/test_partitioning.py index 1a9ea5a..d1681e9 100644 --- a/test/test_partitioning.py +++ b/test/test_partitioning.py @@ -20,6 +20,10 @@ GeographicalConfig, GeographicalPartitioning, ) +from npap.partitioning.locational_marginal_price import ( + LMPConfig, + LMPPartitioning, +) from npap.partitioning.va_electrical import ( VAElectricalDistancePartitioning, ) @@ -1090,3 +1094,158 @@ def test_va_electrical_accepts_multiple_voltages(self): strategy = VAElectricalDistancePartitioning(algorithm="kmedoids") partition = strategy.partition(G, n_clusters=2, random_state=42) assert all_nodes_assigned(partition, list(G.nodes())) + + +# ============================================================================= +# LOCATIONAL MARGINAL PRICE PARTITIONING TESTS +# ============================================================================= + + +class TestLMPPartitioning: + """Tests the partitioning strategy based on locational marginal prices (LMPs).""" + + # ------------------------------------------------------------------------- + # Initialization Tests + # ------------------------------------------------------------------------- + + def test_init_valid_algorithms(self): + """Test that all supported algorithms can be initialized.""" + for algo in LMPPartitioning.SUPPORTED_ALGORITHMS: + strategy = LMPPartitioning(algorithm=algo) + assert strategy.algorithm == algo + + def test_init_invalid_algorithm_raises(self): + """Test that invalid algorithm raises ValueError.""" + with pytest.raises(ValueError, match="Unsupported algorithm"): + LMPPartitioning(algorithm="invalid_algo") + + # ------------------------------------------------------------------------- + # Validation Tests + # ------------------------------------------------------------------------- + + def test_lmp_partitioning_lmp_attribute_required(self): + """Test that LMP attribute is required for LMP partitioning.""" + G = nx.DiGraph() + G.add_node(0) # Missing 'lmp' attribute + G.add_node(1, lmp=100.0) + G.add_edge(0, 1) + + strategy = LMPPartitioning() + + with pytest.raises(ValidationError): + strategy.partition(G, n_clusters=2) + + # ------------------------------------------------------------------------- + # Hierarchical Clustering Tests + # ------------------------------------------------------------------------- + + def test_lmp_partitioning_basic(self, lmp_graph): + """Test basic functionality of LMP-based partitioning.""" + strategy = LMPPartitioning(algorithm="hierarchical") + partition = strategy.partition(lmp_graph, n_clusters=2) + + assert all_nodes_assigned(partition, list(lmp_graph.nodes())) + assert len(partition) == 2 + + def test_lmp_partitioning_respects_connectivity(self): + """Test that LMP partitioning respects graph connectivity.""" + strategy = LMPPartitioning(algorithm="hierarchical") + + G = nx.DiGraph() + + G.add_node(0, lmp=50.0) + G.add_node(1, lmp=100.0) + G.add_node(2, lmp=20.0) + + # Edges + G.add_edge(0, 1) + G.add_edge(1, 2) + + partition = strategy.partition(G, n_clusters=2, random_state=42) + + # Nodes 0 and 1 are connected and should be in the same cluster + assert nodes_in_same_cluster(partition, 0, 1) + + # Nodes 0/2 should not be in the same cluster + assert nodes_in_different_clusters(partition, 0, 2) + + def test_lmp_partitioning_lmp_profile(self, lmp_profile_graph): + """Test that LMP partitioning can use an LMP profile for partitioning.""" + strategy = LMPPartitioning(algorithm="hierarchical") + + partition = strategy.partition(lmp_profile_graph, n_clusters=2, random_state=42) + + # Nodes with similar LMP profiles should cluster together + assert nodes_in_same_cluster(partition, 0, 1) + + # ------------------------------------------------------------------------- + # AC-Island Aware Partitioning Tests + # ------------------------------------------------------------------------- + + def test_lmp_partitioning_ac_island_aware(self, lmp_ac_island_graph): + """Test that LMP partitioning respects AC island boundaries.""" + strategy = LMPPartitioning(algorithm="hierarchical") + partition = strategy.partition(lmp_ac_island_graph, n_clusters=2) + + # Nodes 0,1 are in AC island 0, nodes 2,3 are in AC island 1 + # They should never be in the same cluster even with identical LMPs + assert nodes_in_same_cluster(partition, 0, 1) + assert nodes_in_same_cluster(partition, 2, 3) + assert nodes_in_different_clusters(partition, 0, 2) + + def test_lmp_ac_island_distance_matrix(self, lmp_ac_island_graph): + """Test that the distance matrix correctly applies infinite distance between islands.""" + strategy = LMPPartitioning() + nodes = list(lmp_ac_island_graph.nodes()) + lmps = strategy._extract_lmps(lmp_ac_island_graph, nodes) + ac_islands = strategy._extract_ac_islands(lmp_ac_island_graph, nodes) + config = LMPConfig(infinite_distance=1e5) + + dist_matrix = strategy._build_ac_island_aware_distance_matrix(lmps, ac_islands, config) + + # Between node 0 (Island 0) and node 2 (Island 1) + assert dist_matrix[0, 2] == config.infinite_distance + # Between node 0 and 1 (Same island) + assert dist_matrix[0, 1] < config.infinite_distance + + # ------------------------------------------------------------------------- + # Connectivity Constraint Tests + # ------------------------------------------------------------------------- + + def test_lmp_connectivity_constraint(self, lmp_graph): + """ + Test that use_connectivity=True forces clusters to be contiguous. + + In the lmp_graph, Cluster A (0,1,2) and Cluster B (3,4,5) are connected + only by edge 2-3. If we remove that edge, they become disconnected + components. + """ + G = lmp_graph.copy() + G.remove_edge(2, 3) # Graph is now two disconnected components + + strategy = LMPPartitioning(algorithm="hierarchical") + + # With connectivity, nodes from different components cannot be merged + partition = strategy.partition(G, n_clusters=2, use_connectivity=True) + + assert nodes_in_same_cluster(partition, 0, 2) + assert nodes_in_same_cluster(partition, 3, 5) + assert nodes_in_different_clusters(partition, 0, 3) + + # ------------------------------------------------------------------------- + # Auto-Detection & Config Tests + # ------------------------------------------------------------------------- + + def test_ac_island_auto_detection(self, lmp_ac_island_graph): + """Test that AC islands are automatically detected.""" + strategy = LMPPartitioning() + nodes = list(lmp_ac_island_graph.nodes()) + assert strategy._has_ac_island_data(lmp_ac_island_graph, nodes) + + def test_config_override_connectivity(self, lmp_graph): + """Test that use_connectivity can be toggled via partition() kwargs.""" + strategy = LMPPartitioning(config=LMPConfig(use_connectivity=False)) + + # Test that it doesn't crash and respects the parameter + partition = strategy.partition(lmp_graph, n_clusters=2, use_connectivity=True) + assert len(partition) == 2