From 606fbee34358b64f785b63e4ab82dc5e210578fc Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Thu, 19 Feb 2026 22:55:41 +0100 Subject: [PATCH 01/23] Add graph theory partitioners and conservation mode --- docs/user-guide/available-strategies.md | 18 +++- docs/user-guide/index.md | 3 +- docs/user-guide/visualization.md | 19 +++++ docs/user-guide/workflows.md | 93 +++++++++++++++++++++ npap/aggregation/modes.py | 28 ++++++- npap/aggregation/physical_strategies.py | 92 ++++++++++++++++++++- npap/interfaces.py | 3 + npap/managers.py | 11 ++- npap/partitioning/__init__.py | 3 + npap/partitioning/graph_theory.py | 98 ++++++++++++++++++++++ npap/visualization.py | 105 ++++++++++++++++++++++-- test/test_aggregation.py | 25 ++++++ test/test_partitioning.py | 42 ++++++++++ 13 files changed, 526 insertions(+), 14 deletions(-) create mode 100644 docs/user-guide/workflows.md create mode 100644 npap/partitioning/graph_theory.py diff --git a/docs/user-guide/available-strategies.md b/docs/user-guide/available-strategies.md index 11aa856..6a06c2f 100644 --- a/docs/user-guide/available-strategies.md +++ b/docs/user-guide/available-strategies.md @@ -164,6 +164,17 @@ Combines electrical distance (PTDF approach) with voltage level and AC island co **Required attributes**: Nodes: `voltage`, `ac_island` | Edges: `x` (reactance) +### Graph-theory Partitioning + +Graph-theory strategies rely on matrix-based clustering or community detection rather than physical distances. + +| Strategy | Algorithm | Description | +|----------|-----------|-------------| +| `spectral_clustering` | Spectral clustering (precomputed adjacency) | Ideal for networks with loose geographic signals; adapts to the graph Laplacian. | +| `community_modularity` | Greedy modularity communities | Detects naturally modular regions without any tunable `n_clusters`. | + +Spectral clustering expects `n_clusters` as an argument and splits the adjacency matrix via Eigen-decomposition, while the community strategy returns the modularity-maximizing partition automatically. + ### Choosing a Partitioning Strategy ```{mermaid} @@ -205,6 +216,7 @@ Predefined {py:class}`~npap.AggregationMode` for common use cases: | `SIMPLE` | simple | sum all | sum all | | `GEOGRAPHICAL` | simple | avg coords, sum loads | sum capacity, equivalent reactance | | `DC_KRON` | electrical | Kron reduction | Kron reduction | +| `CONSERVATION` | electrical | Equivalent reactance + transformer preservation | Preserves transformer count & impedance | | `CUSTOM` | user-defined | user-defined | user-defined | ```python @@ -260,6 +272,10 @@ aggregated = manager.aggregate(profile=profile) See [Aggregation](aggregation.md) for detailed documentation. +### Transformer Conservation Mode + +`AggregationMode.CONSERVATION` wires the new `"transformer_conservation"` physical strategy into an electrical topology, so transformer reactance (`x`) and resistance (`r`) are calculated using parallel impedance rules before statistical aggregation steps run. This mode is the foundation for the voltage-aware workflows on this page. + ## Key Classes ### Main Entry Point @@ -289,7 +305,7 @@ See [Aggregation](aggregation.md) for detailed documentation. | Enum | Values | |------|--------| | {py:class}`~npap.EdgeType` | `LINE`, `TRAFO`, `DC_LINK` | -| {py:class}`~npap.AggregationMode` | `SIMPLE`, `GEOGRAPHICAL`, `DC_KRON`, `CUSTOM` | +| {py:class}`~npap.AggregationMode` | `SIMPLE`, `GEOGRAPHICAL`, `DC_KRON`, `CUSTOM`, `CONSERVATION` | ### Exceptions diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 11f0954..2f2351f 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -7,8 +7,9 @@ installation quick-start +workflows available-strategies -``` +``` ```{toctree} :hidden: diff --git a/docs/user-guide/visualization.md b/docs/user-guide/visualization.md index b7a9326..21b1b34 100644 --- a/docs/user-guide/visualization.md +++ b/docs/user-guide/visualization.md @@ -193,6 +193,25 @@ Available colorscales include: - `"Jet"` - `"Turbo"` +## Preset Configurations + +Use the `preset` argument to apply curated styling for different audiences without re-writing `PlotConfig`. + +| Preset | Description | +|--------|-------------| +| `default` | Balanced defaults for data exploration | +| `presentation` | Wide canvas with thicker lines and `"open-street-map"` tiles | +| `dense` | Compact view, higher voltage threshold, and dark tiles | +| `cluster_highlight` | Turbo colorscale with large nodes and white background | + +```python +from npap import PlotPreset + +manager.plot_network(style="voltage_aware", preset=PlotPreset.PRESENTATION) +``` + +Presets layer on top of `config`/`kwargs`; any explicit `PlotConfig` parameter overrides the preset values. + ## Working with Figures ### Getting the Figure Object diff --git a/docs/user-guide/workflows.md b/docs/user-guide/workflows.md new file mode 100644 index 0000000..11172d9 --- /dev/null +++ b/docs/user-guide/workflows.md @@ -0,0 +1,93 @@ +# Workflows + +This page highlights ready-to-copy workflows that combine the new graph-theory partitioners, transformer-conserving aggregation mode, and preset-driven visualization to solve production-grade problems. + +## Voltage-aware case study + +Target scenario: a multi-voltage power grid where geographical anchors are weak, but community structure and voltage hierarchies still guide reduction. + +```{mermaid} +flowchart LR + A[Voltage-Aware Loader] --> B[Community Detection] + B --> C[Spectral / Conservation] + C --> D[Transformer Constrained Aggregation] + D --> E[Visualize with Preset] + + style A fill:#0fad6b,stroke:#076b3f,color:#fff + style B fill:#2993B5,stroke:#1d6f8a,color:#fff + style C fill:#2993B5,stroke:#1d6f8a,color:#fff + style D fill:#0fad6b,stroke:#076b3f,color:#fff + style E fill:#2993B5,stroke:#1d6f8a,color:#fff +``` + +```python +from npap import AggregationMode, PartitionAggregatorManager + +manager = PartitionAggregatorManager() +manager.load_data( + strategy="va_loader", + node_file="buses.csv", + line_file="lines.csv", + transformer_file="transformers.csv", + converter_file="converters.csv", + link_file="dc_links.csv", +) + +# Use graph-theory partitioning to respect community structure, then +# aggregate with the transformer conservation mode. +partition = manager.partition("community_modularity") +aggregated = manager.aggregate(mode=AggregationMode.CONSERVATION) + +manager.plot_network( + style="clustered", + preset="cluster_highlight", + partition_map=partition.mapping, + show=False, +) +``` + +**Why this works** + +1. The `community_modularity` strategy finds structural clusters even when lat/lon are noisy. +2. `AggregationMode.CONSERVATION` invokes the transformer conservation physical strategy to keep reactance/resistance faithful. +3. Presets such as `cluster_highlight` (see [Visualization](visualization.md)) simplify presentation-ready exports. + +## Custom aggregation profile with transformer conservation + +If you need more control than a predefined mode, mix in the new physical strategy manually: + +```python +from npap import AggregationProfile + +profile = AggregationProfile( + topology_strategy="electrical", + physical_strategy="transformer_conservation", + physical_properties=["x", "r"], + edge_properties={"p_max": "sum"}, + default_node_strategy="average", + default_edge_strategy="sum", +) + +aggregated = manager.aggregate(profile=profile) +``` + +This profile lets you combine the transformer-preserving physical strategy with any node/edge aggregation strategy you need (for example, splitting transformers by type in `edge_type_properties`). + +## Visualization presets + +Use the new `preset` argument in `plot_network` or pass `PlotPreset` directly to unlock consistent layout and styling for different audiences: + +| Preset | Description | +|--------|-------------| +| `default` | Balanced settings for data exploration | +| `presentation` | Larger canvas, thicker edges, open-street-map tiles | +| `dense` | Compact view with higher voltage thresholds for busy networks | +| `cluster_highlight` | Turbo colorscale with bold nodes for partition-focused slides | + +```python +from npap.visualization import PlotPreset + +manager.plot_network(style="voltage_aware", preset=PlotPreset.PRESENTATION) +``` + +Presets are composable with `PlotConfig`: pass `config=PlotConfig(...)` and overrides via `kwargs` to fine-tune individual charts while keeping a consistent baseline. diff --git a/npap/aggregation/modes.py b/npap/aggregation/modes.py index 4d8356c..05852c3 100644 --- a/npap/aggregation/modes.py +++ b/npap/aggregation/modes.py @@ -36,6 +36,8 @@ def get_mode_profile(mode: AggregationMode, **overrides) -> AggregationProfile: profile = _geographical_mode() elif mode == AggregationMode.CUSTOM: profile = AggregationProfile(mode=AggregationMode.CUSTOM) + elif mode == AggregationMode.CONSERVATION: + profile = _conservation_mode() elif mode in [AggregationMode.DC_KRON]: raise NotImplementedError( f"Mode {mode} is not yet implemented. " @@ -109,8 +111,32 @@ def _geographical_mode() -> AggregationProfile: "lon": "average", # similar to middle point "base_voltage": "average", }, - edge_properties={"p_max": "sum", "x": "average"}, + edge_properties={"p_max": "sum", "x": "equivalent_reactance"}, default_node_strategy="average", default_edge_strategy="average", warn_on_defaults=True, ) + + +def _conservation_mode() -> AggregationProfile: + """ + Transformer conservation mode. + + Uses electrical topology plus the transformer conservation physical strategy + to preserve equivalent reactance/resistance for transformer connections. + """ + return AggregationProfile( + mode=AggregationMode.CONSERVATION, + topology_strategy="electrical", + physical_strategy="transformer_conservation", + physical_properties=["x", "r"], + node_properties={ + "lat": "average", + "lon": "average", + "voltage": "average", + }, + edge_properties={"p_max": "sum"}, + default_node_strategy="average", + default_edge_strategy="sum", + warn_on_defaults=True, + ) diff --git a/npap/aggregation/physical_strategies.py b/npap/aggregation/physical_strategies.py index 7138a87..7cdbe4b 100644 --- a/npap/aggregation/physical_strategies.py +++ b/npap/aggregation/physical_strategies.py @@ -1,13 +1,101 @@ +from __future__ import annotations + from typing import Any import networkx as nx -from npap.interfaces import PhysicalAggregationStrategy +from npap.aggregation.basic_strategies import ( + EquivalentReactanceStrategy, + build_cluster_edge_map, + build_node_to_cluster_map, +) +from npap.exceptions import AggregationError +from npap.interfaces import EdgeType, PhysicalAggregationStrategy + + +class TransformerConservationStrategy(PhysicalAggregationStrategy): + """ + Preserve transformer-level impedance when reducing the network. + + This strategy treats all transformers between two clusters as parallel elements and + stores their equivalent reactance/resistance as well as transformer count on the aggregated + edge. It is meant to be used with electrical topology and ensures impedance conservation + before the statistical step runs. + """ + + def __init__( + self, + edge_type_attribute: str = "type", + transformer_type: str = EdgeType.TRAFO.value, + reactance_property: str = "x", + resistance_property: str = "r", + ): + self.edge_type_attribute = edge_type_attribute + self.transformer_type = transformer_type + self.reactance_property = reactance_property + self.resistance_property = resistance_property + self._equivalent_reactance = EquivalentReactanceStrategy() + + @property + def required_properties(self) -> list[str]: + return [self.reactance_property, self.resistance_property] + + @property + def modifies_properties(self) -> list[str]: + return [self.reactance_property, self.resistance_property] + + @property + def can_create_edges(self) -> bool: + return False + + @property + def required_topology(self) -> str: + return "electrical" + + def aggregate( + self, + original_graph: nx.DiGraph, + partition_map: dict[int, list[Any]], + topology_graph: nx.DiGraph, + properties: list[str], + parameters: dict[str, Any] | None = None, + ) -> nx.DiGraph: + node_to_cluster = build_node_to_cluster_map(partition_map) + cluster_edge_map = build_cluster_edge_map(original_graph, node_to_cluster) + + for u, v in topology_graph.edges(): + original_edges = cluster_edge_map.get((u, v), []) + transformer_edges = [ + edge + for edge in original_edges + if edge.get(self.edge_type_attribute) == self.transformer_type + ] + + if not transformer_edges: + continue + + reactance = self._calculate_equivalent(transformer_edges, self.reactance_property) + resistance = self._calculate_equivalent(transformer_edges, self.resistance_property) + + if reactance is not None: + topology_graph.edges[u, v][self.reactance_property] = reactance + if resistance is not None: + topology_graph.edges[u, v][self.resistance_property] = resistance + + topology_graph.edges[u, v]["transformer_count"] = len(transformer_edges) + + return topology_graph + + def _calculate_equivalent(self, edges: list[dict[str, Any]], prop: str) -> float | None: + try: + return self._equivalent_reactance.aggregate_property(edges, prop) + except AggregationError: + return None class KronReductionStrategy(PhysicalAggregationStrategy): """ - Kron reduction for DC power flow networks + Kron reduction for DC power flow networks. TODO: Implementation pending. This is a placeholder. """ diff --git a/npap/interfaces.py b/npap/interfaces.py index e61ed57..0d1230a 100644 --- a/npap/interfaces.py +++ b/npap/interfaces.py @@ -67,12 +67,15 @@ class AggregationMode(Enum): Kron reduction for DC networks. CUSTOM : str User-defined aggregation profile. + CONSERVATION : str + Preserve transformer impedance via dedicated physical strategy. """ SIMPLE = "simple" GEOGRAPHICAL = "geographical" DC_KRON = "dc_kron" CUSTOM = "custom" + CONSERVATION = "transformer_conservation" @dataclass diff --git a/npap/managers.py b/npap/managers.py index ea6a485..bc2d981 100644 --- a/npap/managers.py +++ b/npap/managers.py @@ -79,6 +79,7 @@ def _register_default_strategies(self) -> None: """Register built-in partitioning strategies.""" from npap.partitioning.electrical import ElectricalDistancePartitioning from npap.partitioning.geographical import GeographicalPartitioning + from npap.partitioning.graph_theory import CommunityPartitioning, SpectralPartitioning from npap.partitioning.va_geographical import ( VAGeographicalConfig, VAGeographicalPartitioning, @@ -156,6 +157,10 @@ def _register_default_strategies(self) -> None: algorithm="hierarchical" ) + # Graph-theory based strategies + self._strategies["spectral_clustering"] = SpectralPartitioning(random_state=42) + self._strategies["community_modularity"] = CommunityPartitioning() + log_debug("Registered default partitioning strategies", LogCategory.MANAGER) @@ -790,7 +795,10 @@ def _register_default_strategies(self) -> None: SumEdgeStrategy, SumNodeStrategy, ) - from npap.aggregation.physical_strategies import KronReductionStrategy + from npap.aggregation.physical_strategies import ( + KronReductionStrategy, + TransformerConservationStrategy, + ) # Topology strategies self._topology_strategies["simple"] = SimpleTopologyStrategy() @@ -798,6 +806,7 @@ def _register_default_strategies(self) -> None: # Physical strategies self._physical_strategies["kron_reduction"] = KronReductionStrategy() + self._physical_strategies["transformer_conservation"] = TransformerConservationStrategy() # Node property strategies self._node_strategies["sum"] = SumNodeStrategy() diff --git a/npap/partitioning/__init__.py b/npap/partitioning/__init__.py index 7101d5c..7569546 100644 --- a/npap/partitioning/__init__.py +++ b/npap/partitioning/__init__.py @@ -18,12 +18,15 @@ from .electrical import ElectricalDistancePartitioning from .geographical import GeographicalPartitioning +from .graph_theory import CommunityPartitioning, SpectralPartitioning from .va_electrical import VAElectricalDistancePartitioning from .va_geographical import VAGeographicalPartitioning __all__ = [ + "CommunityPartitioning", "ElectricalDistancePartitioning", "GeographicalPartitioning", + "SpectralPartitioning", "VAElectricalDistancePartitioning", "VAGeographicalPartitioning", ] diff --git a/npap/partitioning/graph_theory.py b/npap/partitioning/graph_theory.py new file mode 100644 index 0000000..919b28a --- /dev/null +++ b/npap/partitioning/graph_theory.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import Any + +import networkx as nx +from networkx.algorithms.community import greedy_modularity_communities +from sklearn.cluster import SpectralClustering + +from npap.exceptions import PartitioningError +from npap.interfaces import PartitioningStrategy +from npap.utils import create_partition_map, validate_partition + + +class SpectralPartitioning(PartitioningStrategy): + """ + Partition using spectral clustering on the graph Laplacian. + + This strategy converts the input graph into a symmetric adjacency matrix and applies + ``sklearn.cluster.SpectralClustering`` with ``affinity="precomputed"``. Use this when + you need community-aware partitions on graphs that are not easily handled by geometric + distances (e.g., line topology with weak geographic structure). + + Parameters + ---------- + random_state : int | None + Seed for reproducible k-means assignment inside spectral clustering. + """ + + def __init__(self, random_state: int | None = None): + self.random_state = random_state + + @property + def required_attributes(self) -> dict[str, list[str]]: + return {"nodes": [], "edges": []} + + def _strategy_name(self) -> str: + return "spectral_clustering" + + def partition(self, graph: nx.DiGraph, /, *, n_clusters: int | None = None, **kwargs) -> dict[int, list[Any]]: + if not n_clusters or n_clusters < 2: + raise PartitioningError( + "SpectralPartitioning requires n_clusters >= 2.", + strategy=self._strategy_name(), + ) + + nodes = list(graph.nodes()) + if len(nodes) < n_clusters: + raise PartitioningError( + "Cannot partition into more clusters than nodes.", + strategy=self._strategy_name(), + ) + + adjacency = nx.to_numpy_array(graph.to_undirected(), nodelist=nodes) + + model = SpectralClustering( + n_clusters=n_clusters, + affinity="precomputed", + assign_labels="kmeans", + random_state=self.random_state, + ) + labels = model.fit_predict(adjacency) + + partition_map = create_partition_map(nodes, labels) + validate_partition(partition_map, len(nodes), self._strategy_name()) + + return partition_map + + +class CommunityPartitioning(PartitioningStrategy): + """ + Partition using greedy modularity community detection. + + This strategy uses NetworkX's ``greedy_modularity_communities`` routine to detect communities + and is deterministic for a given graph structure. + """ + + def required_attributes(self) -> dict[str, list[str]]: + return {"nodes": [], "edges": []} + + def _strategy_name(self) -> str: + return "community_modularity" + + def partition(self, graph: nx.DiGraph, **kwargs) -> dict[int, list[Any]]: + communities = list(greedy_modularity_communities(graph.to_undirected())) + + if not communities: + raise PartitioningError( + "Community detection returned no communities.", + strategy=self._strategy_name(), + ) + + partition_map = {idx: list(cluster) for idx, cluster in enumerate(communities)} + validate_partition(partition_map, graph.number_of_nodes(), self._strategy_name()) + + return partition_map + + +__all__ = ["SpectralPartitioning", "CommunityPartitioning"] diff --git a/npap/visualization.py b/npap/visualization.py index ef05f5d..1e959d2 100644 --- a/npap/visualization.py +++ b/npap/visualization.py @@ -1,4 +1,6 @@ -from dataclasses import dataclass, field +from __future__ import annotations + +from dataclasses import dataclass, field, replace from enum import Enum from typing import Any @@ -28,6 +30,92 @@ class PlotStyle(Enum): CLUSTERED = "clustered" +class PlotPreset(Enum): + """ + Preset configurations for quick styling adjustments. + + Attributes + ---------- + DEFAULT : str + Balanced defaults for general exploration. + PRESENTATION : str + Bigger nodes/edges and a wide canvas for slides or demos. + DENSE : str + Higher voltage threshold and compact markers for crowded networks. + CLUSTER_HIGHLIGHT : str + Emphasizes cluster coloring with saturated nodes and Turbo colorscale. + """ + + DEFAULT = "default" + PRESENTATION = "presentation" + DENSE = "dense" + CLUSTER_HIGHLIGHT = "cluster_highlight" + + +_PRESET_OVERRIDES = { + PlotPreset.DEFAULT: {}, + PlotPreset.PRESENTATION: { + "edge_width": 2.5, + "node_size": 7, + "map_style": "open-street-map", + "width": 1100, + "height": 700, + }, + PlotPreset.DENSE: { + "line_voltage_threshold": 400.0, + "edge_width": 1.2, + "node_size": 4, + "map_zoom": 4.5, + "map_style": "carto-darkmatter", + }, + PlotPreset.CLUSTER_HIGHLIGHT: { + "cluster_colorscale": "Turbo", + "node_size": 8, + "edge_width": 1.8, + "map_style": "carto-positron", + "title": "Clustered Network", + }, +} + + +def _normalize_plot_preset(preset: PlotPreset | str | None) -> PlotPreset | None: + """ + Normalize a preset specifier to a PlotPreset enum value. + + Raises + ------ + ValueError + If the provided string does not match any preset. + """ + if preset is None: + return None + + if isinstance(preset, PlotPreset): + return preset + + lookup = preset.strip().lower().replace(" ", "_") + for option in PlotPreset: + if option.value == lookup or option.name.lower() == lookup: + return option + + raise ValueError(f"Unknown preset: {preset}. Valid options: {[p.value for p in PlotPreset]}") + + +def _apply_preset_overrides(config: PlotConfig, preset: PlotPreset | str | None) -> PlotConfig: + """ + Apply preset overrides to the provided PlotConfig. + """ + preset_enum = _normalize_plot_preset(preset) + if not preset_enum: + return config + + overrides = _PRESET_OVERRIDES.get(preset_enum, {}) + if not overrides: + return config + + return replace(config, **overrides) + + @dataclass class PlotConfig: """ @@ -777,6 +865,7 @@ def plot_network( style: str = "simple", partition_map: dict[int, list[Any]] | None = None, show: bool = True, + preset: PlotPreset | str | None = None, config: PlotConfig | None = None, **kwargs, ) -> go.Figure: @@ -796,6 +885,8 @@ def plot_network( Optional cluster mapping for 'clustered' style. show : bool Whether to display the figure immediately. + preset : PlotPreset or str, optional + Named preset that tweaks sizing, map style, and thresholds. config : PlotConfig or None Optional PlotConfig instance to override defaults. If provided, kwargs will further override values from this config. @@ -819,15 +910,13 @@ def plot_network( >>> fig = plot_network(graph, style="voltage_aware", title="My Network") >>> fig = plot_network(graph, style="clustered", partition_map=result.mapping) """ - if config is not None: - # Start with provided config, then override with kwargs - from dataclasses import asdict + base_config = replace(config) if config else PlotConfig() + config_with_preset = _apply_preset_overrides(base_config, preset) - config_dict = asdict(config) - config_dict.update(kwargs) - effective_config = PlotConfig(**config_dict) + if kwargs: + effective_config = replace(config_with_preset, **kwargs) else: - effective_config = PlotConfig(**kwargs) + effective_config = config_with_preset plotter = NetworkPlotter(graph, partition_map=partition_map) diff --git a/test/test_aggregation.py b/test/test_aggregation.py index 3c119ac..cc2c70e 100644 --- a/test/test_aggregation.py +++ b/test/test_aggregation.py @@ -593,6 +593,31 @@ def test_mode_profile_dict_override_merges(self): assert profile.node_properties.get("custom_prop") == "sum" +class TestTransformerConservationMode: + """Verify the transformer conservation aggregation mode.""" + + def test_physical_strategy_preserves_transformers(self): + manager = AggregationManager() + + graph = nx.DiGraph() + for node in range(4): + graph.add_node(node, lat=float(node), lon=float(node), voltage=110.0) + + graph.add_edge(0, 1, type="trafo", x=0.1, r=0.01, p_max=100.0) + graph.add_edge(2, 3, type="trafo", x=0.2, r=0.02, p_max=50.0) + + partition = {0: [0, 2], 1: [1, 3]} + profile = AggregationManager.get_mode_profile(AggregationMode.CONSERVATION) + + aggregated = manager.aggregate(graph, partition, profile=profile) + assert aggregated.has_edge(0, 1) + + edge_data = aggregated.edges[0, 1] + assert edge_data["transformer_count"] == 2 + assert edge_data["x"] == pytest.approx(1 / (1 / 0.1 + 1 / 0.2)) + assert edge_data["r"] == pytest.approx(1 / (1 / 0.01 + 1 / 0.02)) + + # ============================================================================= # EDGE CASE TESTS # ============================================================================= diff --git a/test/test_partitioning.py b/test/test_partitioning.py index 1a9ea5a..2946c25 100644 --- a/test/test_partitioning.py +++ b/test/test_partitioning.py @@ -20,6 +20,10 @@ GeographicalConfig, GeographicalPartitioning, ) +from npap.partitioning.graph_theory import ( + CommunityPartitioning, + SpectralPartitioning, +) from npap.partitioning.va_electrical import ( VAElectricalDistancePartitioning, ) @@ -1090,3 +1094,41 @@ 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())) + + +class TestSpectralPartitioning: + """Verify the spectral clustering strategy.""" + + def test_requires_n_clusters_parameter(self): + strategy = SpectralPartitioning(random_state=0) + graph = nx.DiGraph() + graph.add_nodes_from([0, 1]) + + with pytest.raises(PartitioningError, match="n_clusters >= 2"): + strategy.partition(graph) + + def test_splits_connected_graph(self): + strategy = SpectralPartitioning(random_state=0) + graph = nx.DiGraph() + graph.add_nodes_from(range(4)) + graph.add_edges_from([(0, 1), (1, 2), (2, 3)]) + + partition = strategy.partition(graph, n_clusters=2) + assert len(partition) == 2 + assert sum(len(nodes) for nodes in partition.values()) == graph.number_of_nodes() + + +class TestCommunityPartitioning: + """Verify the modularity-based community strategy.""" + + def test_detects_multiple_communities(self): + strategy = CommunityPartitioning() + graph = nx.DiGraph() + graph.add_nodes_from(range(6)) + graph.add_edges_from([(0, 1), (1, 0), (1, 2), (2, 1), (0, 2), (2, 0)]) + graph.add_edges_from([(3, 4), (4, 3), (4, 5), (5, 4), (3, 5), (5, 3)]) + graph.add_edge(2, 3) + + partition = strategy.partition(graph) + assert sum(len(nodes) for nodes in partition.values()) == graph.number_of_nodes() + assert len(partition) >= 2 From 4a2f49aa319a5d484e6a2980a18fb4e46c7a31b2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:59:56 +0000 Subject: [PATCH 02/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/user-guide/index.md | 2 +- npap/partitioning/graph_theory.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 2f2351f..4769d55 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -9,7 +9,7 @@ installation quick-start workflows available-strategies -``` +``` ```{toctree} :hidden: diff --git a/npap/partitioning/graph_theory.py b/npap/partitioning/graph_theory.py index 919b28a..49e2af3 100644 --- a/npap/partitioning/graph_theory.py +++ b/npap/partitioning/graph_theory.py @@ -36,7 +36,9 @@ def required_attributes(self) -> dict[str, list[str]]: def _strategy_name(self) -> str: return "spectral_clustering" - def partition(self, graph: nx.DiGraph, /, *, n_clusters: int | None = None, **kwargs) -> dict[int, list[Any]]: + def partition( + self, graph: nx.DiGraph, /, *, n_clusters: int | None = None, **kwargs + ) -> dict[int, list[Any]]: if not n_clusters or n_clusters < 2: raise PartitioningError( "SpectralPartitioning requires n_clusters >= 2.", @@ -95,4 +97,4 @@ def partition(self, graph: nx.DiGraph, **kwargs) -> dict[int, list[Any]]: return partition_map -__all__ = ["SpectralPartitioning", "CommunityPartitioning"] +__all__ = ["CommunityPartitioning", "SpectralPartitioning"] From 3b04ea72ae1ff8460ca9c09009a8603a12b244b1 Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Fri, 20 Feb 2026 18:37:07 +0100 Subject: [PATCH 03/23] Address review comments --- npap/aggregation/physical_strategies.py | 15 ++++++++++++--- npap/managers.py | 8 ++++++-- npap/partitioning/graph_theory.py | 16 +++++++++++++--- test/test_aggregation.py | 15 +++++++++++++++ test/test_partitioning.py | 15 +++++++++++++++ 5 files changed, 61 insertions(+), 8 deletions(-) diff --git a/npap/aggregation/physical_strategies.py b/npap/aggregation/physical_strategies.py index 7cdbe4b..c287b85 100644 --- a/npap/aggregation/physical_strategies.py +++ b/npap/aggregation/physical_strategies.py @@ -11,6 +11,7 @@ ) from npap.exceptions import AggregationError from npap.interfaces import EdgeType, PhysicalAggregationStrategy +from npap.logging import LogCategory, log_warning class TransformerConservationStrategy(PhysicalAggregationStrategy): @@ -60,8 +61,11 @@ def aggregate( properties: list[str], parameters: dict[str, Any] | None = None, ) -> nx.DiGraph: - node_to_cluster = build_node_to_cluster_map(partition_map) - cluster_edge_map = build_cluster_edge_map(original_graph, node_to_cluster) + params = parameters or {} + node_to_cluster = params.get("node_to_cluster") or build_node_to_cluster_map(partition_map) + cluster_edge_map = params.get("cluster_edge_map") or build_cluster_edge_map( + original_graph, node_to_cluster + ) for u, v in topology_graph.edges(): original_edges = cluster_edge_map.get((u, v), []) @@ -89,7 +93,12 @@ def aggregate( def _calculate_equivalent(self, edges: list[dict[str, Any]], prop: str) -> float | None: try: return self._equivalent_reactance.aggregate_property(edges, prop) - except AggregationError: + except AggregationError as exc: + log_warning( + f"Failed to aggregate '{prop}' for transformer group: {exc}", + LogCategory.AGGREGATION, + warn_user=True, + ) return None diff --git a/npap/managers.py b/npap/managers.py index bc2d981..1b60045 100644 --- a/npap/managers.py +++ b/npap/managers.py @@ -294,20 +294,24 @@ def aggregate( physical_strategy = self._physical_strategies[profile.physical_strategy] # Validate topology compatibility - if topology_strategy.__class__.__name__ != physical_strategy.required_topology: + if profile.topology_strategy != physical_strategy.required_topology: log_warning( f"Physical strategy '{profile.physical_strategy}' recommends '{physical_strategy.required_topology}' topology, " f"but '{profile.topology_strategy}' is being used. Results may be incorrect.", LogCategory.AGGREGATION, ) + physical_parameters = dict(profile.physical_parameters or {}) + physical_parameters.setdefault("node_to_cluster", node_to_cluster) + physical_parameters.setdefault("cluster_edge_map", cluster_edge_map) + # Apply physical aggregation aggregated = physical_strategy.aggregate( graph, partition_map, aggregated, profile.physical_properties, - profile.physical_parameters, + physical_parameters, ) # Mark properties as modified by physical strategy diff --git a/npap/partitioning/graph_theory.py b/npap/partitioning/graph_theory.py index 49e2af3..d3c74dc 100644 --- a/npap/partitioning/graph_theory.py +++ b/npap/partitioning/graph_theory.py @@ -8,6 +8,7 @@ from npap.exceptions import PartitioningError from npap.interfaces import PartitioningStrategy +from npap.logging import LogCategory, log_warning from npap.utils import create_partition_map, validate_partition @@ -16,9 +17,11 @@ class SpectralPartitioning(PartitioningStrategy): Partition using spectral clustering on the graph Laplacian. This strategy converts the input graph into a symmetric adjacency matrix and applies - ``sklearn.cluster.SpectralClustering`` with ``affinity="precomputed"``. Use this when - you need community-aware partitions on graphs that are not easily handled by geometric - distances (e.g., line topology with weak geographic structure). + ``sklearn.cluster.SpectralClustering`` with ``affinity="precomputed"``. Because the + adjacency matrix mirrors the existing connectivity, disconnected AC islands remain + separate clusters, so the strategy respects island boundaries automatically. Use this + when you need community-aware partitions on graphs that are not easily handled by + geometric distances (e.g., weak geographic structure with strong topological signals). Parameters ---------- @@ -91,6 +94,13 @@ def partition(self, graph: nx.DiGraph, **kwargs) -> dict[int, list[Any]]: strategy=self._strategy_name(), ) + if "n_clusters" in kwargs: + log_warning( + "community_modularity determines cluster count automatically; 'n_clusters' is ignored.", + LogCategory.PARTITIONING, + warn_user=False, + ) + partition_map = {idx: list(cluster) for idx, cluster in enumerate(communities)} validate_partition(partition_map, graph.number_of_nodes(), self._strategy_name()) diff --git a/test/test_aggregation.py b/test/test_aggregation.py index cc2c70e..075d531 100644 --- a/test/test_aggregation.py +++ b/test/test_aggregation.py @@ -617,6 +617,21 @@ def test_physical_strategy_preserves_transformers(self): assert edge_data["x"] == pytest.approx(1 / (1 / 0.1 + 1 / 0.2)) assert edge_data["r"] == pytest.approx(1 / (1 / 0.01 + 1 / 0.02)) + def test_without_transformers_still_aggregates(self): + manager = AggregationManager() + + graph = nx.DiGraph() + graph.add_node(0, lat=0.0, lon=0.0, voltage=110.0) + graph.add_node(1, lat=1.0, lon=1.0, voltage=110.0) + graph.add_edge(0, 1, type="line", x=0.05, r=0.005, p_max=100.0) + + partition = {0: [0], 1: [1]} + profile = AggregationManager.get_mode_profile(AggregationMode.CONSERVATION) + + aggregated = manager.aggregate(graph, partition, profile=profile) + assert aggregated.edges[0, 1]["p_max"] == pytest.approx(100.0) + assert "transformer_count" not in aggregated.edges[0, 1] + # ============================================================================= # EDGE CASE TESTS diff --git a/test/test_partitioning.py b/test/test_partitioning.py index 2946c25..349ccdc 100644 --- a/test/test_partitioning.py +++ b/test/test_partitioning.py @@ -7,6 +7,8 @@ - VAGeographicalPartitioning (with AC island and voltage awareness) """ +import logging + import networkx as nx import numpy as np import pytest @@ -1132,3 +1134,16 @@ def test_detects_multiple_communities(self): partition = strategy.partition(graph) assert sum(len(nodes) for nodes in partition.values()) == graph.number_of_nodes() assert len(partition) >= 2 + + +class TestCommunityPartitioningWarnings: + def test_n_clusters_warning(self, caplog): + strategy = CommunityPartitioning() + graph = nx.DiGraph() + graph.add_nodes_from([0, 1, 2]) + graph.add_edges_from([(0, 1), (1, 2), (2, 0)]) + + caplog.set_level(logging.WARNING) + strategy.partition(graph, n_clusters=2) + + assert "'n_clusters' is ignored" in caplog.text From 7a459cd8edc45232538199f6a57c7db79129746b Mon Sep 17 00:00:00 2001 From: Marco Anarmo Date: Mon, 23 Feb 2026 10:28:34 +0100 Subject: [PATCH 04/23] Remove empty roadmap link from feature template --- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index d100390..da5c9bd 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -7,7 +7,7 @@ body: value: | Thank you for suggesting a feature! Please consider whether it fits NPAP's scope (network partitioning & aggregation). - Before submitting, please check [existing issues](https://github.com/IEE-TUGraz/NPAP/issues) and the [roadmap](https://github.com/IEE-TUGraz/NPAP#roadmap) to see if it's already planned. + Before submitting, please check [existing issues](https://github.com/IEE-TUGraz/NPAP/issues) to see if it's already planned. - type: textarea id: description From c83ebc40f11f05447d90941f1e4d45ad85cea072 Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Fri, 20 Feb 2026 18:55:16 +0100 Subject: [PATCH 05/23] Add graph copy helper --- docs/user-guide/index.md | 7 +++++++ npap/managers.py | 17 +++++++++++++++++ test/test_smoke.py | 20 ++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 4769d55..b0f3a8b 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -149,6 +149,13 @@ Different strategies require specific node and edge attributes: | Electrical | `ac_island` | `x` (reactance) | | Voltage-Aware | `lat`, `lon`, `voltage`, `ac_island` | `x`, `type` | +### Cloning Loaded Graphs + +If you want to experiment with variations of a loaded network without re-running the loaders, +use `PartitionAggregatorManager.copy_graph()` to obtain a deep copy of the current graph. The copy +preserves every node/edge attribute but is entirely separate from the manager’s internal storage, +so you can test different aggregation paths or visualizations without modifying the source. + ## Error Handling NPAP provides a comprehensive exception hierarchy: diff --git a/npap/managers.py b/npap/managers.py index 1b60045..d91d7ec 100644 --- a/npap/managers.py +++ b/npap/managers.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import copy from typing import Any import networkx as nx @@ -1000,6 +1003,20 @@ def aggregate_parallel_edges( return self._current_graph + def copy_graph(self) -> nx.DiGraph | nx.MultiDiGraph: + """ + Return a deep copy of the currently loaded graph. + + Raises + ------ + ValueError + If no graph has been loaded. + """ + if not self._current_graph: + raise ValueError("No graph loaded. Call load_data() first.") + + return copy.deepcopy(self._current_graph) + def full_workflow( self, data_strategy: str, diff --git a/test/test_smoke.py b/test/test_smoke.py index d5db0bd..aef898c 100644 --- a/test/test_smoke.py +++ b/test/test_smoke.py @@ -1,3 +1,5 @@ +import networkx as nx + import npap @@ -31,3 +33,21 @@ def test_manager_instantiation(): # Verify internal state is initialized assert hasattr(manager, "partitioning_manager") assert hasattr(manager, "aggregation_manager") + + +def test_copy_graph_returns_deepcopy(): + """Ensure the manager can duplicate the current graph.""" + from npap.managers import PartitionAggregatorManager + + manager = PartitionAggregatorManager() + graph = nx.DiGraph() + graph.add_edge("a", "b", x=0.1) + manager._current_graph = graph + + graph_copy = manager.copy_graph() + + assert graph_copy is not graph + assert nx.is_isomorphic(graph_copy, graph) + + graph_copy.remove_edge("a", "b") + assert graph.has_edge("a", "b") From d33cf5adeb5b1403611ab4e69dca0ea9ab09a348 Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Fri, 20 Feb 2026 19:53:55 +0100 Subject: [PATCH 06/23] feature enhancement: copy graph visualization --- docs/api/index.rst | 1 + docs/api/partitioning.rst | 16 ++ docs/api/visualization.rst | 18 ++ docs/user-guide/aggregation.md | 37 ++- docs/user-guide/available-strategies.md | 61 ++++- docs/user-guide/partitioning/index.md | 37 +++ docs/user-guide/visualization.md | 50 +++- npap/aggregation/__init__.py | 3 +- npap/aggregation/modes.py | 56 ++++- npap/aggregation/physical_strategies.py | 301 +++++++++++++++++++++++- npap/interfaces.py | 3 + npap/managers.py | 8 + npap/partitioning/__init__.py | 4 + npap/partitioning/adjacent.py | 202 ++++++++++++++++ npap/partitioning/lmp.py | 167 +++++++++++++ npap/visualization.py | 116 ++++++++- test/test_aggregation.py | 91 ++++++- test/test_partitioning.py | 126 ++++++++++ test/test_visualization.py | 52 ++++ 19 files changed, 1314 insertions(+), 35 deletions(-) create mode 100644 docs/api/visualization.rst create mode 100644 npap/partitioning/adjacent.py create mode 100644 npap/partitioning/lmp.py create mode 100644 test/test_visualization.py diff --git a/docs/api/index.rst b/docs/api/index.rst index 96e5cf9..d49b283 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -10,6 +10,7 @@ This section contains the complete API reference for NPAP. interfaces partitioning aggregation + visualization exceptions Core Module diff --git a/docs/api/partitioning.rst b/docs/api/partitioning.rst index 1387439..88c0dd3 100644 --- a/docs/api/partitioning.rst +++ b/docs/api/partitioning.rst @@ -21,6 +21,22 @@ Electrical Distance Partitioning :show-inheritance: :no-index: +LMP Partitioning +---------------- + +.. automodule:: npap.partitioning.lmp + :members: + :show-inheritance: + :no-index: + +Adjacent-Node Agglomerative Clustering +-------------------------------------- + +.. automodule:: npap.partitioning.adjacent + :members: + :show-inheritance: + :no-index: + Voltage-Aware Partitioning -------------------------- diff --git a/docs/api/visualization.rst b/docs/api/visualization.rst new file mode 100644 index 0000000..1e77f9a --- /dev/null +++ b/docs/api/visualization.rst @@ -0,0 +1,18 @@ +Visualization +============= + +.. currentmodule:: npap.visualization + +Visualization helpers for NPAP. + +.. autosummary:: + :toctree: generated + :nosignatures: + + PlotStyle + PlotPreset + PlotConfig + NetworkPlotter + plot_network + export_figure + clone_graph diff --git a/docs/user-guide/aggregation.md b/docs/user-guide/aggregation.md index b73bbba..7f80e8e 100644 --- a/docs/user-guide/aggregation.md +++ b/docs/user-guide/aggregation.md @@ -48,7 +48,8 @@ NPAP provides predefined aggregation modes for common use cases. |------|-------------|----------| | `SIMPLE` | Sum all numeric properties | Basic reduction | | `GEOGRAPHICAL` | Average coordinates, sum loads | Spatial analysis | -| `DC_KRON` | Kron reduction for DC networks | DC network analysis | +| `DC_PTDF` | PTDF-driven Kron reduction | DC electrical analysis | +| `DC_KRON` | Kron reduction | DC electrical research | | `CUSTOM` | User-defined profile | Advanced use | ### SIMPLE Mode @@ -82,6 +83,25 @@ Configuration: - Edge `x`: `average` - Default: `average` +### DC_PTDF Mode + +For DC-specific reductions use this mode to preserve PTDF-derived reactances. + +```python +aggregated = manager.aggregate(mode=AggregationMode.DC_PTDF) +``` + +Configuration: +- Topology: `electrical` +- Physical: `ptdf_reduction` +- Physical properties: `x` (reactance is handled via PTDF/Kron reduction) +- Node properties: `lat`, `lon` → `average` +- Edge `p_max`: `sum` + +The aggregated graph carries a `reduced_ptdf` matrix on `aggregated.graph`, +which you can read via `aggregated.graph["reduced_ptdf"]` to inspect the +reduced PTDF used during the reduction. + ### CUSTOM Mode For full control, create an {py:class}`~npap.AggregationProfile`: @@ -278,12 +298,21 @@ edge_properties={ Physical aggregation strategies preserve electrical laws during network reduction. -### Kron Reduction (Planned) +### Kron Reduction Mode -```{note} -Kron reduction is planned for a future release. +Kron reduction is now available via `AggregationMode.DC_KRON`. It uses the +`kron_reduction` physical strategy on an electrical topology so the aggregated +reactances reflect a Kron-reduced susceptance matrix of the cluster +representatives. + +```python +aggregated = manager.aggregate(mode=AggregationMode.DC_KRON) ``` +The aggregated graph stores the reduced Laplacian under +`aggregated.graph["kron_reduced_laplacian"]`, which you can inspect for +downstream DC studies or debugging. + ## Handling Defaults When a property isn't explicitly mapped, NPAP uses the default strategy: diff --git a/docs/user-guide/available-strategies.md b/docs/user-guide/available-strategies.md index 6a06c2f..e69b510 100644 --- a/docs/user-guide/available-strategies.md +++ b/docs/user-guide/available-strategies.md @@ -175,6 +175,45 @@ Graph-theory strategies rely on matrix-based clustering or community detection r Spectral clustering expects `n_clusters` as an argument and splits the adjacency matrix via Eigen-decomposition, while the community strategy returns the modularity-maximizing partition automatically. +### Adjacent-node Agglomerative Clustering + +`adjacent_agglomerative` merges groups only via edges that already exist in the graph. It is useful for topology-aware workflows that should not combine disconnected buses even when their attributes look similar. + +**Required node attributes**: depends on `AdjacentAgglomerativeConfig.node_attribute`; any numeric attribute can guide the merging order. + +**Configuration**: use {py:class}`~npap.partitioning.adjacent.AdjacentAgglomerativeConfig` to choose the attribute that scores merges and to keep AC islands separate via `ac_island_attr`. When the attribute is omitted, merges fall back to any adjacent pair in deterministic order. + +```python +from npap.partitioning import AdjacentAgglomerativeConfig +from npap import PartitionAggregatorManager + +config = AdjacentAgglomerativeConfig(node_attribute="load", ac_island_attr="ac_island") +manager = PartitionAggregatorManager() +partition = manager.partition("adjacent_agglomerative", n_clusters=4, config=config) +``` + +### LMP Partitioning + +The `lmp_similarity` strategy groups buses with similar locational marginal prices (LMPs, typically provided by an OPF or market simulation) and optionally favours directly connected nodes through the `adjacency_bonus` configuration parameter. Nodes with different `ac_island` values are always separated by a large `infinite_distance` to keep islands apart. + +**Required node attributes**: `lmp` (or a custom attribute specified via `price_attribute`) + +**Configuration**: Use {py:class}`~npap.partitioning.lmp.LMPPartitioningConfig` to override the attribute names, adjacency bonus, or infinite-distance penalty. + +```python +from npap.partitioning import LMPPartitioningConfig +from npap import PartitionAggregatorManager + +config = LMPPartitioningConfig( + price_attribute="locational_price", + adjacency_bonus=0.2, + infinite_distance=1e5, +) + +manager = PartitionAggregatorManager() +partition = manager.partition("lmp_similarity", n_clusters=3, config=config) +``` + ### Choosing a Partitioning Strategy ```{mermaid} @@ -215,7 +254,8 @@ Predefined {py:class}`~npap.AggregationMode` for common use cases: |------|----------|-----------------|------------------------------------| | `SIMPLE` | simple | sum all | sum all | | `GEOGRAPHICAL` | simple | avg coords, sum loads | sum capacity, equivalent reactance | -| `DC_KRON` | electrical | Kron reduction | Kron reduction | +| `DC_PTDF` | electrical | PTDF reduction | PTDF-derived reactance values | +| `DC_KRON` | electrical | Kron reduction | Kron reduction | | `CONSERVATION` | electrical | Equivalent reactance + transformer preservation | Preserves transformer count & impedance | | `CUSTOM` | user-defined | user-defined | user-defined | @@ -226,6 +266,23 @@ from npap import AggregationMode aggregated = manager.aggregate(mode=AggregationMode.GEOGRAPHICAL) ``` +### PTDF Reduction Mode + +`AggregationMode.DC_PTDF` wires the `"ptdf_reduction"` physical strategy into an +electrical topology, so the reduced graph stores PTDF-consistent reactances for +each aggregated edge and exposes a `reduced_ptdf` matrix in `aggregated.graph`. +This mode keeps `x` coupled to the physical reduction step instead of applying +statistical averaging. + +### Kron Reduction Mode + +`AggregationMode.DC_KRON` applies the `"kron_reduction"` strategy to an +electrical topology. The strategy eliminates nodes that belong to each cluster +and keeps only the representative nodes, so aggregated reactances follow a +Kron-reduced susceptance matrix. The resulting Laplacian is stored in +`aggregated.graph["kron_reduced_laplacian"]` for debugging or downstream DC +studies. + ### Custom Aggregation Profile For full control over the aggregation step, create an {py:class}`~npap.AggregationProfile`: @@ -305,7 +362,7 @@ See [Aggregation](aggregation.md) for detailed documentation. | Enum | Values | |------|--------| | {py:class}`~npap.EdgeType` | `LINE`, `TRAFO`, `DC_LINK` | -| {py:class}`~npap.AggregationMode` | `SIMPLE`, `GEOGRAPHICAL`, `DC_KRON`, `CUSTOM`, `CONSERVATION` | +| {py:class}`~npap.AggregationMode` | `SIMPLE`, `GEOGRAPHICAL`, `DC_PTDF`, `DC_KRON`, `CUSTOM`, `CONSERVATION` | ### Exceptions diff --git a/docs/user-guide/partitioning/index.md b/docs/user-guide/partitioning/index.md index 1feb5ce..e066d31 100644 --- a/docs/user-guide/partitioning/index.md +++ b/docs/user-guide/partitioning/index.md @@ -120,6 +120,8 @@ flowchart TD | Geographical with AC islands | `geographical_kmedoids_haversine` | | Electrical behavior grouping | `electrical_kmedoids` | | Multi-voltage network | `va_geographical_kmedoids_haversine` | +| Congestion-aware pricing | `lmp_similarity` | +| Topology-preserving merges | `adjacent_agglomerative` | | Unknown cluster count | `geographical_dbscan_*` or `geographical_hdbscan_*` | ### Performance Comparison @@ -160,6 +162,41 @@ config = ElectricalDistanceConfig( ) ``` +### Adjacent-node Agglomerative Configuration + +Use {py:class}`~npap.partitioning.adjacent.AdjacentAgglomerativeConfig` when you need +clusters to grow only along explicit edges. The optional `node_attribute` +parameter determines the values AgglomerativeClustering compares as it chooses +which adjacent clusters to merge next, while `ac_island_attr` keeps islands +separate even when DC links exist. + +```python +from npap.partitioning import AdjacentAgglomerativeConfig + +config = AdjacentAgglomerativeConfig( + node_attribute="load", + ac_island_attr="ac_island", +) +``` + +Pass the config via `config=` when calling `PartitionAggregatorManager.partition`. + +### LMP Partitioning Configuration + +LMP partitioning relies on locational marginal prices (LMPs) to highlight congestion-aware clusters. Use {py:class}`~npap.partitioning.lmp.LMPPartitioningConfig` when your price column is named differently or when you want to tweak the adjacency/infinite-distance handling. + +```python +from npap.partitioning import LMPPartitioningConfig + +config = LMPPartitioningConfig( + price_attribute="locational_price", + adjacency_bonus=0.2, # neighbours with similar prices merge quicker + infinite_distance=1e5, # separate distinct AC islands +) +``` + +Apply the config via the `config` keyword when calling `PartitionAggregatorManager.partition("lmp_similarity", ...)`. + ## Working with Partition Results ### Inspecting Results diff --git a/docs/user-guide/visualization.md b/docs/user-guide/visualization.md index 21b1b34..d60cfd3 100644 --- a/docs/user-guide/visualization.md +++ b/docs/user-guide/visualization.md @@ -36,6 +36,19 @@ partition = manager.partition("geographical_kmeans", n_clusters=10) manager.plot_network(style="clustered") ``` +The ``plot_network`` helper now accepts the ``partition_result`` keyword, +so you can pass the full {py:class}`~npap.PartitionResult` returned by +``PartitionAggregatorManager.partition`` directly without extracting ``mapping``. + +```python +fig = manager.plot_network( + style="clustered", + partition_result=partition, + preset="cluster_highlight", + show=False, +) +``` + ## Plot Styles NPAP provides three built-in plot styles: @@ -227,13 +240,30 @@ fig.update_layout( ) # Save to file -fig.write_html("network.html") -fig.write_image("network.png") +from npap.visualization import export_figure + +export_figure(fig, "network.html") +export_figure(fig, "network.png") # Requires `kaleido` for PNG/SVG/PDF # Display fig.show() ``` +### Exporting Figures for Reports + +Use {py:func}`~npap.visualization.export_figure` for reproducible exports without +relying on Plotly's toolbar buttons. The helper infers the format from the target +path (``.html`` by default) and writes PNG/SVG/PDF when ``kaleido`` is installed. + +```python +fig = manager.plot_network(style="clustered", show=False) +export_figure(fig, "reports/network.html") +export_figure(fig, "reports/network.png", scale=2.0) +``` + +Pass ``format="svg"`` or ``format="pdf"`` when the file extension cannot be derived +from the path. + ### Adding Custom Traces ```python @@ -304,6 +334,22 @@ G.add_edge("A", "B") fig = manager.plot_network(graph=G, style="simple") ``` +### Cloning Graphs for Safe Editing + +When you need to try different styling or annotations without mutating the +original graph, {py:func}`~npap.visualization.clone_graph` returns a deep copy: + +```python +from npap.visualization import clone_graph + +backup = clone_graph(manager.copy_graph()) +backup.add_node("demo", lat=0.0, lon=0.0) +fig = manager.plot_network(graph=backup, style="simple", show=False) +``` + +This is handy when generating multiple views (e.g., highlighting different +clusters or adding temporary annotations) while keeping the base graph intact. + ## Performance Optimization ### Large Networks diff --git a/npap/aggregation/__init__.py b/npap/aggregation/__init__.py index d1e84db..1d7fa78 100644 --- a/npap/aggregation/__init__.py +++ b/npap/aggregation/__init__.py @@ -37,7 +37,7 @@ build_typed_cluster_edge_map, ) from .modes import get_mode_profile -from .physical_strategies import KronReductionStrategy +from .physical_strategies import KronReductionStrategy, PTDFReductionStrategy __all__ = [ "AverageEdgeStrategy", @@ -47,6 +47,7 @@ "FirstEdgeStrategy", "FirstNodeStrategy", "KronReductionStrategy", + "PTDFReductionStrategy", "SimpleTopologyStrategy", "SumEdgeStrategy", "SumNodeStrategy", diff --git a/npap/aggregation/modes.py b/npap/aggregation/modes.py index 05852c3..9dc4ad3 100644 --- a/npap/aggregation/modes.py +++ b/npap/aggregation/modes.py @@ -38,11 +38,10 @@ def get_mode_profile(mode: AggregationMode, **overrides) -> AggregationProfile: profile = AggregationProfile(mode=AggregationMode.CUSTOM) elif mode == AggregationMode.CONSERVATION: profile = _conservation_mode() - elif mode in [AggregationMode.DC_KRON]: - raise NotImplementedError( - f"Mode {mode} is not yet implemented. " - f"Use AggregationMode.SIMPLE or AggregationMode.GEOGRAPHICAL for now." - ) + elif mode == AggregationMode.DC_PTDF: + profile = _ptdf_mode() + elif mode == AggregationMode.DC_KRON: + profile = _kron_mode() else: raise ValueError(f"Unknown aggregation mode: {mode}") @@ -140,3 +139,50 @@ def _conservation_mode() -> AggregationProfile: default_edge_strategy="sum", warn_on_defaults=True, ) + + +def _ptdf_mode() -> AggregationProfile: + """ + PTDF-based aggregation mode for DC networks. + + Builds an electrical topology, applies the PTDF reduction physical strategy, + and keeps reactance as a physical property so equivalent PTDF-driven + reactances are propagated before statistical aggregation. + """ + return AggregationProfile( + mode=AggregationMode.DC_PTDF, + topology_strategy="electrical", + physical_strategy="ptdf_reduction", + physical_properties=["x"], + node_properties={ + "lat": "average", + "lon": "average", + }, + edge_properties={"p_max": "sum"}, + default_node_strategy="average", + default_edge_strategy="sum", + warn_on_defaults=True, + ) + + +def _kron_mode() -> AggregationProfile: + """ + Kron reduction mode for DC networks. + + Electrical topology + Kron reduction for reactances that match a reduced + DC network of cluster representatives. + """ + return AggregationProfile( + mode=AggregationMode.DC_KRON, + topology_strategy="electrical", + physical_strategy="kron_reduction", + physical_properties=["x"], + node_properties={ + "lat": "average", + "lon": "average", + }, + edge_properties={"p_max": "sum"}, + default_node_strategy="average", + default_edge_strategy="sum", + warn_on_defaults=True, + ) diff --git a/npap/aggregation/physical_strategies.py b/npap/aggregation/physical_strategies.py index c287b85..38288df 100644 --- a/npap/aggregation/physical_strategies.py +++ b/npap/aggregation/physical_strategies.py @@ -1,8 +1,10 @@ from __future__ import annotations +from collections.abc import Sequence from typing import Any import networkx as nx +import numpy as np from npap.aggregation.basic_strategies import ( EquivalentReactanceStrategy, @@ -13,6 +15,156 @@ from npap.interfaces import EdgeType, PhysicalAggregationStrategy from npap.logging import LogCategory, log_warning +_MIN_REACTANCE = 1e-6 +_MIN_SUSCEPTANCE = 1e-12 + + +def _build_admittance_matrix(graph: nx.DiGraph, reactance_property: str) -> tuple[np.ndarray, dict[Any, int]]: + """Build Laplacian (admittance) matrix for the given graph.""" + nodes = list(graph.nodes()) + n = len(nodes) + + if n == 0: + raise AggregationError("Graph must contain nodes for PTDF reduction.", strategy="ptdf_reduction") + + node_to_index: dict[Any, int] = {node: idx for idx, node in enumerate(nodes)} + + laplacian = np.zeros((n, n), dtype=float) + processed = False + + for u, v, data in graph.edges(data=True): + x_value = data.get(reactance_property) + if x_value is None or not isinstance(x_value, (int, float)): + continue + + reactance = float(x_value) + if abs(reactance) < _MIN_REACTANCE: + reactance = _MIN_REACTANCE + + susceptance = 1.0 / reactance + u_idx = node_to_index[u] + v_idx = node_to_index[v] + + laplacian[u_idx, u_idx] += susceptance + laplacian[v_idx, v_idx] += susceptance + laplacian[u_idx, v_idx] -= susceptance + laplacian[v_idx, u_idx] -= susceptance + + processed = True + + if not processed: + raise AggregationError( + f"No numeric '{reactance_property}' values found for PTDF reduction.", + strategy="ptdf_reduction", + ) + + return laplacian, node_to_index + + +def _kron_reduce_laplacian( + laplacian: np.ndarray, keep_indices: Sequence[int] +) -> np.ndarray: + """Apply Kron reduction to the given Laplacian matrix.""" + n = laplacian.shape[0] + all_indices = set(range(n)) + keep_set = set(keep_indices) + eliminate = sorted(all_indices - keep_set) + + reduced = laplacian[np.ix_(keep_indices, keep_indices)].copy() + + if not eliminate: + return reduced + + lap_kk = laplacian[np.ix_(keep_indices, keep_indices)] + lap_ke = laplacian[np.ix_(keep_indices, eliminate)] + lap_ek = laplacian[np.ix_(eliminate, keep_indices)] + lap_ee = laplacian[np.ix_(eliminate, eliminate)] + + try: + inv_lap_ee = np.linalg.inv(lap_ee) + except np.linalg.LinAlgError: + inv_lap_ee = np.linalg.pinv(lap_ee) + + return lap_kk - lap_ke @ inv_lap_ee @ lap_ek + + +def _select_representatives(partition_map: dict[int, list[Any]], cluster_order: list[int]) -> list[Any]: + representatives: list[Any] = [] + for cluster in cluster_order: + nodes = partition_map.get(cluster) + if not nodes: + raise AggregationError( + f"Cluster {cluster} has no nodes, cannot compute PTDF reduction.", + strategy="ptdf_reduction", + ) + representatives.append(nodes[0]) + return representatives + + +def _compute_reduced_ptdf(graph: nx.DiGraph, reactance_property: str) -> dict[str, Any]: + """Build a reduced PTDF matrix for the aggregated (cluster) graph.""" + nodes = list(graph.nodes()) + if len(nodes) <= 1: + return {"matrix": np.zeros((0, len(nodes))), "nodes": nodes, "slack": None, "edges": []} + + node_to_index = {node: idx for idx, node in enumerate(nodes)} + unique_edges: dict[tuple[Any, Any], dict[str, Any]] = {} + + for u, v, data in graph.edges(data=True): + key = tuple(sorted((u, v))) + if key[0] == key[1]: + continue + + susceptance = data.get("susceptance") + if susceptance is None: + reactance = data.get(reactance_property) + if reactance is None or not isinstance(reactance, (int, float)): + continue + susceptance = 1.0 / max(abs(reactance), _MIN_REACTANCE) + + row = unique_edges.setdefault(key, {"u": key[0], "v": key[1], "susceptance": 0.0}) + row["susceptance"] += susceptance + + edges = list(unique_edges.values()) + if not edges: + return {"matrix": np.zeros((0, len(nodes))), "nodes": nodes, "slack": None, "edges": []} + + edge_susceptances = np.array([edge["susceptance"] for edge in edges], dtype=float) + incidence = np.zeros((len(edges), len(nodes)), dtype=float) + for idx, edge in enumerate(edges): + u_idx = node_to_index[edge["u"]] + v_idx = node_to_index[edge["v"]] + incidence[idx, u_idx] = 1.0 + incidence[idx, v_idx] = -1.0 + + slack_node = nodes[0] + slack_idx = node_to_index[slack_node] + keep_indices = [idx for idx in range(len(nodes)) if idx != slack_idx] + if not keep_indices: + return {"matrix": np.zeros((len(edges), len(nodes))), "nodes": nodes, "slack": slack_node, "edges": edges} + + incidence_sba = incidence[:, keep_indices] + weight_diag = np.diag(edge_susceptances) + b_matrix = incidence_sba.T @ weight_diag @ incidence_sba + + try: + inv_b = np.linalg.inv(b_matrix) + except np.linalg.LinAlgError: + inv_b = np.linalg.pinv(b_matrix) + + ptdf_core = weight_diag @ incidence_sba @ inv_b + ptdf_full = np.zeros((len(edges), len(nodes)), dtype=float) + + for idx, node_idx in enumerate(keep_indices): + ptdf_full[:, node_idx] = ptdf_core[:, idx] + + return { + "matrix": ptdf_full, + "nodes": nodes, + "slack": slack_node, + "edges": [(edge["u"], edge["v"]) for edge in edges], + } + class TransformerConservationStrategy(PhysicalAggregationStrategy): """ @@ -102,20 +254,104 @@ def _calculate_equivalent(self, edges: list[dict[str, Any]], prop: str) -> float return None +class PTDFReductionStrategy(PhysicalAggregationStrategy): + """ + PTDF-based reduction for DC networks. + + This strategy builds a Kron-reduced Laplacian for the representative nodes + of each cluster, derives the susceptance between aggregated nodes, and + populates the resulting edges with reactance values that match the reduced PTDF. + """ + + def __init__(self, reactance_property: str = "x"): + self.reactance_property = reactance_property + + @property + def required_properties(self) -> list[str]: + return [self.reactance_property] + + @property + def modifies_properties(self) -> list[str]: + return [self.reactance_property] + + @property + def can_create_edges(self) -> bool: + return True + + @property + def required_topology(self) -> str: + return "electrical" + + def aggregate( + self, + original_graph: nx.DiGraph, + partition_map: dict[int, list[Any]], + topology_graph: nx.DiGraph, + properties: list[str], + parameters: dict[str, Any] | None = None, + ) -> nx.DiGraph: + del parameters # unused + del properties # unused + cluster_order = list(topology_graph.nodes()) + representatives = _select_representatives(partition_map, cluster_order) + + laplacian, node_to_index = _build_admittance_matrix( + original_graph, self.reactance_property + ) + keep_indices = [node_to_index[node] for node in representatives] + reduced_laplacian = _kron_reduce_laplacian(laplacian, keep_indices) + + aggregated_graph = nx.DiGraph() + aggregated_graph.add_nodes_from( + (node, dict(topology_graph.nodes[node])) for node in topology_graph.nodes() + ) + + for src_idx, src_cluster in enumerate(cluster_order): + for dst_idx, dst_cluster in enumerate(cluster_order): + if src_idx == dst_idx: + continue + + susceptance = -reduced_laplacian[src_idx, dst_idx] + if susceptance <= _MIN_SUSCEPTANCE or np.isnan(susceptance): + continue + + reactance = 1.0 / susceptance + aggregated_graph.add_edge( + src_cluster, + dst_cluster, + **{ + self.reactance_property: reactance, + "susceptance": susceptance, + "aggregation_source": "ptdf_reduction", + }, + ) + + aggregated_graph.graph["reduced_ptdf"] = _compute_reduced_ptdf( + aggregated_graph, self.reactance_property + ) + + return aggregated_graph + + class KronReductionStrategy(PhysicalAggregationStrategy): """ Kron reduction for DC power flow networks. - TODO: Implementation pending. This is a placeholder. + Eliminates interior nodes that belong to each cluster, keeping only the cluster + representatives. The resulting Laplacian defines the equivalent susceptance + between clusters, so the aggregated edges inherit physics-consistent reactances. """ + def __init__(self, reactance_property: str = "x"): + self.reactance_property = reactance_property + @property def required_properties(self) -> list[str]: - return ["reactance"] + return [self.reactance_property] @property def modifies_properties(self) -> list[str]: - return ["reactance"] + return [self.reactance_property] @property def can_create_edges(self) -> bool: @@ -127,14 +363,57 @@ def required_topology(self) -> str: def aggregate( self, - original_graph: nx.Graph, + original_graph: nx.DiGraph, partition_map: dict[int, list[Any]], - topology_graph: nx.Graph, + topology_graph: nx.DiGraph, properties: list[str], - parameters: dict[str, Any] = None, - ) -> nx.Graph: - """Kron reduction - TO BE IMPLEMENTED""" - raise NotImplementedError( - "Kron reduction is not yet implemented. " - "Use AggregationMode.SIMPLE or AggregationMode.GEOGRAPHICAL for now." + parameters: dict[str, Any] | None = None, + ) -> nx.DiGraph: + del properties # handled via physical strategy + cluster_order = list(topology_graph.nodes()) + representatives = _select_representatives(partition_map, cluster_order) + + laplacian, node_to_index = _build_admittance_matrix( + original_graph, self.reactance_property ) + + try: + keep_indices = [node_to_index[node] for node in representatives] + except KeyError as exc: + raise AggregationError( + f"Representative node {exc.args[0]} missing reactance '{self.reactance_property}'.", + strategy="kron_reduction", + ) from exc + + reduced_laplacian = _kron_reduce_laplacian(laplacian, keep_indices) + + aggregated_graph = nx.DiGraph() + aggregated_graph.add_nodes_from( + (node, dict(topology_graph.nodes[node])) for node in topology_graph.nodes() + ) + + n_clusters = len(cluster_order) + for src_idx in range(n_clusters): + for dst_idx in range(n_clusters): + if src_idx == dst_idx: + continue + + susceptance = -reduced_laplacian[src_idx, dst_idx] + if susceptance <= _MIN_SUSCEPTANCE or np.isnan(susceptance): + continue + + reactance = 1.0 / susceptance + src_cluster = cluster_order[src_idx] + dst_cluster = cluster_order[dst_idx] + aggregated_graph.add_edge( + src_cluster, + dst_cluster, + **{ + self.reactance_property: reactance, + "susceptance": susceptance, + "aggregation_source": "kron_reduction", + }, + ) + + aggregated_graph.graph["kron_reduced_laplacian"] = reduced_laplacian + return aggregated_graph diff --git a/npap/interfaces.py b/npap/interfaces.py index 0d1230a..1ce2f5c 100644 --- a/npap/interfaces.py +++ b/npap/interfaces.py @@ -65,6 +65,8 @@ class AggregationMode(Enum): Average coordinates, sum other properties. DC_KRON : str Kron reduction for DC networks. + DC_PTDF : str + PTDF-driven Kron reduction for DC networks. CUSTOM : str User-defined aggregation profile. CONSERVATION : str @@ -74,6 +76,7 @@ class AggregationMode(Enum): SIMPLE = "simple" GEOGRAPHICAL = "geographical" DC_KRON = "dc_kron" + DC_PTDF = "dc_ptdf" CUSTOM = "custom" CONSERVATION = "transformer_conservation" diff --git a/npap/managers.py b/npap/managers.py index d91d7ec..6b9a340 100644 --- a/npap/managers.py +++ b/npap/managers.py @@ -80,9 +80,11 @@ def partition(self, graph: nx.DiGraph, method: str, **kwargs) -> dict[int, list[ def _register_default_strategies(self) -> None: """Register built-in partitioning strategies.""" + from npap.partitioning.adjacent import AdjacentNodeAgglomerativePartitioning from npap.partitioning.electrical import ElectricalDistancePartitioning from npap.partitioning.geographical import GeographicalPartitioning from npap.partitioning.graph_theory import CommunityPartitioning, SpectralPartitioning + from npap.partitioning.lmp import LMPPartitioning from npap.partitioning.va_geographical import ( VAGeographicalConfig, VAGeographicalPartitioning, @@ -98,6 +100,10 @@ def _register_default_strategies(self) -> None: self._strategies["geographical_kmedoids_haversine"] = GeographicalPartitioning( algorithm="kmedoids", distance_metric="haversine" ) + self._strategies["lmp_similarity"] = LMPPartitioning() + self._strategies[ + "adjacent_agglomerative" + ] = AdjacentNodeAgglomerativePartitioning() self._strategies["geographical_dbscan_euclidean"] = GeographicalPartitioning( algorithm="dbscan", distance_metric="euclidean" ) @@ -804,6 +810,7 @@ def _register_default_strategies(self) -> None: ) from npap.aggregation.physical_strategies import ( KronReductionStrategy, + PTDFReductionStrategy, TransformerConservationStrategy, ) @@ -813,6 +820,7 @@ def _register_default_strategies(self) -> None: # Physical strategies self._physical_strategies["kron_reduction"] = KronReductionStrategy() + self._physical_strategies["ptdf_reduction"] = PTDFReductionStrategy() self._physical_strategies["transformer_conservation"] = TransformerConservationStrategy() # Node property strategies diff --git a/npap/partitioning/__init__.py b/npap/partitioning/__init__.py index 7569546..6ef4a69 100644 --- a/npap/partitioning/__init__.py +++ b/npap/partitioning/__init__.py @@ -16,16 +16,20 @@ Voltage-aware electrical distance partitioning with AC island awareness. """ +from .adjacent import AdjacentNodeAgglomerativePartitioning from .electrical import ElectricalDistancePartitioning from .geographical import GeographicalPartitioning from .graph_theory import CommunityPartitioning, SpectralPartitioning +from .lmp import LMPPartitioning from .va_electrical import VAElectricalDistancePartitioning from .va_geographical import VAGeographicalPartitioning __all__ = [ + "AdjacentNodeAgglomerativePartitioning", "CommunityPartitioning", "ElectricalDistancePartitioning", "GeographicalPartitioning", + "LMPPartitioning", "SpectralPartitioning", "VAElectricalDistancePartitioning", "VAGeographicalPartitioning", diff --git a/npap/partitioning/adjacent.py b/npap/partitioning/adjacent.py new file mode 100644 index 0000000..0c63f2c --- /dev/null +++ b/npap/partitioning/adjacent.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +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_info +from npap.utils import create_partition_map, validate_partition, with_runtime_config + + +@dataclass +class AdjacentAgglomerativeConfig: + """ + Configuration for adjacent-node agglomerative clustering. + + Attributes + ---------- + node_attribute : str | None + Optional node attribute used to score merges. If omitted, all nodes get a + neutral score so merges are driven purely by adjacency. + ac_island_attr : str | None + Node attribute that identifies AC islands. When provided, edges connecting + different islands are ignored so island membership cannot be merged. + """ + + node_attribute: str | None = None + ac_island_attr: str | None = "ac_island" + + +class AdjacentNodeAgglomerativePartitioning(PartitioningStrategy): + """ + Merge clusters only along existing network edges. + + The strategy aggregates clusters using a greedy agglomerative procedure that + considers only adjacent nodes (or nodes connected through merged clusters). + An optional ``node_attribute`` lets you prefer merges between similar buses, + while ``ac_island_attr`` keeps AC islands separate even if DC links exist. + """ + + _CONFIG_PARAMS = {"node_attribute", "ac_island_attr"} + + def __init__(self, config: AdjacentAgglomerativeConfig | None = None): + self.config = config or AdjacentAgglomerativeConfig() + + @property + def required_attributes(self) -> dict[str, list[str]]: + if self.config.node_attribute: + return {"nodes": [self.config.node_attribute], "edges": []} + return {"nodes": [], "edges": []} + + def _strategy_name(self) -> str: + return "adjacent_agglomerative" + + @with_runtime_config(AdjacentAgglomerativeConfig, _CONFIG_PARAMS) + def partition(self, graph: nx.DiGraph, **kwargs) -> dict[int, list[Any]]: + """ + Partition nodes so only adjacent clusters merge. + + Parameters + ---------- + graph : nx.DiGraph + Directed graph representing network topology. + **kwargs : dict + Additional runtime overrides: + + - n_clusters : int (required) + - config : AdjacentAgglomerativeConfig + + Returns + ------- + dict[int, list[Any]] + Mapping from cluster ID to node IDs. + + Raises + ------ + PartitioningError + When ``n_clusters`` is invalid or adjacency cannot produce enough + merges. + ValidationError + When required node attributes are missing or not numeric. + """ + effective_config = kwargs.get("_effective_config", self.config) + node_attribute = effective_config.node_attribute + ac_attr = effective_config.ac_island_attr + n_clusters = kwargs.get("n_clusters") + + if n_clusters is None or n_clusters <= 0: + raise PartitioningError( + "n_clusters must be a positive integer.", + strategy=self._strategy_name(), + ) + + nodes = list(graph.nodes()) + n_nodes = len(nodes) + + if n_nodes == 0: + return {} + + if n_clusters > n_nodes: + raise PartitioningError( + f"Cannot create {n_clusters} clusters from {n_nodes} nodes.", + strategy=self._strategy_name(), + ) + + node_values: dict[Any, float] = {} + if node_attribute: + missing = [] + for node in nodes: + value = graph.nodes[node].get(node_attribute) + if not isinstance(value, (int, float)): + missing.append(node) + else: + node_values[node] = float(value) + if missing: + raise ValidationError( + f"Nodes {missing} lack a numeric '{node_attribute}' attribute.", + missing_attributes={"nodes": missing}, + strategy=self._strategy_name(), + ) + else: + node_values = dict.fromkeys(nodes, 0.0) + + node_islands: dict[Any, Any] = {} + if ac_attr: + for node in nodes: + node_islands[node] = graph.nodes[node].get(ac_attr) + + log_info( + f"Adjacent agglomerative partitioning (n_clusters={n_clusters}, attribute={node_attribute})", + LogCategory.PARTITIONING, + ) + + edges = [] + for u, v in graph.edges(): + if ac_attr: + island_u = node_islands.get(u) + island_v = node_islands.get(v) + if ( + island_u is not None + and island_v is not None + and island_u != island_v + ): + continue + edges.append((u, v)) + + if not edges and n_clusters < n_nodes: + raise PartitioningError( + "Graph has no adjacency edges to perform agglomerative merges.", + strategy=self._strategy_name(), + ) + + cluster_map = {node: node for node in nodes} + cluster_nodes: dict[Any, set[Any]] = {node: {node} for node in nodes} + cluster_stats: dict[Any, tuple[float, int]] = { + node: (node_values[node], 1) for node in nodes + } + + def cluster_mean(cluster_id: Any) -> float: + total, count = cluster_stats[cluster_id] + return total / count + + while len(cluster_nodes) > n_clusters: + best: tuple[float, tuple[Any, Any], Any, Any] | None = None + for u, v in edges: + cu = cluster_map[u] + cv = cluster_map[v] + if cu == cv or cu not in cluster_nodes or cv not in cluster_nodes: + continue + + diff = abs(cluster_mean(cu) - cluster_mean(cv)) + order = tuple(sorted((cu, cv))) + if best is None or diff < best[0] or (diff == best[0] and order < best[1]): + best = (diff, order, cu, cv) + + if best is None: + raise PartitioningError( + "Unable to reach requested cluster count via adjacency merges.", + strategy=self._strategy_name(), + ) + + _, _, cu, cv = best + keep, merge = (cu, cv) if cu < cv else (cv, cu) + + cluster_nodes[keep].update(cluster_nodes[merge]) + for node in cluster_nodes[merge]: + cluster_map[node] = keep + + sum_keep, count_keep = cluster_stats[keep] + sum_merge, count_merge = cluster_stats[merge] + cluster_stats[keep] = (sum_keep + sum_merge, count_keep + count_merge) + + del cluster_nodes[merge] + del cluster_stats[merge] + + labels = np.array([cluster_map[node] for node in nodes], dtype=int) + partition_map = create_partition_map(nodes, labels) + validate_partition(partition_map, n_nodes, self._strategy_name()) + return partition_map diff --git a/npap/partitioning/lmp.py b/npap/partitioning/lmp.py new file mode 100644 index 0000000..c6104b3 --- /dev/null +++ b/npap/partitioning/lmp.py @@ -0,0 +1,167 @@ +from dataclasses import dataclass +from typing import Any + +import networkx as nx +import numpy as np +from sklearn.cluster import AgglomerativeClustering + +from npap.exceptions import PartitioningError, ValidationError +from npap.interfaces import PartitioningStrategy +from npap.logging import LogCategory, log_info +from npap.utils import create_partition_map, validate_partition, with_runtime_config + + +@dataclass +class LMPPartitioningConfig: + """ + Configuration for LMP-based partitioning. + + Attributes + ---------- + price_attribute : str + Node attribute containing the locational marginal price (LMP). + ac_island_attr : str + Node attribute that identifies AC island membership (optional). + adjacency_bonus : float + Value subtracted from distances for directly connected nodes (clamped at 0). + infinite_distance : float + Distance value assigned to nodes that belong to different AC islands. + """ + + price_attribute: str = "lmp" + ac_island_attr: str = "ac_island" + adjacency_bonus: float = 0.0 + infinite_distance: float = 1e4 + + +class LMPPartitioning(PartitioningStrategy): + """ + Partition nodes by locational marginal prices (LMP). + + The strategy clusters nodes whose LMPs (or other custom ``price_attribute``) + are similar, optionally favouring directly connected buses through the + ``adjacency_bonus`` parameter. Nodes in different AC islands are separated via + a large ``infinite_distance`` penalty to preserve electrical isolation. + """ + + _CONFIG_PARAMS = {"price_attribute", "ac_island_attr", "adjacency_bonus", "infinite_distance"} + + def __init__(self, config: LMPPartitioningConfig | None = None): + self.config = config or LMPPartitioningConfig() + + @property + def required_attributes(self) -> dict[str, list[str]]: + return {"nodes": [self.config.price_attribute], "edges": []} + + def _get_strategy_name(self) -> str: + return "lmp_similarity" + + @with_runtime_config(LMPPartitioningConfig, _CONFIG_PARAMS) + def partition(self, graph: nx.DiGraph, **kwargs) -> dict[int, list[Any]]: + """ + Partition nodes using LMP similarity. + + Parameters + ---------- + graph : nx.DiGraph + Directed graph with a numeric LMP attribute on each node. + **kwargs : dict + Additional parameters: + + - n_clusters : int (required) + - config : LMPPartitioningConfig instance (overrides the instance config) + - adjacency_bonus : float (runtime override) + - price_attribute : str (runtime override) + + Returns + ------- + dict[int, list[Any]] + Mapping from cluster ID to node IDs. + + Raises + ------ + PartitioningError + If ``n_clusters`` is missing or invalid, or clustering fails. + ValidationError + If any node lacks the required LMP attribute. + """ + effective_config = kwargs.get("_effective_config", self.config) + price_attribute = effective_config.price_attribute + ac_attr = effective_config.ac_island_attr + n_clusters = kwargs.get("n_clusters") + + if n_clusters is None or n_clusters <= 0: + raise PartitioningError( + "LMP partitioning requires a positive 'n_clusters' parameter.", + strategy=self._get_strategy_name(), + ) + + nodes = list(graph.nodes()) + n_nodes = len(nodes) + if n_nodes > 0 and n_clusters > n_nodes: + raise PartitioningError( + f"Cannot create {n_clusters} clusters from {n_nodes} nodes.", + strategy=self._get_strategy_name(), + ) + + missing_nodes = [ + node + for node in nodes + if not isinstance(graph.nodes[node].get(price_attribute), (int, float)) + ] + if missing_nodes: + raise ValidationError( + f"Nodes {missing_nodes} lack a numeric '{price_attribute}' attribute.", + missing_attributes={"nodes": missing_nodes}, + strategy=self._get_strategy_name(), + ) + + lmp_values = np.array( + [float(graph.nodes[node][price_attribute]) for node in nodes], dtype=float + ) + island_values = [graph.nodes[node].get(ac_attr) for node in nodes] + + log_info( + f"Starting LMP partitioning (n_clusters={n_clusters}, price_attribute={price_attribute}, " + f"adjacency_bonus={effective_config.adjacency_bonus})", + LogCategory.PARTITIONING, + ) + + distance_matrix = np.zeros((n_nodes, n_nodes), dtype=float) + for i in range(n_nodes): + for j in range(i + 1, n_nodes): + if ( + ac_attr + and island_values[i] is not None + and island_values[j] is not None + and island_values[i] != island_values[j] + ): + dist = effective_config.infinite_distance + else: + diff = abs(lmp_values[i] - lmp_values[j]) + if effective_config.adjacency_bonus > 0.0: + if graph.has_edge(nodes[i], nodes[j]) or graph.has_edge(nodes[j], nodes[i]): + diff = max(diff - effective_config.adjacency_bonus, 0.0) + dist = diff + distance_matrix[i, j] = dist + distance_matrix[j, i] = dist + + if n_nodes == 0: + return {} + + try: + clustering = AgglomerativeClustering( + n_clusters=min(n_clusters, n_nodes), + metric="precomputed", + linkage="average", + ) + labels = clustering.fit_predict(distance_matrix) + except Exception as exc: + raise PartitioningError( + f"LMP partitioning failed: {exc}", strategy=self._get_strategy_name() + ) from exc + + partition_map = create_partition_map(nodes, labels) + validate_partition(partition_map, n_nodes, self._get_strategy_name()) + + return partition_map diff --git a/npap/visualization.py b/npap/visualization.py index 1e959d2..51d53c1 100644 --- a/npap/visualization.py +++ b/npap/visualization.py @@ -1,14 +1,16 @@ from __future__ import annotations +import copy from dataclasses import dataclass, field, replace from enum import Enum +from pathlib import Path from typing import Any import networkx as nx import plotly.graph_objects as go import plotly.io as pio -from npap.interfaces import EdgeType +from npap.interfaces import EdgeType, PartitionResult class PlotStyle(Enum): @@ -116,6 +118,22 @@ def _apply_preset_overrides(config: PlotConfig, preset: PlotPreset | str | None) return replace(config, **overrides) +def _resolve_partition_map( + partition_map: dict[int, list[Any]] | PartitionResult | None, + partition_result: PartitionResult | None, +) -> dict[int, list[Any]] | None: + """ + Resolve either a partition map or PartitionResult into the final mapping. + """ + if partition_result: + return partition_result.mapping + + if isinstance(partition_map, PartitionResult): + return partition_map.mapping + + return partition_map + + @dataclass class PlotConfig: """ @@ -859,11 +877,11 @@ def plot_clustered(self, config: PlotConfig | None = None, show: bool = True) -> """ return self._plot(PlotStyle.CLUSTERED, config, show) - def plot_network( graph: nx.DiGraph, style: str = "simple", - partition_map: dict[int, list[Any]] | None = None, + partition_map: dict[int, list[Any]] | PartitionResult | None = None, + partition_result: PartitionResult | None = None, show: bool = True, preset: PlotPreset | str | None = None, config: PlotConfig | None = None, @@ -881,8 +899,11 @@ def plot_network( NetworkX DiGraph with geographical coordinates (lat, lon). style : str Visualization style ('simple', 'voltage_aware', or 'clustered'). - partition_map : dict[int, list[Any]] or None - Optional cluster mapping for 'clustered' style. + partition_map : dict[int, list[Any]] | PartitionResult or None + Optional cluster mapping for 'clustered' style (or pass a PartitionResult). + partition_result : PartitionResult | None + Alternative place to hand over a PartitionResult directly without + extracting ``mapping`` manually. show : bool Whether to display the figure immediately. preset : PlotPreset or str, optional @@ -918,7 +939,8 @@ def plot_network( else: effective_config = config_with_preset - plotter = NetworkPlotter(graph, partition_map=partition_map) + resolved_partition = _resolve_partition_map(partition_map, partition_result) + plotter = NetworkPlotter(graph, partition_map=resolved_partition) # Support both string and enum style specifications if style == "simple" or style == PlotStyle.SIMPLE: @@ -931,3 +953,85 @@ def plot_network( raise ValueError( f"Unknown plot style: {style}. Valid options: 'simple', 'voltage_aware', 'clustered'" ) + + +def export_figure( + fig: go.Figure, + path: str | Path, + format: str | None = None, + *, + scale: float = 1, + include_plotlyjs: str = "cdn", + engine: str | None = None, +) -> Path: + """ + Export a Plotly figure to disk (HTML or static image). + + Parameters + ---------- + fig : go.Figure + Figure to export. + path : str or Path + Target file path. + format : str or None + Optional format override (``"html"``, ``"png"``, ``"svg"``, etc.). + When ``None``, the extension of ``path`` determines the format + (defaults to ``html`` when missing). + scale : float + Scale factor applied when saving static image formats. + include_plotlyjs : str + Plotly.js bundling mode for HTML export (`"cdn"`, `"include"`, or `"relative"`). + engine : str or None + Plotly image engine for formats like PNG/SVG (`"kaleido"` by default). + + Returns + ------- + Path + Resolved path to the exported file. + + Raises + ------ + RuntimeError + If the requested format cannot be generated (e.g., ``kaleido`` missing). + """ + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + + resolved_format = (format or target.suffix.lstrip(".")).lower() + resolved_format = resolved_format or "html" + + if resolved_format == "html": + fig.write_html(str(target), include_plotlyjs=include_plotlyjs) + return target + + try: + fig.write_image( + str(target), + format=resolved_format, + scale=scale, + engine=engine or "kaleido", + ) + except ValueError as exc: + raise RuntimeError( + "Failed to export figure. Make sure the required image engine " + "(e.g., kaleido) is installed." + ) from exc + + return target + + +def clone_graph(graph: nx.Graph | nx.MultiGraph | nx.MultiDiGraph) -> nx.Graph | nx.MultiGraph | nx.MultiDiGraph: + """ + Return a deep copy of the supplied graph for safe downstream edits. + + Parameters + ---------- + graph : nx.Graph or nx.MultiGraph or nx.MultiDiGraph + Graph to clone. + + Returns + ------- + nx.Graph or nx.MultiGraph or nx.MultiDiGraph + Deep copy of the original graph. + """ + return copy.deepcopy(graph) diff --git a/test/test_aggregation.py b/test/test_aggregation.py index 075d531..457d2e4 100644 --- a/test/test_aggregation.py +++ b/test/test_aggregation.py @@ -26,6 +26,10 @@ build_typed_cluster_edge_map, ) from npap.aggregation.modes import get_mode_profile +from npap.aggregation.physical_strategies import ( + KronReductionStrategy, + PTDFReductionStrategy, +) from npap.interfaces import AggregationMode, AggregationProfile from npap.managers import AggregationManager @@ -461,6 +465,64 @@ def test_invalid_node_strategy_raises(self, simple_digraph, simple_partition_map manager.aggregate(simple_digraph, simple_partition_map, profile) +class TestPTDFAggregationStrategy: + """Tests for the PTDF reduction physical strategy.""" + + def test_ptdf_reduction_generates_reactance_edges( + self, simple_digraph, simple_partition_map + ): + topology = ElectricalTopologyStrategy(initial_connectivity="existing") + topology_graph = topology.create_topology(simple_digraph, simple_partition_map) + strategy = PTDFReductionStrategy() + + aggregated = strategy.aggregate( + simple_digraph, simple_partition_map, topology_graph, properties=["x"] + ) + + assert aggregated.has_edge(0, 1) + assert aggregated.edges[0, 1]["x"] > 0 + assert "susceptance" in aggregated.edges[0, 1] + reduced_ptdf = aggregated.graph.get("reduced_ptdf") + assert isinstance(reduced_ptdf, dict) + assert reduced_ptdf["nodes"] == [0, 1] + + +class TestKronReductionStrategy: + """Tests for the Kron reduction physical strategy.""" + + def test_series_reduction_combines_path(self): + """Kron reduction should merge series paths into a single reactance.""" + graph = nx.DiGraph() + graph.add_edge(0, 2, x=1.0) + graph.add_edge(2, 1, x=1.0) + + partition = {0: [0, 2], 1: [1]} + topology = ElectricalTopologyStrategy(initial_connectivity="existing") + topology_graph = topology.create_topology(graph, partition) + + strategy = KronReductionStrategy() + aggregated = strategy.aggregate( + graph, partition, topology_graph, properties=["x"] + ) + + assert set(aggregated.nodes()) == {0, 1} + assert aggregated.edges[0, 1]["x"] == pytest.approx(2.0) + assert aggregated.edges[1, 0]["x"] == pytest.approx(2.0) + assert aggregated.edges[0, 1]["aggregation_source"] == "kron_reduction" + lap = aggregated.graph["kron_reduced_laplacian"] + assert lap.shape == (2, 2) + + def test_ptdf_mode_profile_applies_strategy(self, simple_digraph, simple_partition_map): + manager = AggregationManager() + profile = get_mode_profile(AggregationMode.DC_PTDF, warn_on_defaults=False) + + aggregated = manager.aggregate(simple_digraph, simple_partition_map, profile) + + assert aggregated.has_edge(0, 1) + assert aggregated.edges[0, 1]["aggregation_source"] == "ptdf_reduction" + assert "reduced_ptdf" in aggregated.graph + + # ============================================================================= # PARALLEL EDGE AGGREGATION TESTS # ============================================================================= @@ -570,10 +632,31 @@ def test_geographical_mode_profile(self): assert profile.node_properties.get("lon") == "average" assert profile.default_node_strategy == "average" - def test_dc_kron_mode_not_implemented(self): - """Test DC_KRON mode raises NotImplementedError.""" - with pytest.raises(NotImplementedError): - get_mode_profile(AggregationMode.DC_KRON) + def test_dc_ptdf_mode_profile(self): + """Test DC_PTDF mode returns configured profile.""" + profile = get_mode_profile(AggregationMode.DC_PTDF, warn_on_defaults=False) + + assert profile.physical_strategy == "ptdf_reduction" + assert "x" in profile.physical_properties + assert profile.topology_strategy == "electrical" + + def test_dc_kron_mode_profile(self): + """Test DC_KRON mode returns configured profile.""" + profile = get_mode_profile(AggregationMode.DC_KRON, warn_on_defaults=False) + + assert profile.physical_strategy == "kron_reduction" + assert "x" in profile.physical_properties + assert profile.topology_strategy == "electrical" + + def test_dc_kron_mode_applies_strategy(self, simple_digraph, simple_partition_map): + """Ensure the Kron mode applies the physical strategy end-to-end.""" + manager = AggregationManager() + profile = get_mode_profile(AggregationMode.DC_KRON, warn_on_defaults=False) + + aggregated = manager.aggregate(simple_digraph, simple_partition_map, profile) + + assert "kron_reduced_laplacian" in aggregated.graph + assert aggregated.edges[0, 1]["aggregation_source"] == "kron_reduction" def test_mode_profile_with_overrides(self): """Test mode profile with parameter overrides.""" diff --git a/test/test_partitioning.py b/test/test_partitioning.py index 349ccdc..e37db4b 100644 --- a/test/test_partitioning.py +++ b/test/test_partitioning.py @@ -14,6 +14,10 @@ import pytest from npap.exceptions import PartitioningError, ValidationError +from npap.partitioning.adjacent import ( + AdjacentAgglomerativeConfig, + AdjacentNodeAgglomerativePartitioning, +) from npap.partitioning.electrical import ( ElectricalDistanceConfig, ElectricalDistancePartitioning, @@ -26,6 +30,7 @@ CommunityPartitioning, SpectralPartitioning, ) +from npap.partitioning.lmp import LMPPartitioning from npap.partitioning.va_electrical import ( VAElectricalDistancePartitioning, ) @@ -1120,6 +1125,127 @@ def test_splits_connected_graph(self): assert sum(len(nodes) for nodes in partition.values()) == graph.number_of_nodes() +class TestLMPPartitioning: + """Tests for the locational marginal price (LMP) partitioning strategy.""" + + def test_groups_similar_lmp_values(self): + """Nodes with similar LMPs should land in the same cluster.""" + graph = nx.DiGraph() + graph.add_node(0, lmp=10.0) + graph.add_node(1, lmp=10.5) + graph.add_node(2, lmp=30.0) + graph.add_edge(0, 1) + graph.add_edge(1, 2) + + strategy = LMPPartitioning() + partition = strategy.partition(graph, n_clusters=2) + + assert nodes_in_same_cluster(partition, 0, 1) + assert nodes_in_different_clusters(partition, 0, 2) + assert nodes_in_different_clusters(partition, 1, 2) + + def test_adjacency_bonus_prefers_connected_nodes(self): + """Adjacency bonus should pull neighbors closer when LMPs are similar.""" + graph = nx.DiGraph() + graph.add_node(0, lmp=5.0) + graph.add_node(1, lmp=5.2) + graph.add_node(2, lmp=5.2) + graph.add_edge(0, 1) + + strategy = LMPPartitioning() + partition = strategy.partition(graph, n_clusters=2, adjacency_bonus=0.3) + + assert nodes_in_same_cluster(partition, 0, 1) + assert nodes_in_different_clusters(partition, 0, 2) + + def test_ac_islands_are_separated(self): + """Nodes from different AC islands are assigned to different clusters.""" + graph = nx.DiGraph() + graph.add_node(0, lmp=8.0, ac_island=0) + graph.add_node(1, lmp=8.0, ac_island=1) + graph.add_node(2, lmp=20.0, ac_island=0) + + strategy = LMPPartitioning() + partition = strategy.partition(graph, n_clusters=2) + + assert nodes_in_different_clusters(partition, 0, 1) + assert nodes_in_same_cluster(partition, 0, 2) + + def test_missing_lmp_attribute_raises_validation_error(self): + """Strategy should complain when the price attribute is absent.""" + graph = nx.DiGraph() + graph.add_node(0) + graph.add_node(1, lmp=12.0) + + strategy = LMPPartitioning() + + with pytest.raises(ValidationError, match="lack a numeric 'lmp'"): + strategy.partition(graph, n_clusters=1) + + +class TestAdjacentAgglomerativePartitioning: + """Tests for the adjacency-constrained agglomerative strategy.""" + + def test_adjacent_nodes_merge_preferred(self): + """Nodes joined by an edge should be merged before distant buses.""" + graph = nx.DiGraph() + graph.add_node(0, load=1.0) + graph.add_node(1, load=1.0) + graph.add_node(2, load=5.0) + graph.add_edge(0, 1) + + config = AdjacentAgglomerativeConfig(node_attribute="load") + strategy = AdjacentNodeAgglomerativePartitioning(config=config) + partition = strategy.partition(graph, n_clusters=2) + + assert nodes_in_same_cluster(partition, 0, 1) + assert nodes_in_different_clusters(partition, 0, 2) + + def test_non_adjacent_nodes_remain_separate(self): + """Nodes that share values but lack edges remain in separate clusters.""" + graph = nx.DiGraph() + graph.add_node(0, load=0.0) + graph.add_node(1, load=0.0) + graph.add_node(2, load=0.0) + + graph.add_edge(0, 1) + + config = AdjacentAgglomerativeConfig(node_attribute="load") + strategy = AdjacentNodeAgglomerativePartitioning(config=config) + partition = strategy.partition(graph, n_clusters=2) + + assert nodes_in_same_cluster(partition, 0, 1) + assert nodes_in_different_clusters(partition, 0, 2) + + def test_ac_islands_block_merges(self): + """Nodes in different AC islands cannot merge even if adjacent.""" + graph = nx.DiGraph() + graph.add_node(0, load=1.0, ac_island="A") + graph.add_node(1, load=1.0, ac_island="B") + graph.add_edge(0, 1) + + config = AdjacentAgglomerativeConfig( + node_attribute="load", + ac_island_attr="ac_island", + ) + strategy = AdjacentNodeAgglomerativePartitioning(config=config) + + with pytest.raises(PartitioningError, match="adjacency"): + strategy.partition(graph, n_clusters=1) + + def test_missing_attribute_raises_validation_error(self): + """All nodes must expose the configured attribute.""" + graph = nx.DiGraph() + graph.add_node(0) + graph.add_node(1, load=2.0) + + config = AdjacentAgglomerativeConfig(node_attribute="load") + strategy = AdjacentNodeAgglomerativePartitioning(config=config) + + with pytest.raises(ValidationError, match="lack a numeric 'load'"): + strategy.partition(graph, n_clusters=1) + + class TestCommunityPartitioning: """Verify the modularity-based community strategy.""" diff --git a/test/test_visualization.py b/test/test_visualization.py new file mode 100644 index 0000000..029aacb --- /dev/null +++ b/test/test_visualization.py @@ -0,0 +1,52 @@ +""" +Tests for the visualization helpers. +""" + +import networkx as nx +import plotly.graph_objects as go + +from npap.interfaces import PartitionResult +from npap.visualization import clone_graph, export_figure, plot_network + + +def test_plot_network_accepts_partition_result(simple_digraph): + """plot_network should accept PartitionResult objects without manual mapping.""" + mapping = {0: [0, 1], 1: [2, 3]} + partition = PartitionResult( + mapping=mapping, + original_graph_hash="hash", + strategy_name="test", + strategy_metadata={}, + n_clusters=2, + ) + + fig = plot_network( + simple_digraph, + style="clustered", + partition_result=partition, + show=False, + ) + + assert fig is not None + # Ensure the figure contains clustered nodes (should have more than one trace) + assert len(fig.data) > 0 + + +def test_export_figure_defaults_html(tmp_path): + """export_figure should write HTML files by default.""" + fig = go.Figure(go.Scatter(x=[0, 1], y=[0, 1])) + target = export_figure(fig, tmp_path / "network.html") + + assert target.exists() + assert target.suffix == ".html" + + +def test_clone_graph_returns_deep_copy(simple_digraph): + """clone_graph should not mutate the original graph when the copy changes.""" + copy_graph = clone_graph(simple_digraph) + + assert isinstance(copy_graph, nx.DiGraph) + assert id(copy_graph) != id(simple_digraph) + + copy_graph.nodes[0]["label"] = "clone" + assert "label" not in simple_digraph.nodes[0] From 83b75b0239270734c9fd67c9f11cda6e63c500c3 Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Fri, 20 Feb 2026 20:09:50 +0100 Subject: [PATCH 07/23] Add property-based partitioning tests --- pyproject.toml | 2 +- test/test_property_partitioning.py | 120 +++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 test/test_property_partitioning.py diff --git a/pyproject.toml b/pyproject.toml index 3a21493..01ba0cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ Repository = "https://github.com/IEE-TUGraz/NPAP" Changelog = "https://github.com/IEE-TUGraz/NPAP/releases" [project.optional-dependencies] -test = ["pytest>=7.0", "pytest-cov>=4.0"] +test = ["pytest>=7.0", "pytest-cov>=4.0", "hypothesis>=6.0,<7.0"] dev = ["pre-commit>=3.0", "ruff>=0.8.0"] docs = [ "sphinx>=7.0", diff --git a/test/test_property_partitioning.py b/test/test_property_partitioning.py new file mode 100644 index 0000000..b368fad --- /dev/null +++ b/test/test_property_partitioning.py @@ -0,0 +1,120 @@ +""" +Property-based tests for partitioning and visualization helpers. +""" + +import networkx as nx +from hypothesis import given, settings +from hypothesis import strategies as st + +from npap.interfaces import PartitionResult +from npap.managers import PartitioningManager +from npap.partitioning.adjacent import AdjacentAgglomerativeConfig +from npap.visualization import PlotPreset, plot_network + + +@st.composite +def connected_graph_with_attributes(draw): + node_count = draw(st.integers(min_value=3, max_value=7)) + nodes = list(range(node_count)) + + # Always include a spanning chain so the graph is connected. + edges = [(i, i + 1) for i in range(node_count - 1)] + extra_edges = draw( + st.lists( + st.tuples(st.integers(0, node_count - 1), st.integers(0, node_count - 1)), + min_size=0, + max_size=node_count, + ) + ) + edges = list(dict.fromkeys(edges + extra_edges)) + + graph = nx.DiGraph() + island_choices = ["A", "B", None] + + for node in nodes: + graph.add_node( + node, + load=draw(st.floats(min_value=0.0, max_value=100.0)), + ac_island=draw(st.sampled_from(island_choices)), + lat=draw(st.floats(min_value=-90.0, max_value=90.0)), + lon=draw(st.floats(min_value=-180.0, max_value=180.0)), + ) + + for u, v in edges: + if u != v: + graph.add_edge(u, v) + + n_clusters = draw(st.integers(min_value=1, max_value=node_count)) + return graph, n_clusters + + +@given(connected_graph_with_attributes()) +@settings(max_examples=20) +def test_adjacent_agglomerative_handles_random_graphs(data): + """Partitioning should return exactly n_clusters for connected graphs.""" + graph, n_clusters = data + + manager = PartitioningManager() + partition = manager.partition( + graph, + "adjacent_agglomerative", + n_clusters=n_clusters, + config=AdjacentAgglomerativeConfig( + node_attribute="load", + ac_island_attr=None, + ), + ) + + assert len(partition) == n_clusters + assert sum(len(nodes) for nodes in partition.values()) == graph.number_of_nodes() + + +@given( + st.lists(st.integers(min_value=0, max_value=3), min_size=2, max_size=6), + st.sampled_from(list(PlotPreset)), +) +@settings(max_examples=15, deadline=None) +def test_plot_network_handles_random_partitions(cluster_assignments, preset): + """Plotting should accept arbitrary cluster assignments without raising.""" + graph = nx.DiGraph() + for node_id, cluster_id in enumerate(cluster_assignments): + graph.add_node(node_id, lat=0.1 * node_id, lon=0.1 * node_id) + for node_id in range(len(cluster_assignments) - 1): + graph.add_edge(node_id, node_id + 1) + + partition_map = {} + for node_id, cluster_id in enumerate(cluster_assignments): + partition_map.setdefault(cluster_id, []).append(node_id) + + fig = plot_network( + graph, + style="clustered", + partition_map=partition_map, + preset=preset, + show=False, + ) + + assert isinstance(fig, object) + assert len(fig.data) > 0 + + +def test_plot_network_accepts_partition_result_from_manager(simple_digraph): + """plot_network should accept PartitionResult objects returned by managers.""" + partition_map = {0: [0, 1], 1: [2, 3]} + partition_result = PartitionResult( + mapping=partition_map, + original_graph_hash="hash", + strategy_name="hypothesis", + strategy_metadata={}, + n_clusters=len(partition_map), + ) + + fig = plot_network( + simple_digraph, + style="clustered", + partition_result=partition_result, + show=False, + ) + + assert isinstance(fig, object) + assert len(fig.data) > 0 From 3a1df80490e389b552e4946b7339fefd07dbc99f Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Fri, 20 Feb 2026 20:13:54 +0100 Subject: [PATCH 08/23] Add CLI helpers --- docs/user-guide/workflows.md | 33 +++++ npap/cli.py | 236 +++++++++++++++++++++++++++++++++++ pyproject.toml | 5 + 3 files changed, 274 insertions(+) create mode 100644 npap/cli.py diff --git a/docs/user-guide/workflows.md b/docs/user-guide/workflows.md index 11172d9..49eca17 100644 --- a/docs/user-guide/workflows.md +++ b/docs/user-guide/workflows.md @@ -91,3 +91,36 @@ manager.plot_network(style="voltage_aware", preset=PlotPreset.PRESENTATION) ``` Presets are composable with `PlotConfig`: pass `config=PlotConfig(...)` and overrides via `kwargs` to fine-tune individual charts while keeping a consistent baseline. + +## Command-line helpers + +Three lightweight CLIs wrap the PartitionAggregatorManager, AggregationManager, and visualization helpers so you can automate common workflows without writing Python: + +1. `npap-cluster` – load CSV node/edge files (or a saved NetworkX graph), partition with any registered strategy, and emit a JSON mapping. +2. `npap-aggregate` – read the partition JSON, aggregate via a predefined `AggregationMode`, and export the reduced graph (GraphML/GEXF/GPickle). +3. `npap-plot` – load either the original or aggregated graph, optionally color it with a partition JSON, and save the figure as HTML/PNG/SVG using the Plot presets. + +```bash +npap-cluster \ + --node-file buses.csv \ + --edge-file lines.csv \ + --partition-strategy geographical_kmeans \ + --n-clusters 8 \ + --partition-output partitions.json + +npap-aggregate \ + --node-file buses.csv \ + --edge-file lines.csv \ + --partition-file partitions.json \ + --mode dc_ptdf \ + --output aggregated.graphml + +npap-plot \ + --aggregated-file aggregated.graphml \ + --partition-file partitions.json \ + --style clustered \ + --preset cluster_highlight \ + --output reports/clustered.html +``` + +The CLI helpers respect the same loaders as the Python API and reuse the preset-driven visualization pipeline introduced earlier. Install them via `pip install -e ".[test]"` (needed for Hypothesis and the CLI entry points) before running the commands. diff --git a/npap/cli.py b/npap/cli.py new file mode 100644 index 0000000..77616e9 --- /dev/null +++ b/npap/cli.py @@ -0,0 +1,236 @@ +""" +Command-line helpers for quick NPAP workflows. + +These entry points wrap the core managers so you can script clustering, +aggregation, and visualization without writing Python glue code. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import networkx as nx + +from npap.aggregation.modes import AggregationMode, get_mode_profile +from npap.logging import LogCategory, log_info +from npap.managers import AggregationManager, PartitionAggregatorManager +from npap.visualization import PlotPreset, export_figure, plot_network + + +def _load_graph_from_file(path: str, fmt: str | None = None) -> nx.Graph: + """ + Read a NetworkX graph from disk in one of the supported formats. + """ + path_obj = Path(path) + fmt = (fmt or path_obj.suffix.lstrip(".")).lower() + + if fmt == "graphml": + return nx.read_graphml(path) + if fmt == "gexf": + return nx.read_gexf(path) + if fmt in {"gpickle", "pickle"}: + return nx.read_gpickle(path) + raise ValueError(f"Unsupported graph format: {fmt}") + + +def _load_graph(args: argparse.Namespace, manager: PartitionAggregatorManager) -> nx.DiGraph: + if args.node_file and args.edge_file: + load_kwargs = { + "delimiter": args.delimiter, + "decimal": args.decimal, + } + if args.node_id_col: + load_kwargs["node_id_col"] = args.node_id_col + if args.edge_from_col: + load_kwargs["edge_from_col"] = args.edge_from_col + if args.edge_to_col: + load_kwargs["edge_to_col"] = args.edge_to_col + + return manager.load_data( + "csv_files", + node_file=args.node_file, + edge_file=args.edge_file, + **{k: v for k, v in load_kwargs.items() if v is not None}, + ) + + if args.graph_file: + graph = _load_graph_from_file(args.graph_file, args.graph_format) + bidirectional = not args.no_bidirectional + return manager.load_data( + "networkx_direct", graph=graph, bidirectional=bidirectional + ) + + raise ValueError("Either node/edge files or a graph file must be provided.") + + +def _dump_partition(partition: dict[int, list[int]], output: str | None = None) -> None: + payload = {str(cluster_id): nodes for cluster_id, nodes in partition.items()} + if output: + Path(output).write_text(json.dumps(payload, indent=2)) + log_info(f"Wrote partition mapping to {output}", LogCategory.MANAGER) + else: + print(json.dumps(payload, indent=2)) + + +def _read_partition(path: str) -> dict[int, list[int]]: + payload = json.loads(Path(path).read_text()) + return {int(k): v for k, v in payload.items()} + + +def _write_graph(graph: nx.Graph, output: str, fmt: str | None = None) -> None: + fmt = (fmt or Path(output).suffix.lstrip(".")).lower() + writer = { + "graphml": nx.write_graphml, + "gexf": nx.write_gexf, + "gpickle": nx.write_gpickle, + "pickle": nx.write_gpickle, + }.get(fmt) + + if writer is None: + raise ValueError(f"Unknown graph output format: {fmt}") + + writer(graph, output) + log_info(f"Exported graph to {output}", LogCategory.AGGREGATION) + + +def _common_load_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--node-file", help="CSV file with node attributes.") + parser.add_argument("--edge-file", help="CSV file with edge list.") + parser.add_argument("--graph-file", help="Path to a NetworkX graph on disk.") + parser.add_argument( + "--graph-format", + choices=["graphml", "gexf", "gpickle", "pickle"], + help="Force the format of --graph-file.", + ) + parser.add_argument( + "--no-bidirectional", + action="store_true", + help="Do not create bidirectional edges when converting undirected graphs.", + ) + parser.add_argument( + "--delimiter", + default=",", + help="CSV delimiter (only used with --node-file/--edge-file).", + ) + parser.add_argument( + "--decimal", + default=".", + help="Decimal separator (only used with --node-file/--edge-file).", + ) + parser.add_argument("--node-id-col", help="Override node ID column name.") + parser.add_argument("--edge-from-col", help="Override edge source column name.") + parser.add_argument("--edge-to-col", help="Override edge target column name.") + + +def cluster_entry() -> None: + """Partition a loaded graph and emit a JSON mapping.""" + parser = argparse.ArgumentParser(description="Partition a grid with NPAP.") + _common_load_parser(parser) + parser.add_argument( + "--partition-strategy", + default="geographical_kmeans", + help="Partitioning strategy name registered in PartitioningManager.", + ) + parser.add_argument( + "--n-clusters", "-n", type=int, required=True, help="Number of clusters to create." + ) + parser.add_argument( + "--partition-output", + help="Write partition mapping to this JSON file. Prints to stdout if omitted.", + ) + + args = parser.parse_args() + manager = PartitionAggregatorManager() + graph = _load_graph(args, manager) + + partition = manager.partition( + graph, args.partition_strategy, n_clusters=args.n_clusters + ) + _dump_partition(partition.mapping, args.partition_output) + + +def aggregate_entry() -> None: + """Aggregate a previously partitioned graph using a predefined mode.""" + parser = argparse.ArgumentParser(description="Aggregate a partitioned grid.") + _common_load_parser(parser) + parser.add_argument( + "--partition-file", + required=True, + help="Path to JSON partition map produced by npap-cluster.", + ) + parser.add_argument( + "--mode", + type=AggregationMode, + choices=list(AggregationMode), + default=AggregationMode.GEOGRAPHICAL, + help="Predefined aggregation mode.", + ) + parser.add_argument( + "--output", + help="Write aggregated graph to this path (GraphML/GEXF/GPickle inferred).", + ) + + args = parser.parse_args() + manager = PartitionAggregatorManager() + graph = _load_graph(args, manager) + partition_map = _read_partition(args.partition_file) + + aggregation_manager = AggregationManager() + profile = get_mode_profile(args.mode) + aggregated = aggregation_manager.aggregate(graph, partition_map, profile) + + if args.output: + _write_graph(aggregated, args.output) + + +def plot_entry() -> None: + """Render a graph or aggregated network and save the figure.""" + parser = argparse.ArgumentParser(description="Plot NPAP partitions or graphs.") + _common_load_parser(parser) + parser.add_argument("--aggregated-file", help="Path to aggregated NetworkX graph.") + parser.add_argument( + "--aggregated-format", + choices=["graphml", "gexf", "gpickle", "pickle"], + help="Format of the aggregated graph file.", + ) + parser.add_argument( + "--partition-file", + help="Optional partition JSON to color clusters (overrides partition_map).", + ) + parser.add_argument( + "--style", + default="clustered", + choices=["simple", "voltage_aware", "clustered"], + help="Plot style.", + ) + parser.add_argument( + "--preset", + choices=[preset.value for preset in PlotPreset], + default=PlotPreset.DEFAULT.value, + help="Plot preset for quick styling changes.", + ) + parser.add_argument( + "--output", + required=True, + help="Output path for the visualization (HTML/default or PNG when format=png).", + ) + + args = parser.parse_args() + manager = PartitionAggregatorManager() + graph = _load_graph(args, manager) + + if args.aggregated_file: + graph = _load_graph_from_file(args.aggregated_file, args.aggregated_format) + + partition_map = _read_partition(args.partition_file) if args.partition_file else None + + fig = plot_network( + graph, + style=args.style, + partition_map=partition_map, + preset=args.preset, + show=False, + ) + export_figure(fig, args.output) diff --git a/pyproject.toml b/pyproject.toml index 01ba0cc..d0f4918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,11 @@ docs = [ "sphinx-reredirects>=0.1", ] +[project.scripts] +npap-cluster = "npap.cli:cluster_entry" +npap-aggregate = "npap.cli:aggregate_entry" +npap-plot = "npap.cli:plot_entry" + [tool.setuptools.packages.find] include = ["npap", "npap.*"] From 88684d859cf181c8146bd7ce292d3c436fa051f6 Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Fri, 20 Feb 2026 20:17:54 +0100 Subject: [PATCH 09/23] Add PTDF/Kron diagnostics --- docs/user-guide/visualization.md | 12 ++++++ docs/user-guide/workflows.md | 10 +++++ npap/cli.py | 35 +++++++++++++++- npap/visualization.py | 69 ++++++++++++++++++++++++++++++++ pyproject.toml | 1 + test/test_visualization.py | 19 ++++++++- 6 files changed, 144 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/visualization.md b/docs/user-guide/visualization.md index d60cfd3..79766a4 100644 --- a/docs/user-guide/visualization.md +++ b/docs/user-guide/visualization.md @@ -225,6 +225,18 @@ manager.plot_network(style="voltage_aware", preset=PlotPreset.PRESENTATION) Presets layer on top of `config`/`kwargs`; any explicit `PlotConfig` parameter overrides the preset values. +## Matrix Diagnostics + +After aggregation the graph carries diagnostic matrices (`reduced_ptdf` and `kron_reduced_laplacian`). Use {py:func}`~npap.visualization.plot_reduced_matrices` to inspect them via heatmaps. + +```python +from npap.visualization import plot_reduced_matrices + +fig = plot_reduced_matrices(aggregated, matrices=("ptdf", "laplacian")) +``` + +The same helper powers `npap-diag` so you can generate HTML/PNG diagnostics without writing Python (see [Workflows](workflows.md)). + ## Working with Figures ### Getting the Figure Object diff --git a/docs/user-guide/workflows.md b/docs/user-guide/workflows.md index 49eca17..3e10b27 100644 --- a/docs/user-guide/workflows.md +++ b/docs/user-guide/workflows.md @@ -100,6 +100,8 @@ Three lightweight CLIs wrap the PartitionAggregatorManager, AggregationManager, 2. `npap-aggregate` – read the partition JSON, aggregate via a predefined `AggregationMode`, and export the reduced graph (GraphML/GEXF/GPickle). 3. `npap-plot` – load either the original or aggregated graph, optionally color it with a partition JSON, and save the figure as HTML/PNG/SVG using the Plot presets. +3. Use `npap-diag` to visualize the reduced PTDF/laplacian right after aggregation and export a diagnostic figure. + ```bash npap-cluster \ --node-file buses.csv \ @@ -123,4 +125,12 @@ npap-plot \ --output reports/clustered.html ``` +```bash +npap-diag \ + --aggregated-file aggregated.graphml \ + --matrix ptdf \ + --matrix laplacian \ + --output reports/diagnostics.html +``` + The CLI helpers respect the same loaders as the Python API and reuse the preset-driven visualization pipeline introduced earlier. Install them via `pip install -e ".[test]"` (needed for Hypothesis and the CLI entry points) before running the commands. diff --git a/npap/cli.py b/npap/cli.py index 77616e9..5168359 100644 --- a/npap/cli.py +++ b/npap/cli.py @@ -16,7 +16,7 @@ from npap.aggregation.modes import AggregationMode, get_mode_profile from npap.logging import LogCategory, log_info from npap.managers import AggregationManager, PartitionAggregatorManager -from npap.visualization import PlotPreset, export_figure, plot_network +from npap.visualization import PlotPreset, export_figure, plot_network, plot_reduced_matrices def _load_graph_from_file(path: str, fmt: str | None = None) -> nx.Graph: @@ -234,3 +234,36 @@ def plot_entry() -> None: show=False, ) export_figure(fig, args.output) + + +def diagnose_entry() -> None: + """Visualize reduced PTDF/laplacian matrices from an aggregated graph.""" + parser = argparse.ArgumentParser(description="Inspect reduced PTDF/laplacian matrices.") + parser.add_argument( + "--aggregated-file", + required=True, + help="Path to a saved aggregated graph (GraphML/GEXF/GPickle).", + ) + parser.add_argument( + "--aggregated-format", + choices=["graphml", "gexf", "gpickle", "pickle"], + help="Format of the aggregated graph file.", + ) + parser.add_argument( + "--matrix", + choices=["ptdf", "laplacian"], + action="append", + default=["ptdf", "laplacian"], + help="Matrix to visualize; can be repeated.", + ) + parser.add_argument( + "--output", + required=True, + help="Path where the diagnostic figure will be saved (HTML/PNG).", + ) + + args = parser.parse_args() + graph = _load_graph_from_file(args.aggregated_file, args.aggregated_format) + + fig = plot_reduced_matrices(graph, matrices=tuple(args.matrix), show=False) + export_figure(fig, args.output) diff --git a/npap/visualization.py b/npap/visualization.py index 51d53c1..6fae321 100644 --- a/npap/visualization.py +++ b/npap/visualization.py @@ -7,8 +7,10 @@ from typing import Any import networkx as nx +import numpy as np import plotly.graph_objects as go import plotly.io as pio +from plotly.subplots import make_subplots from npap.interfaces import EdgeType, PartitionResult @@ -1035,3 +1037,70 @@ def clone_graph(graph: nx.Graph | nx.MultiGraph | nx.MultiDiGraph) -> nx.Graph | Deep copy of the original graph. """ return copy.deepcopy(graph) + + +def plot_reduced_matrices( + graph: nx.Graph, + *, + matrices: tuple[str, ...] = ("ptdf", "laplacian"), + show: bool = True, +) -> go.Figure: + """ + Plot heatmaps for reduced PTDF/laplacian matrices produced during aggregation. + + Parameters + ---------- + graph : nx.Graph + Aggregated graph carrying ``reduced_ptdf`` and/or + ``kron_reduced_laplacian`` in ``graph.graph``. + matrices : tuple[str, ...] + Which matrices to visualize; valid values are ``"ptdf"`` and + ``"laplacian"``. + show : bool + Whether to display the figure automatically. + + Returns + ------- + go.Figure + Plotly Figure containing the requested heatmaps. + """ + available = [] + if "ptdf" in matrices: + ptdf = graph.graph.get("reduced_ptdf") + if ptdf and isinstance(ptdf.get("matrix"), np.ndarray): + available.append(("PTDF", ptdf["matrix"], ptdf["nodes"])) + if "laplacian" in matrices: + lap = graph.graph.get("kron_reduced_laplacian") + if isinstance(lap, np.ndarray): + labels = list(graph.nodes()) + available.append(("Kron Laplacian", lap, labels)) + + if not available: + raise ValueError("No reduced matrices found on the graph.") + + fig = make_subplots(rows=len(available), cols=1, subplot_titles=[name for name, *_ in available]) + + for row, (name, matrix, labels) in enumerate(available, start=1): + fig.add_trace( + go.Heatmap( + z=matrix, + x=[str(label) for label in labels], + y=[str(label) for label in labels], + colorbar=dict(title=name), + colorscale="Viridis", + ), + row=row, + col=1, + ) + + fig.update_layout( + height=300 * len(available), + title="Reduced matrices diagnostics", + xaxis=dict(tickangle=45), + yaxis=dict(autorange="reversed"), + ) + + if show: + fig.show() + + return fig diff --git a/pyproject.toml b/pyproject.toml index d0f4918..c23f279 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ docs = [ npap-cluster = "npap.cli:cluster_entry" npap-aggregate = "npap.cli:aggregate_entry" npap-plot = "npap.cli:plot_entry" +npap-diag = "npap.cli:diagnose_entry" [tool.setuptools.packages.find] include = ["npap", "npap.*"] diff --git a/test/test_visualization.py b/test/test_visualization.py index 029aacb..71089ac 100644 --- a/test/test_visualization.py +++ b/test/test_visualization.py @@ -5,8 +5,15 @@ import networkx as nx import plotly.graph_objects as go +from npap.aggregation.modes import AggregationMode, get_mode_profile from npap.interfaces import PartitionResult -from npap.visualization import clone_graph, export_figure, plot_network +from npap.managers import AggregationManager +from npap.visualization import ( + clone_graph, + export_figure, + plot_network, + plot_reduced_matrices, +) def test_plot_network_accepts_partition_result(simple_digraph): @@ -50,3 +57,13 @@ def test_clone_graph_returns_deep_copy(simple_digraph): copy_graph.nodes[0]["label"] = "clone" assert "label" not in simple_digraph.nodes[0] + + +def test_plot_reduced_matrices_ptdf(simple_digraph, simple_partition_map): + """plot_reduced_matrices should visualize PTDF from an aggregated graph.""" + agg_manager = AggregationManager() + profile = get_mode_profile(AggregationMode.DC_PTDF, warn_on_defaults=False) + aggregated = agg_manager.aggregate(simple_digraph, simple_partition_map, profile) + + fig = plot_reduced_matrices(aggregated, matrices=("ptdf",)) + assert len(fig.data) == 1 From bf4eaca1dd38e4a4455a7a885d4457d8155b1896 Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Fri, 20 Feb 2026 20:30:12 +0100 Subject: [PATCH 10/23] feature update --- docs/user-guide/visualization.md | 7 ++- docs/user-guide/workflows.md | 13 +++++ npap/visualization.py | 39 ++++++++++++- scripts/lmp_conservation_workflow.py | 87 ++++++++++++++++++++++++++++ test/test_visualization_presets.py | 41 +++++++++++++ 5 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 scripts/lmp_conservation_workflow.py create mode 100644 test/test_visualization_presets.py diff --git a/docs/user-guide/visualization.md b/docs/user-guide/visualization.md index 79766a4..87b3f42 100644 --- a/docs/user-guide/visualization.md +++ b/docs/user-guide/visualization.md @@ -216,15 +216,20 @@ Use the `preset` argument to apply curated styling for different audiences witho | `presentation` | Wide canvas with thicker lines and `"open-street-map"` tiles | | `dense` | Compact view, higher voltage threshold, and dark tiles | | `cluster_highlight` | Turbo colorscale with large nodes and white background | +| `transmission_study` | Terrain styling with a wide canvas that emphasizes high-voltage corridors | +| `distribution_study` | Zoomed-in, low-voltage view with saturated cluster colors for dense grids | +| `e_mobility_planning` | Bold node markers and tight zoom that highlight e-mobility rollout areas | ```python from npap import PlotPreset -manager.plot_network(style="voltage_aware", preset=PlotPreset.PRESENTATION) +manager.plot_network(style="voltage_aware", preset=PlotPreset.TRANSMISSION_STUDY) ``` Presets layer on top of `config`/`kwargs`; any explicit `PlotConfig` parameter overrides the preset values. +Use the scenario-specific presets when you want a ready-made baseline (transmission study for HV analysis, distribution study for LV neighborhoods, and e-mobility planning for node-heavy deployments) and layer additional `PlotConfig` tweaks as needed. + ## Matrix Diagnostics After aggregation the graph carries diagnostic matrices (`reduced_ptdf` and `kron_reduced_laplacian`). Use {py:func}`~npap.visualization.plot_reduced_matrices` to inspect them via heatmaps. diff --git a/docs/user-guide/workflows.md b/docs/user-guide/workflows.md index 3e10b27..3905563 100644 --- a/docs/user-guide/workflows.md +++ b/docs/user-guide/workflows.md @@ -92,6 +92,8 @@ manager.plot_network(style="voltage_aware", preset=PlotPreset.PRESENTATION) Presets are composable with `PlotConfig`: pass `config=PlotConfig(...)` and overrides via `kwargs` to fine-tune individual charts while keeping a consistent baseline. +Use the new scenario presets to match the story you are telling: `PlotPreset.TRANSMISSION_STUDY` stabilizes the view for high-voltage corridors, `PlotPreset.DISTRIBUTION_STUDY` tightens the zoom on dense, low-voltage districts, and `PlotPreset.E_MOBILITY_PLANNING` highlights buses/nodes that are prime candidates for charging infrastructure. + ## Command-line helpers Three lightweight CLIs wrap the PartitionAggregatorManager, AggregationManager, and visualization helpers so you can automate common workflows without writing Python: @@ -133,4 +135,15 @@ npap-diag \ --output reports/diagnostics.html ``` +The new `scripts/lmp_conservation_workflow.py` bundles the full price-aware flow (LMP clustering → conservation aggregation → visualization) so you can run it as: + +```bash +python scripts/lmp_conservation_workflow.py \ + --node-file buses.csv \ + --edge-file lines.csv \ + --clusters 8 \ + --partition-output reports/lmp-partition.html \ + --figure-output reports/lmp-conservation.html +``` + The CLI helpers respect the same loaders as the Python API and reuse the preset-driven visualization pipeline introduced earlier. Install them via `pip install -e ".[test]"` (needed for Hypothesis and the CLI entry points) before running the commands. diff --git a/npap/visualization.py b/npap/visualization.py index 6fae321..0e3a381 100644 --- a/npap/visualization.py +++ b/npap/visualization.py @@ -41,19 +41,28 @@ class PlotPreset(Enum): Attributes ---------- DEFAULT : str - Balanced defaults for general exploration. + Balanced defaults for data exploration. PRESENTATION : str Bigger nodes/edges and a wide canvas for slides or demos. DENSE : str Higher voltage threshold and compact markers for crowded networks. CLUSTER_HIGHLIGHT : str Emphasizes cluster coloring with saturated nodes and Turbo colorscale. + TRANSMISSION_STUDY : str + Highlights high-voltage corridors with a wide canvas and terrain tiles. + DISTRIBUTION_STUDY : str + Focuses on low-voltage, high-density neighborhoods with tighter zoom. + E_MOBILITY_PLANNING : str + Accents nodes most relevant for e-mobility rollout with bold markers. """ DEFAULT = "default" PRESENTATION = "presentation" DENSE = "dense" CLUSTER_HIGHLIGHT = "cluster_highlight" + TRANSMISSION_STUDY = "transmission_study" + DISTRIBUTION_STUDY = "distribution_study" + E_MOBILITY_PLANNING = "e_mobility_planning" _PRESET_OVERRIDES = { @@ -79,6 +88,32 @@ class PlotPreset(Enum): "map_style": "carto-positron", "title": "Clustered Network", }, + PlotPreset.TRANSMISSION_STUDY: { + "line_voltage_threshold": 450.0, + "edge_width": 2.3, + "node_size": 6, + "map_style": "stamen-terrain", + "width": 1200, + "height": 750, + "title": "Transmission Study", + }, + PlotPreset.DISTRIBUTION_STUDY: { + "line_voltage_threshold": 220.0, + "edge_width": 1.0, + "node_size": 9, + "map_zoom": 8.8, + "map_style": "open-street-map", + "cluster_colorscale": "YlOrBr", + "title": "Distribution Study", + }, + PlotPreset.E_MOBILITY_PLANNING: { + "node_color": "#FF6F61", + "node_size": 10, + "edge_width": 1.1, + "map_zoom": 10.5, + "map_style": "stamen-toner", + "title": "E-Mobility Planning", + }, } @@ -97,7 +132,7 @@ def _normalize_plot_preset(preset: PlotPreset | str | None) -> PlotPreset | None if isinstance(preset, PlotPreset): return preset - lookup = preset.strip().lower().replace(" ", "_") + lookup = preset.strip().lower().replace(" ", "_").replace("-", "_") for option in PlotPreset: if option.value == lookup or option.name.lower() == lookup: return option diff --git a/scripts/lmp_conservation_workflow.py b/scripts/lmp_conservation_workflow.py new file mode 100644 index 0000000..4618745 --- /dev/null +++ b/scripts/lmp_conservation_workflow.py @@ -0,0 +1,87 @@ +""" +Example workflow: price-aware clustering through LMP partitioning, +conservation aggregation, and preset-driven plotting. + +This script shows how to combine the locational marginal price +partitioning strategy with the conservation aggregation mode, then +export the clustered figure using the helper presets introduced earlier. +""" + +from __future__ import annotations + +from pathlib import Path + +import networkx as nx + +from npap import AggregationMode, PartitionAggregatorManager +from npap.visualization import PlotPreset + + +def main( + node_file: str, + edge_file: str, + n_clusters: int = 6, + partition_output: str | None = None, + aggregated_output: str | None = None, + figure_output: str | None = None, +): + """Execute the LMP → conservation workflow with optional exports.""" + manager = PartitionAggregatorManager() + manager.load_data("csv_files", node_file=node_file, edge_file=edge_file) + + # Price-aware partitioning based on LMP + partition = manager.partition( + "lmp_similarity", + n_clusters=n_clusters, + adjacency_bonus=0.3, + infinite_distance=1e5, + ) + + if partition_output: + manager.plot_network( + style="clustered", + partition_map=partition.mapping, + preset=PlotPreset.CLUSTER_HIGHLIGHT, + show=False, + ).write_html(partition_output) + + # Conservation aggregation + aggregated = manager.aggregate(mode=AggregationMode.CONSERVATION) + + if aggregated_output: + aggregated.graph["metadata"] = {"source": "lmp_conservation_workflow"} + aggregated.graph["created_by"] = "lmp_conservation_workflow" + aggregated.graph["original_partitions"] = partition.mapping.keys() + agg_path = Path(aggregated_output) + agg_path.parent.mkdir(parents=True, exist_ok=True) + nx.write_graphml(aggregated, agg_path) + + if figure_output: + manager.plot_network( + graph=aggregated, + style="clustered", + partition_map=partition.mapping, + preset=PlotPreset.PRESENTATION, + show=False, + ).write_html(figure_output) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="LMP → conservation workflow") + parser.add_argument("--node-file", required=True, help="Node CSV file path.") + parser.add_argument("--edge-file", required=True, help="Edge CSV file path.") + parser.add_argument("--clusters", type=int, default=6, help="Number of LMP clusters.") + parser.add_argument("--partition-output", help="Optional HTML export for the partitioned graph.") + parser.add_argument("--figure-output", help="Optional HTML export after conservation aggregation.") + + args = parser.parse_args() + + main( + args.node_file, + args.edge_file, + n_clusters=args.clusters, + partition_output=args.partition_output, + figure_output=args.figure_output, + ) diff --git a/test/test_visualization_presets.py b/test/test_visualization_presets.py new file mode 100644 index 0000000..8cb1601 --- /dev/null +++ b/test/test_visualization_presets.py @@ -0,0 +1,41 @@ +from npap.visualization import ( + PlotConfig, + PlotPreset, + _apply_preset_overrides, + _normalize_plot_preset, +) + + +def test_transmission_study_preset_overrides_high_voltage(): + config = PlotConfig() + updated = _apply_preset_overrides(config, PlotPreset.TRANSMISSION_STUDY) + + assert updated.title == "Transmission Study" + assert updated.line_voltage_threshold == 450.0 + assert updated.width == 1200 + assert updated.height == 750 + + +def test_distribution_study_preset_focuses_on_dense_grid(): + config = PlotConfig() + updated = _apply_preset_overrides(config, PlotPreset.DISTRIBUTION_STUDY) + + assert updated.title == "Distribution Study" + assert updated.map_zoom == 8.8 + assert updated.cluster_colorscale == "YlOrBr" + + +def test_e_mobility_preset_highlights_nodes(): + config = PlotConfig() + updated = _apply_preset_overrides(config, "e_mobility_planning") + + assert updated.title == "E-Mobility Planning" + assert updated.node_color == "#FF6F61" + assert updated.node_size == 10 + assert updated.map_zoom == 10.5 + + +def test_preset_normalization_accepts_human_friendly_names(): + assert _normalize_plot_preset("Transmission Study") == PlotPreset.TRANSMISSION_STUDY + assert _normalize_plot_preset("distribution study") == PlotPreset.DISTRIBUTION_STUDY + assert _normalize_plot_preset("E-Mobility Planning") == PlotPreset.E_MOBILITY_PLANNING From 15c98cc9d0ba430aab55aa6e659ccb27fcb6f261 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:35:11 +0000 Subject: [PATCH 11/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- npap/aggregation/physical_strategies.py | 31 ++++++++++++++----------- npap/cli.py | 8 ++----- npap/managers.py | 4 +--- npap/partitioning/adjacent.py | 6 +---- npap/visualization.py | 9 +++++-- scripts/lmp_conservation_workflow.py | 8 +++++-- test/test_aggregation.py | 8 ++----- 7 files changed, 37 insertions(+), 37 deletions(-) diff --git a/npap/aggregation/physical_strategies.py b/npap/aggregation/physical_strategies.py index 38288df..5c17653 100644 --- a/npap/aggregation/physical_strategies.py +++ b/npap/aggregation/physical_strategies.py @@ -19,13 +19,17 @@ _MIN_SUSCEPTANCE = 1e-12 -def _build_admittance_matrix(graph: nx.DiGraph, reactance_property: str) -> tuple[np.ndarray, dict[Any, int]]: +def _build_admittance_matrix( + graph: nx.DiGraph, reactance_property: str +) -> tuple[np.ndarray, dict[Any, int]]: """Build Laplacian (admittance) matrix for the given graph.""" nodes = list(graph.nodes()) n = len(nodes) if n == 0: - raise AggregationError("Graph must contain nodes for PTDF reduction.", strategy="ptdf_reduction") + raise AggregationError( + "Graph must contain nodes for PTDF reduction.", strategy="ptdf_reduction" + ) node_to_index: dict[Any, int] = {node: idx for idx, node in enumerate(nodes)} @@ -61,9 +65,7 @@ def _build_admittance_matrix(graph: nx.DiGraph, reactance_property: str) -> tupl return laplacian, node_to_index -def _kron_reduce_laplacian( - laplacian: np.ndarray, keep_indices: Sequence[int] -) -> np.ndarray: +def _kron_reduce_laplacian(laplacian: np.ndarray, keep_indices: Sequence[int]) -> np.ndarray: """Apply Kron reduction to the given Laplacian matrix.""" n = laplacian.shape[0] all_indices = set(range(n)) @@ -88,7 +90,9 @@ def _kron_reduce_laplacian( return lap_kk - lap_ke @ inv_lap_ee @ lap_ek -def _select_representatives(partition_map: dict[int, list[Any]], cluster_order: list[int]) -> list[Any]: +def _select_representatives( + partition_map: dict[int, list[Any]], cluster_order: list[int] +) -> list[Any]: representatives: list[Any] = [] for cluster in cluster_order: nodes = partition_map.get(cluster) @@ -141,7 +145,12 @@ def _compute_reduced_ptdf(graph: nx.DiGraph, reactance_property: str) -> dict[st slack_idx = node_to_index[slack_node] keep_indices = [idx for idx in range(len(nodes)) if idx != slack_idx] if not keep_indices: - return {"matrix": np.zeros((len(edges), len(nodes))), "nodes": nodes, "slack": slack_node, "edges": edges} + return { + "matrix": np.zeros((len(edges), len(nodes))), + "nodes": nodes, + "slack": slack_node, + "edges": edges, + } incidence_sba = incidence[:, keep_indices] weight_diag = np.diag(edge_susceptances) @@ -295,9 +304,7 @@ def aggregate( cluster_order = list(topology_graph.nodes()) representatives = _select_representatives(partition_map, cluster_order) - laplacian, node_to_index = _build_admittance_matrix( - original_graph, self.reactance_property - ) + laplacian, node_to_index = _build_admittance_matrix(original_graph, self.reactance_property) keep_indices = [node_to_index[node] for node in representatives] reduced_laplacian = _kron_reduce_laplacian(laplacian, keep_indices) @@ -373,9 +380,7 @@ def aggregate( cluster_order = list(topology_graph.nodes()) representatives = _select_representatives(partition_map, cluster_order) - laplacian, node_to_index = _build_admittance_matrix( - original_graph, self.reactance_property - ) + laplacian, node_to_index = _build_admittance_matrix(original_graph, self.reactance_property) try: keep_indices = [node_to_index[node] for node in representatives] diff --git a/npap/cli.py b/npap/cli.py index 5168359..2477e60 100644 --- a/npap/cli.py +++ b/npap/cli.py @@ -58,9 +58,7 @@ def _load_graph(args: argparse.Namespace, manager: PartitionAggregatorManager) - if args.graph_file: graph = _load_graph_from_file(args.graph_file, args.graph_format) bidirectional = not args.no_bidirectional - return manager.load_data( - "networkx_direct", graph=graph, bidirectional=bidirectional - ) + return manager.load_data("networkx_direct", graph=graph, bidirectional=bidirectional) raise ValueError("Either node/edge files or a graph file must be provided.") @@ -145,9 +143,7 @@ def cluster_entry() -> None: manager = PartitionAggregatorManager() graph = _load_graph(args, manager) - partition = manager.partition( - graph, args.partition_strategy, n_clusters=args.n_clusters - ) + partition = manager.partition(graph, args.partition_strategy, n_clusters=args.n_clusters) _dump_partition(partition.mapping, args.partition_output) diff --git a/npap/managers.py b/npap/managers.py index 6b9a340..e06bd19 100644 --- a/npap/managers.py +++ b/npap/managers.py @@ -101,9 +101,7 @@ def _register_default_strategies(self) -> None: algorithm="kmedoids", distance_metric="haversine" ) self._strategies["lmp_similarity"] = LMPPartitioning() - self._strategies[ - "adjacent_agglomerative" - ] = AdjacentNodeAgglomerativePartitioning() + self._strategies["adjacent_agglomerative"] = AdjacentNodeAgglomerativePartitioning() self._strategies["geographical_dbscan_euclidean"] = GeographicalPartitioning( algorithm="dbscan", distance_metric="euclidean" ) diff --git a/npap/partitioning/adjacent.py b/npap/partitioning/adjacent.py index 0c63f2c..a8bdbcd 100644 --- a/npap/partitioning/adjacent.py +++ b/npap/partitioning/adjacent.py @@ -139,11 +139,7 @@ def partition(self, graph: nx.DiGraph, **kwargs) -> dict[int, list[Any]]: if ac_attr: island_u = node_islands.get(u) island_v = node_islands.get(v) - if ( - island_u is not None - and island_v is not None - and island_u != island_v - ): + if island_u is not None and island_v is not None and island_u != island_v: continue edges.append((u, v)) diff --git a/npap/visualization.py b/npap/visualization.py index 0e3a381..1197325 100644 --- a/npap/visualization.py +++ b/npap/visualization.py @@ -914,6 +914,7 @@ def plot_clustered(self, config: PlotConfig | None = None, show: bool = True) -> """ return self._plot(PlotStyle.CLUSTERED, config, show) + def plot_network( graph: nx.DiGraph, style: str = "simple", @@ -1057,7 +1058,9 @@ def export_figure( return target -def clone_graph(graph: nx.Graph | nx.MultiGraph | nx.MultiDiGraph) -> nx.Graph | nx.MultiGraph | nx.MultiDiGraph: +def clone_graph( + graph: nx.Graph | nx.MultiGraph | nx.MultiDiGraph, +) -> nx.Graph | nx.MultiGraph | nx.MultiDiGraph: """ Return a deep copy of the supplied graph for safe downstream edits. @@ -1113,7 +1116,9 @@ def plot_reduced_matrices( if not available: raise ValueError("No reduced matrices found on the graph.") - fig = make_subplots(rows=len(available), cols=1, subplot_titles=[name for name, *_ in available]) + fig = make_subplots( + rows=len(available), cols=1, subplot_titles=[name for name, *_ in available] + ) for row, (name, matrix, labels) in enumerate(available, start=1): fig.add_trace( diff --git a/scripts/lmp_conservation_workflow.py b/scripts/lmp_conservation_workflow.py index 4618745..25ad331 100644 --- a/scripts/lmp_conservation_workflow.py +++ b/scripts/lmp_conservation_workflow.py @@ -73,8 +73,12 @@ def main( parser.add_argument("--node-file", required=True, help="Node CSV file path.") parser.add_argument("--edge-file", required=True, help="Edge CSV file path.") parser.add_argument("--clusters", type=int, default=6, help="Number of LMP clusters.") - parser.add_argument("--partition-output", help="Optional HTML export for the partitioned graph.") - parser.add_argument("--figure-output", help="Optional HTML export after conservation aggregation.") + parser.add_argument( + "--partition-output", help="Optional HTML export for the partitioned graph." + ) + parser.add_argument( + "--figure-output", help="Optional HTML export after conservation aggregation." + ) args = parser.parse_args() diff --git a/test/test_aggregation.py b/test/test_aggregation.py index 457d2e4..79080b3 100644 --- a/test/test_aggregation.py +++ b/test/test_aggregation.py @@ -468,9 +468,7 @@ def test_invalid_node_strategy_raises(self, simple_digraph, simple_partition_map class TestPTDFAggregationStrategy: """Tests for the PTDF reduction physical strategy.""" - def test_ptdf_reduction_generates_reactance_edges( - self, simple_digraph, simple_partition_map - ): + def test_ptdf_reduction_generates_reactance_edges(self, simple_digraph, simple_partition_map): topology = ElectricalTopologyStrategy(initial_connectivity="existing") topology_graph = topology.create_topology(simple_digraph, simple_partition_map) strategy = PTDFReductionStrategy() @@ -501,9 +499,7 @@ def test_series_reduction_combines_path(self): topology_graph = topology.create_topology(graph, partition) strategy = KronReductionStrategy() - aggregated = strategy.aggregate( - graph, partition, topology_graph, properties=["x"] - ) + aggregated = strategy.aggregate(graph, partition, topology_graph, properties=["x"]) assert set(aggregated.nodes()) == {0, 1} assert aggregated.edges[0, 1]["x"] == pytest.approx(2.0) From 5c46e48d0fc3f9d2fda6acc5b68ebf724b47f8ed Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Sat, 21 Feb 2026 00:09:03 +0100 Subject: [PATCH 12/23] Add tests to cover CLI and aggregation helpers --- test/test_cli_entries.py | 184 ++++++++++++++++++++++++++ test/test_cli_helpers.py | 49 +++++++ test/test_managers_physical_params.py | 61 +++++++++ test/test_partitioning_additional.py | 62 +++++++++ test/test_physical_strategies.py | 54 ++++++++ test/test_visualization_helpers.py | 39 ++++++ 6 files changed, 449 insertions(+) create mode 100644 test/test_cli_entries.py create mode 100644 test/test_cli_helpers.py create mode 100644 test/test_managers_physical_params.py create mode 100644 test/test_partitioning_additional.py create mode 100644 test/test_physical_strategies.py create mode 100644 test/test_visualization_helpers.py diff --git a/test/test_cli_entries.py b/test/test_cli_entries.py new file mode 100644 index 0000000..5687c7e --- /dev/null +++ b/test/test_cli_entries.py @@ -0,0 +1,184 @@ +import argparse +from types import SimpleNamespace + +import networkx as nx + + +class DummyPartitionManager: + def __init__(self): + self.graph = nx.DiGraph() + + def load_data(self, *args, **kwargs): + self.graph.add_node("A", lat=0.0, lon=0.0) + self.graph.add_node("B", lat=1.0, lon=1.0) + self.graph.add_edge("A", "B") + return self.graph + + def partition(self, graph, strategy, n_clusters): + from npap.interfaces import PartitionResult + + return PartitionResult( + mapping={0: ["A"], 1: ["B"]}, + original_graph_hash="dummy", + strategy_name=strategy, + strategy_metadata={}, + n_clusters=n_clusters, + ) + + +class DummyAggregationManager: + def aggregate(self, graph, partition_map, profile): + agg = nx.DiGraph() + agg.add_node("agg") + return agg + + +def _monkeypatch_parse_args(monkeypatch, args): + monkeypatch.setattr(argparse.ArgumentParser, "parse_args", lambda self: args) + + +def test_cluster_entry_uses_partition_manager(monkeypatch): + args = SimpleNamespace( + node_file="nodes.csv", + edge_file="edges.csv", + graph_file=None, + graph_format=None, + no_bidirectional=False, + delimiter=",", + decimal=".", + node_id_col=None, + edge_from_col=None, + edge_to_col=None, + partition_strategy="adjacent_agglomerative", + n_clusters=2, + partition_output=None, + ) + _monkeypatch_parse_args(monkeypatch, args) + + recorded = [] + def fake_dump(partition, output=None): + recorded.append((partition.copy(), output)) + + def fake_partition_manager(): + return DummyPartitionManager() + + monkeypatch.setattr("npap.cli.PartitionAggregatorManager", fake_partition_manager) + monkeypatch.setattr("npap.cli._dump_partition", fake_dump) + from npap.cli import cluster_entry + + cluster_entry() + + assert recorded + mapping, output = recorded[0] + assert mapping == {0: ["A"], 1: ["B"]} + assert output is None + + +def test_aggregate_entry_writes_aggregated_graph(monkeypatch, tmp_path): + args = SimpleNamespace( + node_file=None, + edge_file=None, + graph_file=None, + graph_format=None, + no_bidirectional=False, + delimiter=",", + decimal=".", + node_id_col=None, + edge_from_col=None, + edge_to_col=None, + partition_file=str(tmp_path / "part.json"), + mode="geographical", + output=str(tmp_path / "agg.graphml"), + ) + _monkeypatch_parse_args(monkeypatch, args) + + monkeypatch.setattr("npap.cli._load_graph", lambda args, manager: nx.DiGraph()) + monkeypatch.setattr("npap.cli._read_partition", lambda path: {0: ["A"]}) + def fake_aggregation_manager(): + return DummyAggregationManager() + + monkeypatch.setattr("npap.cli.AggregationManager", fake_aggregation_manager) + monkeypatch.setattr("npap.cli.get_mode_profile", lambda mode: "profile") + + recorded = [] + + def fake_write(graph, output, fmt=None): + recorded.append((graph, output, fmt)) + + monkeypatch.setattr("npap.cli._write_graph", fake_write) + from npap.cli import aggregate_entry + + aggregate_entry() + + assert recorded + written_graph, path, _ = recorded[0] + assert path == args.output + assert isinstance(written_graph, nx.Graph) + + +def test_plot_entry_exports_figure(monkeypatch, tmp_path): + args = SimpleNamespace( + node_file=None, + edge_file=None, + graph_file=None, + graph_format=None, + no_bidirectional=False, + delimiter=",", + decimal=".", + node_id_col=None, + edge_from_col=None, + edge_to_col=None, + aggregated_file="agg.graphml", + aggregated_format="graphml", + partition_file="part.json", + style="clustered", + preset="presentation", + output=str(tmp_path / "figure.html"), + ) + _monkeypatch_parse_args(monkeypatch, args) + + monkeypatch.setattr("npap.cli._load_graph", lambda args, manager: nx.DiGraph()) + monkeypatch.setattr("npap.cli._load_graph_from_file", lambda path, fmt: nx.DiGraph()) + monkeypatch.setattr("npap.cli._read_partition", lambda path: {0: ["A"]}) + monkeypatch.setattr("npap.cli.plot_network", lambda *args, **kwargs: object()) + + recorded = [] + + def fake_export(fig, path, **kwargs): + recorded.append((fig, path, kwargs)) + + monkeypatch.setattr("npap.cli.export_figure", fake_export) + from npap.cli import plot_entry + + plot_entry() + + assert recorded + _, path, _ = recorded[0] + assert path == args.output + + +def test_diagnose_entry_exports_diagnostics(monkeypatch, tmp_path): + args = SimpleNamespace( + aggregated_file="agg.graphml", + aggregated_format="graphml", + matrix=["ptdf"], + output=str(tmp_path / "diag.html"), + ) + _monkeypatch_parse_args(monkeypatch, args) + + monkeypatch.setattr("npap.cli._load_graph_from_file", lambda path, fmt: nx.DiGraph()) + monkeypatch.setattr("npap.cli.plot_reduced_matrices", lambda graph, **kwargs: object()) + + recorded = [] + + def fake_export(fig, path, **kwargs): + recorded.append((fig, path)) + + monkeypatch.setattr("npap.cli.export_figure", fake_export) + from npap.cli import diagnose_entry + + diagnose_entry() + + assert recorded + _, path = recorded[0] + assert path == args.output diff --git a/test/test_cli_helpers.py b/test/test_cli_helpers.py new file mode 100644 index 0000000..88d0fad --- /dev/null +++ b/test/test_cli_helpers.py @@ -0,0 +1,49 @@ +import json + +import networkx as nx + +from npap.cli import ( + _dump_partition, + _load_graph_from_file, + _read_partition, + _write_graph, +) + + +def test_dump_and_read_partition(tmp_path): + partition = {0: ["A"], 1: ["B"]} + output = tmp_path / "partition.json" + + _dump_partition(partition, str(output)) + loaded = _read_partition(str(output)) + + assert output.exists() + assert loaded == partition + assert json.loads(output.read_text()) == {"0": ["A"], "1": ["B"]} + + +def test_write_graph_with_supported_formats(tmp_path, monkeypatch): + graph = nx.DiGraph() + graph.add_node("bus") + graph.add_edge("bus", "bus") + + target = tmp_path / "grid.graphml" + if not hasattr(nx, "write_gpickle"): + monkeypatch.setattr(nx, "write_gpickle", nx.write_graphml, raising=False) + + _write_graph(graph, str(target)) + + assert target.exists() + assert isinstance(nx.read_graphml(target), nx.Graph) + + +def test_load_graph_from_file(tmp_path): + graph = nx.DiGraph() + graph.add_node("A") + path = tmp_path / "grid.graphml" + nx.write_graphml(graph, path) + + loaded = _load_graph_from_file(str(path), None) + + assert isinstance(loaded, nx.Graph) + assert list(loaded.nodes()) == ["A"] diff --git a/test/test_managers_physical_params.py b/test/test_managers_physical_params.py new file mode 100644 index 0000000..afaa06c --- /dev/null +++ b/test/test_managers_physical_params.py @@ -0,0 +1,61 @@ +import networkx as nx + +from npap.interfaces import AggregationMode, AggregationProfile, PhysicalAggregationStrategy +from npap.managers import AggregationManager + + +class RecordingPhysicalStrategy(PhysicalAggregationStrategy): + required_properties = [] + modifies_properties = [] + + def __init__(self): + self.received = {} + + @property + def can_create_edges(self) -> bool: + return True + + @property + def required_topology(self) -> str: + return "simple" + + def aggregate( + self, + original_graph, + partition_map, + topology_graph, + properties, + parameters=None, + ): + self.received["node_to_cluster"] = parameters.get("node_to_cluster") + self.received["cluster_edge_map"] = parameters.get("cluster_edge_map") + return topology_graph + + +def test_aggregation_manager_passes_physical_parameters(): + manager = AggregationManager() + strategy = RecordingPhysicalStrategy() + manager.register_physical_strategy("recording", strategy) + + profile = AggregationProfile( + mode=AggregationMode.CUSTOM, + topology_strategy="simple", + physical_strategy="recording", + physical_properties=[], + node_properties={}, + edge_properties={}, + default_node_strategy="sum", + default_edge_strategy="sum", + warn_on_defaults=False, + ) + + graph = nx.DiGraph() + graph.add_node("A") + graph.add_node("B") + graph.add_edge("A", "B") + partition_map = {0: ["A"], 1: ["B"]} + + manager.aggregate(graph, partition_map, profile) + + assert strategy.received["node_to_cluster"] + assert strategy.received["cluster_edge_map"] diff --git a/test/test_partitioning_additional.py b/test/test_partitioning_additional.py new file mode 100644 index 0000000..99bb825 --- /dev/null +++ b/test/test_partitioning_additional.py @@ -0,0 +1,62 @@ +import networkx as nx + +from npap.partitioning.adjacent import ( + AdjacentAgglomerativeConfig, + AdjacentNodeAgglomerativePartitioning, +) +from npap.partitioning.graph_theory import CommunityPartitioning, SpectralPartitioning +from npap.partitioning.lmp import LMPPartitioning, LMPPartitioningConfig + + +def _build_chain_graph(): + graph = nx.DiGraph() + for idx in range(4): + graph.add_node(idx, lmp=float(idx), ac_island="A" if idx < 2 else "B") + if idx > 0: + graph.add_edge(idx - 1, idx) + return graph + + +def test_adjacent_avoids_cross_island_merges(): + graph = _build_chain_graph() + strategy = AdjacentNodeAgglomerativePartitioning( + AdjacentAgglomerativeConfig(node_attribute="lmp", ac_island_attr="ac_island") + ) + + partition = strategy.partition(graph, n_clusters=2) + + assert len(partition) == 2 + islands = {node: graph.nodes[node]["ac_island"] for cluster in partition.values() for node in cluster} + assert set(islands.values()) == {"A", "B"} + + +def test_lmp_infinite_distance_respects_islands(): + graph = _build_chain_graph() + strategy = LMPPartitioning( + LMPPartitioningConfig(adjacency_bonus=0.0, infinite_distance=1e3) + ) + + partition = strategy.partition(graph, n_clusters=2) + + assert len(partition) == 2 + assert all(graph.nodes[node]["ac_island"] in {"A", "B"} for cluster in partition.values() for node in cluster) + + +def test_community_partitioning_detects_two_groups(): + graph = nx.DiGraph() + graph.add_edges_from([(0, 1), (1, 0), (2, 3), (3, 2)]) + + strategy = CommunityPartitioning() + partition = strategy.partition(graph, n_clusters=2) + + assert len(partition) == 2 + + +def test_spectral_partitioning_respects_connectivity(): + graph = nx.DiGraph() + graph.add_edges_from([(0, 1), (1, 2), (2, 0), (3, 4)]) + strategy = SpectralPartitioning() + result = strategy.partition(graph, n_clusters=2, random_state=0) + + assert isinstance(result, dict) + assert len(result) == 2 diff --git a/test/test_physical_strategies.py b/test/test_physical_strategies.py new file mode 100644 index 0000000..eba73fb --- /dev/null +++ b/test/test_physical_strategies.py @@ -0,0 +1,54 @@ +import networkx as nx + +from npap.aggregation.physical_strategies import KronReductionStrategy, PTDFReductionStrategy + + +def _build_simple_graph() -> tuple[nx.DiGraph, dict[int, list[int]]]: + graph = nx.DiGraph() + graph.add_node("A") + graph.add_node("B") + graph.add_edge("A", "B", x=0.5) + partition_map = {0: ["A"], 1: ["B"]} + return graph, partition_map + + +def _build_topology() -> nx.DiGraph: + topology = nx.DiGraph() + topology.add_nodes_from([0, 1]) + return topology + + +def test_ptdf_reduction_stores_matrix(): + original, partition_map = _build_simple_graph() + topology = _build_topology() + strategy = PTDFReductionStrategy(reactance_property="x") + + aggregated = strategy.aggregate( + original, + partition_map, + topology, + ["x"], + parameters=None, + ) + + assert aggregated.graph["reduced_ptdf"]["matrix"].shape[0] >= 0 + assert aggregated.graph["reduced_ptdf"]["nodes"] == [0, 1] + assert aggregated.has_edge(0, 1) or aggregated.has_edge(1, 0) + + +def test_kron_reduction_generates_laplacian(): + original, partition_map = _build_simple_graph() + topology = _build_topology() + strategy = KronReductionStrategy(reactance_property="x") + + aggregated = strategy.aggregate( + original, + partition_map, + topology, + ["x"], + parameters=None, + ) + + laplacian = aggregated.graph["kron_reduced_laplacian"] + assert laplacian.shape == (2, 2) + assert aggregated.has_edge(0, 1) or aggregated.has_edge(1, 0) diff --git a/test/test_visualization_helpers.py b/test/test_visualization_helpers.py new file mode 100644 index 0000000..71600c7 --- /dev/null +++ b/test/test_visualization_helpers.py @@ -0,0 +1,39 @@ +import networkx as nx +import numpy as np +import plotly.graph_objects as go +import pytest + +from npap.visualization import PlotPreset, plot_network, plot_reduced_matrices + + +def _build_simple_graph(): + graph = nx.DiGraph() + graph.add_node("A", lat=0.0, lon=0.0) + graph.add_node("B", lat=0.1, lon=0.1) + graph.add_edge("A", "B") + return graph + + +def test_plot_network_returns_figure(): + graph = _build_simple_graph() + + fig = plot_network(graph, style="simple", preset=PlotPreset.DENSE, show=False) + + assert isinstance(fig, go.Figure) + assert fig.data + + +def test_plot_reduced_matrices_requires_diagnostics(): + graph = nx.DiGraph() + + with pytest.raises(ValueError): + plot_reduced_matrices(graph, matrices=("ptdf",)) + + graph.graph["reduced_ptdf"] = { + "matrix": np.array([[1.0]]), + "nodes": ["A"], + "slack": None, + "edges": [("A", "A")], + } + fig = plot_reduced_matrices(graph, matrices=("ptdf",), show=False) + assert isinstance(fig, go.Figure) From 61292c72178b02e6ae368dccebe0f57e8cf6ede4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:09:53 +0000 Subject: [PATCH 13/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test/test_cli_entries.py | 2 ++ test/test_partitioning_additional.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/test/test_cli_entries.py b/test/test_cli_entries.py index 5687c7e..9cacc1b 100644 --- a/test/test_cli_entries.py +++ b/test/test_cli_entries.py @@ -56,6 +56,7 @@ def test_cluster_entry_uses_partition_manager(monkeypatch): _monkeypatch_parse_args(monkeypatch, args) recorded = [] + def fake_dump(partition, output=None): recorded.append((partition.copy(), output)) @@ -94,6 +95,7 @@ def test_aggregate_entry_writes_aggregated_graph(monkeypatch, tmp_path): monkeypatch.setattr("npap.cli._load_graph", lambda args, manager: nx.DiGraph()) monkeypatch.setattr("npap.cli._read_partition", lambda path: {0: ["A"]}) + def fake_aggregation_manager(): return DummyAggregationManager() diff --git a/test/test_partitioning_additional.py b/test/test_partitioning_additional.py index 99bb825..c424fc3 100644 --- a/test/test_partitioning_additional.py +++ b/test/test_partitioning_additional.py @@ -26,20 +26,24 @@ def test_adjacent_avoids_cross_island_merges(): partition = strategy.partition(graph, n_clusters=2) assert len(partition) == 2 - islands = {node: graph.nodes[node]["ac_island"] for cluster in partition.values() for node in cluster} + islands = { + node: graph.nodes[node]["ac_island"] for cluster in partition.values() for node in cluster + } assert set(islands.values()) == {"A", "B"} def test_lmp_infinite_distance_respects_islands(): graph = _build_chain_graph() - strategy = LMPPartitioning( - LMPPartitioningConfig(adjacency_bonus=0.0, infinite_distance=1e3) - ) + strategy = LMPPartitioning(LMPPartitioningConfig(adjacency_bonus=0.0, infinite_distance=1e3)) partition = strategy.partition(graph, n_clusters=2) assert len(partition) == 2 - assert all(graph.nodes[node]["ac_island"] in {"A", "B"} for cluster in partition.values() for node in cluster) + assert all( + graph.nodes[node]["ac_island"] in {"A", "B"} + for cluster in partition.values() + for node in cluster + ) def test_community_partitioning_detects_two_groups(): From f024ddb630697548881da6863f52fbd019a49f62 Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Thu, 26 Feb 2026 04:08:18 +0100 Subject: [PATCH 14/23] Fix CSV loading: remove hardcoded quotechar and add comment support --- npap/input/csv_loader.py | 4 ++-- npap/input/va_loader.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/npap/input/csv_loader.py b/npap/input/csv_loader.py index 32454d2..5a84dc4 100644 --- a/npap/input/csv_loader.py +++ b/npap/input/csv_loader.py @@ -115,7 +115,7 @@ def load(self, node_file: str, edge_file: str, **kwargs) -> nx.DiGraph | nx.Mult log_debug(f"Loading nodes from {node_file}", LogCategory.INPUT) # Load nodes - nodes_df = pd.read_csv(node_file, delimiter=delimiter, decimal=decimal) + nodes_df = pd.read_csv(node_file, delimiter=delimiter, decimal=decimal, comment="#") if nodes_df.empty: raise DataLoadingError("Node file is empty", strategy="csv_files") @@ -131,7 +131,7 @@ def load(self, node_file: str, edge_file: str, **kwargs) -> nx.DiGraph | nx.Mult log_debug(f"Loading edges from {edge_file}", LogCategory.INPUT) # Load edges - edges_df = pd.read_csv(edge_file, delimiter=delimiter, decimal=decimal, quotechar="'") + edges_df = pd.read_csv(edge_file, delimiter=delimiter, decimal=decimal, comment="#") if edges_df.empty: raise DataLoadingError("Edge file is empty", strategy="csv_files") diff --git a/npap/input/va_loader.py b/npap/input/va_loader.py index 802a228..3851b0f 100644 --- a/npap/input/va_loader.py +++ b/npap/input/va_loader.py @@ -441,7 +441,7 @@ def _load_nodes(file_path: str, delimiter: str, decimal: str) -> pd.DataFrame: DataLoadingError If node file is empty. """ - nodes_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal) + nodes_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, comment="#") if nodes_df.empty: raise DataLoadingError("Node file is empty", strategy="va_loader") @@ -471,7 +471,7 @@ def _load_lines(self, file_path: str, delimiter: str, decimal: str) -> pd.DataFr DataLoadingError If lines file is missing required columns. """ - lines_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, quotechar="'") + lines_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, comment="#") if lines_df.empty: log_warning( @@ -514,7 +514,7 @@ def _load_transformers(self, file_path: str, delimiter: str, decimal: str) -> pd If transformers file is missing required columns or has invalid values. """ transformers_df = pd.read_csv( - file_path, delimiter=delimiter, decimal=decimal, quotechar="'" + file_path, delimiter=delimiter, decimal=decimal, comment="#" ) if transformers_df.empty: @@ -590,7 +590,7 @@ def _load_converters(self, file_path: str, delimiter: str, decimal: str) -> pd.D DataLoadingError If converters file is missing required columns. """ - converters_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, quotechar="'") + converters_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, comment="#") if converters_df.empty: log_warning( @@ -634,7 +634,7 @@ def _load_links(self, file_path: str, delimiter: str, decimal: str) -> pd.DataFr DataLoadingError If links file is missing required columns. """ - links_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, quotechar="'") + links_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, comment="#") if links_df.empty: log_warning("Links file is empty. No DC links will be created.", LogCategory.INPUT) From 846ae130ddb40b756b8900675ba7df2f23f1554f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:12:09 +0000 Subject: [PATCH 15/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- npap/input/va_loader.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/npap/input/va_loader.py b/npap/input/va_loader.py index 3851b0f..384ee67 100644 --- a/npap/input/va_loader.py +++ b/npap/input/va_loader.py @@ -513,9 +513,7 @@ def _load_transformers(self, file_path: str, delimiter: str, decimal: str) -> pd DataLoadingError If transformers file is missing required columns or has invalid values. """ - transformers_df = pd.read_csv( - file_path, delimiter=delimiter, decimal=decimal, comment="#" - ) + transformers_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, comment="#") if transformers_df.empty: log_warning( From 942027dcbd9192c43cec350c65c0934f92fa277d Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Fri, 20 Feb 2026 19:53:55 +0100 Subject: [PATCH 16/23] feature enhancement: copy graph visualization --- npap/aggregation/physical_strategies.py | 8 ++++++-- npap/partitioning/adjacent.py | 6 +++++- npap/visualization.py | 3 --- test/test_aggregation.py | 8 ++++++-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/npap/aggregation/physical_strategies.py b/npap/aggregation/physical_strategies.py index 5c17653..9201128 100644 --- a/npap/aggregation/physical_strategies.py +++ b/npap/aggregation/physical_strategies.py @@ -304,7 +304,9 @@ def aggregate( cluster_order = list(topology_graph.nodes()) representatives = _select_representatives(partition_map, cluster_order) - laplacian, node_to_index = _build_admittance_matrix(original_graph, self.reactance_property) + laplacian, node_to_index = _build_admittance_matrix( + original_graph, self.reactance_property + ) keep_indices = [node_to_index[node] for node in representatives] reduced_laplacian = _kron_reduce_laplacian(laplacian, keep_indices) @@ -380,7 +382,9 @@ def aggregate( cluster_order = list(topology_graph.nodes()) representatives = _select_representatives(partition_map, cluster_order) - laplacian, node_to_index = _build_admittance_matrix(original_graph, self.reactance_property) + laplacian, node_to_index = _build_admittance_matrix( + original_graph, self.reactance_property + ) try: keep_indices = [node_to_index[node] for node in representatives] diff --git a/npap/partitioning/adjacent.py b/npap/partitioning/adjacent.py index a8bdbcd..0c63f2c 100644 --- a/npap/partitioning/adjacent.py +++ b/npap/partitioning/adjacent.py @@ -139,7 +139,11 @@ def partition(self, graph: nx.DiGraph, **kwargs) -> dict[int, list[Any]]: if ac_attr: island_u = node_islands.get(u) island_v = node_islands.get(v) - if island_u is not None and island_v is not None and island_u != island_v: + if ( + island_u is not None + and island_v is not None + and island_u != island_v + ): continue edges.append((u, v)) diff --git a/npap/visualization.py b/npap/visualization.py index 1197325..bc429fd 100644 --- a/npap/visualization.py +++ b/npap/visualization.py @@ -914,7 +914,6 @@ def plot_clustered(self, config: PlotConfig | None = None, show: bool = True) -> """ return self._plot(PlotStyle.CLUSTERED, config, show) - def plot_network( graph: nx.DiGraph, style: str = "simple", @@ -1075,8 +1074,6 @@ def clone_graph( Deep copy of the original graph. """ return copy.deepcopy(graph) - - def plot_reduced_matrices( graph: nx.Graph, *, diff --git a/test/test_aggregation.py b/test/test_aggregation.py index 79080b3..457d2e4 100644 --- a/test/test_aggregation.py +++ b/test/test_aggregation.py @@ -468,7 +468,9 @@ def test_invalid_node_strategy_raises(self, simple_digraph, simple_partition_map class TestPTDFAggregationStrategy: """Tests for the PTDF reduction physical strategy.""" - def test_ptdf_reduction_generates_reactance_edges(self, simple_digraph, simple_partition_map): + def test_ptdf_reduction_generates_reactance_edges( + self, simple_digraph, simple_partition_map + ): topology = ElectricalTopologyStrategy(initial_connectivity="existing") topology_graph = topology.create_topology(simple_digraph, simple_partition_map) strategy = PTDFReductionStrategy() @@ -499,7 +501,9 @@ def test_series_reduction_combines_path(self): topology_graph = topology.create_topology(graph, partition) strategy = KronReductionStrategy() - aggregated = strategy.aggregate(graph, partition, topology_graph, properties=["x"]) + aggregated = strategy.aggregate( + graph, partition, topology_graph, properties=["x"] + ) assert set(aggregated.nodes()) == {0, 1} assert aggregated.edges[0, 1]["x"] == pytest.approx(2.0) From bff32011fc6e158040a5f2b93044d039fd7a39a1 Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Fri, 20 Feb 2026 20:13:54 +0100 Subject: [PATCH 17/23] Add CLI helpers --- npap/cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/npap/cli.py b/npap/cli.py index 2477e60..acd98ec 100644 --- a/npap/cli.py +++ b/npap/cli.py @@ -231,7 +231,6 @@ def plot_entry() -> None: ) export_figure(fig, args.output) - def diagnose_entry() -> None: """Visualize reduced PTDF/laplacian matrices from an aggregated graph.""" parser = argparse.ArgumentParser(description="Inspect reduced PTDF/laplacian matrices.") From 245cf7a35bfc7d9b4cbed8f554c3d4590577a6aa Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:35:11 +0000 Subject: [PATCH 18/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- npap/aggregation/physical_strategies.py | 8 ++------ npap/partitioning/adjacent.py | 6 +----- npap/visualization.py | 1 + test/test_aggregation.py | 8 ++------ 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/npap/aggregation/physical_strategies.py b/npap/aggregation/physical_strategies.py index 9201128..5c17653 100644 --- a/npap/aggregation/physical_strategies.py +++ b/npap/aggregation/physical_strategies.py @@ -304,9 +304,7 @@ def aggregate( cluster_order = list(topology_graph.nodes()) representatives = _select_representatives(partition_map, cluster_order) - laplacian, node_to_index = _build_admittance_matrix( - original_graph, self.reactance_property - ) + laplacian, node_to_index = _build_admittance_matrix(original_graph, self.reactance_property) keep_indices = [node_to_index[node] for node in representatives] reduced_laplacian = _kron_reduce_laplacian(laplacian, keep_indices) @@ -382,9 +380,7 @@ def aggregate( cluster_order = list(topology_graph.nodes()) representatives = _select_representatives(partition_map, cluster_order) - laplacian, node_to_index = _build_admittance_matrix( - original_graph, self.reactance_property - ) + laplacian, node_to_index = _build_admittance_matrix(original_graph, self.reactance_property) try: keep_indices = [node_to_index[node] for node in representatives] diff --git a/npap/partitioning/adjacent.py b/npap/partitioning/adjacent.py index 0c63f2c..a8bdbcd 100644 --- a/npap/partitioning/adjacent.py +++ b/npap/partitioning/adjacent.py @@ -139,11 +139,7 @@ def partition(self, graph: nx.DiGraph, **kwargs) -> dict[int, list[Any]]: if ac_attr: island_u = node_islands.get(u) island_v = node_islands.get(v) - if ( - island_u is not None - and island_v is not None - and island_u != island_v - ): + if island_u is not None and island_v is not None and island_u != island_v: continue edges.append((u, v)) diff --git a/npap/visualization.py b/npap/visualization.py index bc429fd..c8c50c9 100644 --- a/npap/visualization.py +++ b/npap/visualization.py @@ -914,6 +914,7 @@ def plot_clustered(self, config: PlotConfig | None = None, show: bool = True) -> """ return self._plot(PlotStyle.CLUSTERED, config, show) + def plot_network( graph: nx.DiGraph, style: str = "simple", diff --git a/test/test_aggregation.py b/test/test_aggregation.py index 457d2e4..79080b3 100644 --- a/test/test_aggregation.py +++ b/test/test_aggregation.py @@ -468,9 +468,7 @@ def test_invalid_node_strategy_raises(self, simple_digraph, simple_partition_map class TestPTDFAggregationStrategy: """Tests for the PTDF reduction physical strategy.""" - def test_ptdf_reduction_generates_reactance_edges( - self, simple_digraph, simple_partition_map - ): + def test_ptdf_reduction_generates_reactance_edges(self, simple_digraph, simple_partition_map): topology = ElectricalTopologyStrategy(initial_connectivity="existing") topology_graph = topology.create_topology(simple_digraph, simple_partition_map) strategy = PTDFReductionStrategy() @@ -501,9 +499,7 @@ def test_series_reduction_combines_path(self): topology_graph = topology.create_topology(graph, partition) strategy = KronReductionStrategy() - aggregated = strategy.aggregate( - graph, partition, topology_graph, properties=["x"] - ) + aggregated = strategy.aggregate(graph, partition, topology_graph, properties=["x"]) assert set(aggregated.nodes()) == {0, 1} assert aggregated.edges[0, 1]["x"] == pytest.approx(2.0) From f72a7b2f1d2b163542ffd42ea5f4166dadd60ff8 Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Sat, 21 Feb 2026 00:09:03 +0100 Subject: [PATCH 19/23] Add tests to cover CLI and aggregation helpers --- test/test_partitioning_additional.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/test_partitioning_additional.py b/test/test_partitioning_additional.py index c424fc3..da0867c 100644 --- a/test/test_partitioning_additional.py +++ b/test/test_partitioning_additional.py @@ -34,7 +34,9 @@ def test_adjacent_avoids_cross_island_merges(): def test_lmp_infinite_distance_respects_islands(): graph = _build_chain_graph() - strategy = LMPPartitioning(LMPPartitioningConfig(adjacency_bonus=0.0, infinite_distance=1e3)) + strategy = LMPPartitioning( + LMPPartitioningConfig(adjacency_bonus=0.0, infinite_distance=1e3) + ) partition = strategy.partition(graph, n_clusters=2) From a0f93f021b5b6b59a32d4287dbb7652af089f8a8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:09:53 +0000 Subject: [PATCH 20/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test/test_partitioning_additional.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/test_partitioning_additional.py b/test/test_partitioning_additional.py index da0867c..c424fc3 100644 --- a/test/test_partitioning_additional.py +++ b/test/test_partitioning_additional.py @@ -34,9 +34,7 @@ def test_adjacent_avoids_cross_island_merges(): def test_lmp_infinite_distance_respects_islands(): graph = _build_chain_graph() - strategy = LMPPartitioning( - LMPPartitioningConfig(adjacency_bonus=0.0, infinite_distance=1e3) - ) + strategy = LMPPartitioning(LMPPartitioningConfig(adjacency_bonus=0.0, infinite_distance=1e3)) partition = strategy.partition(graph, n_clusters=2) From e28016c1708b0989281c0ae915ea7d01cd9d07b1 Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Thu, 26 Feb 2026 04:16:24 +0100 Subject: [PATCH 21/23] Include debug scripts for Zenodo data loading --- tmp_inspect.py | 4 ++++ tmp_path.py | 2 ++ tmp_pd.py | 7 +++++++ tmp_read.py | 6 ++++++ tmp_read_repr.py | 6 ++++++ 5 files changed, 25 insertions(+) create mode 100644 tmp_inspect.py create mode 100644 tmp_path.py create mode 100644 tmp_pd.py create mode 100644 tmp_read.py create mode 100644 tmp_read_repr.py diff --git a/tmp_inspect.py b/tmp_inspect.py new file mode 100644 index 0000000..a4469ec --- /dev/null +++ b/tmp_inspect.py @@ -0,0 +1,4 @@ +import inspect +import powerplantmatching.data as data +print(data.IRENASTAT) +print(inspect.getsource(data.IRENASTAT)) diff --git a/tmp_path.py b/tmp_path.py new file mode 100644 index 0000000..3584bc6 --- /dev/null +++ b/tmp_path.py @@ -0,0 +1,2 @@ +import powerplantmatching +print(powerplantmatching.__file__) diff --git a/tmp_pd.py b/tmp_pd.py new file mode 100644 index 0000000..42a4c48 --- /dev/null +++ b/tmp_pd.py @@ -0,0 +1,7 @@ +import pandas as pd +import urllib.request +url='https://zenodo.org/records/10952917/files/IRENASTAT_capacities_2000-2023.csv' +with urllib.request.urlopen(url) as resp: + df=pd.read_csv(resp, comment='#', quotechar='"') +print(df.shape) +print(df.head()) diff --git a/tmp_read.py b/tmp_read.py new file mode 100644 index 0000000..c9317f5 --- /dev/null +++ b/tmp_read.py @@ -0,0 +1,6 @@ +import urllib.request +url='https://zenodo.org/records/10952917/files/IRENASTAT_capacities_2000-2023.csv' +with urllib.request.urlopen(url) as resp: + for _ in range(20): + line = resp.readline().decode('utf-8', 'replace').rstrip('\n') + print(line) diff --git a/tmp_read_repr.py b/tmp_read_repr.py new file mode 100644 index 0000000..973cffe --- /dev/null +++ b/tmp_read_repr.py @@ -0,0 +1,6 @@ +import urllib.request +url='https://zenodo.org/records/10952917/files/IRENASTAT_capacities_2000-2023.csv' +with urllib.request.urlopen(url) as resp: + for _ in range(12): + line = resp.readline().decode('utf-8', 'replace') + print(repr(line)) From 943c1f624bd3a9add0f188fcf0df4d3adab68224 Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Thu, 26 Feb 2026 04:33:28 +0100 Subject: [PATCH 22/23] docs: cleanup .hypothesis and add example library with European network data --- .gitignore | 3 + examples/README.md | 34 +++++++++ examples/pypsa_network_example.py | 111 ++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/pypsa_network_example.py diff --git a/.gitignore b/.gitignore index bfeca4b..62f8bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ Thumbs.db # Claude files CLAUDE.md .claude/ + +# Hypothesis cache +.hypothesis/ diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..584e10f --- /dev/null +++ b/examples/README.md @@ -0,0 +1,34 @@ +# NPAP Examples + +This directory contains examples demonstrating the use and potential applications of the **Network Partitioning and Aggregation Package (NPAP)**. + +## Available Examples + +### 1. European Electricity Network Partitioning +- **Script**: `pypsa_network_example.py` +- **Description**: This example process a topologically connected representation of the European high-voltage grid (220 kV to 750 kV) constructed from OpenStreetMap data. +- **Features Demonstrated**: + - Data loading from CSV files (compatible with PyPSA-Eur datasets). + - Geographical partitioning. + - Geographical aggregation. + - Interactive visualization of clustered networks. +- **Data Source**: [Zenodo Record 18619025](https://zenodo.org/records/18619025). + +## Running the Examples + +Ensure you have the required dependencies installed (including `requests` for data downloading): + +```bash +pip install requests pandas plotly networkx +``` + +To run the PyPSA example: + +```bash +python examples/pypsa_network_example.py +``` + +The script will automatically download the necessary CSV files from Zenodo (approx. 36 MB) into the `examples/data/` directory. + +## Outputs +Running the example will generate an HTML file (e.g., `examples/clustered_european_grid.html`) which can be opened in any web browser to explore the clustered network. diff --git a/examples/pypsa_network_example.py b/examples/pypsa_network_example.py new file mode 100644 index 0000000..9019e7e --- /dev/null +++ b/examples/pypsa_network_example.py @@ -0,0 +1,111 @@ +""" +NPAP Example: European Electricity Network Partitioning and Aggregation. + +This example showcases how to use NPAP to: +1. Load a real-world European high-voltage electricity network (based on PyPSA-Eur OSM data). +2. Partition the network using geographical and graph-theory-based strategies. +3. Aggregate the network to a reduced representation. +4. Visualize the results. + +Data Source: +Xiong, B., Fioriti, D., Neumann, F., Riepin, I., Brown, T. +Prebuilt Electricity Network for PyPSA-Eur based on OpenStreetMap Data. +Zenodo (2025). https://zenodo.org/records/18619025 +""" + +import os +import requests +import pandas as pd +from pathlib import Path + +from npap.managers import PartitionAggregatorManager +from npap.interfaces import AggregationMode +from npap.visualization import NetworkPlotter + +# --- 1. CONFIGURATION --- + +# Data files from Zenodo +ZENODO_URL = "https://zenodo.org/records/18619025/files/" +FILES = ["buses.csv", "lines.csv", "transformers.csv"] +DATA_DIR = Path("examples/data") + +# Partitioning settings +N_CLUSTERS = 50 # Target number of clusters + +# --- 2. DATA PREPARATION --- + +def download_data(): + """Download required data files from Zenodo if they don't exist.""" + DATA_DIR.mkdir(parents=True, exist_ok=True) + + for file_name in FILES: + target_path = DATA_DIR / file_name + if not target_path.exists(): + print(f"Downloading {file_name} from Zenodo...") + response = requests.get(f"{ZENODO_URL}{file_name}?download=1") + response.raise_for_status() + with open(target_path, "wb") as f: + f.write(response.content) + print(f"Saved to {target_path}") + else: + print(f"Using local file: {target_path}") + +# --- 3. MAIN WORKFLOW --- + +def main(): + # Ensure data is available + download_data() + + # Initialize NPAP Manager + manager = PartitionAggregatorManager() + + # 3.1 Load the network + # We use CSVFilesStrategy as the Zenodo data is in CSV format + print("\nLoading network from CSV files...") + graph = manager.load_data( + "csv_files", + node_file=str(DATA_DIR / "buses.csv"), + edge_file=str(DATA_DIR / "lines.csv"), + node_id_col="Bus", # column in Zenodo buses.csv + edge_from_col="bus0", # source column in lines.csv + edge_to_col="bus1" # target column in lines.csv + ) + + print(f"Original network: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges") + + # 3.2 Partitioning + # We'll use a geographical approach for this large-scale European grid + print(f"\nPartitioning network into {N_CLUSTERS} clusters (Geographical)...") + partition_result = manager.partition_graph( + "geographical", + n_clusters=N_CLUSTERS + ) + + print(f"Partitioning complete. Resulting in {partition_result.n_clusters} clusters.") + + # 3.3 Aggregation + # Reduce the network according to the partition + print("\nAggregating network...") + aggregated_graph = manager.aggregate_graph( + mode=AggregationMode.GEOGRAPHICAL + ) + + print(f"Aggregated network: {aggregated_graph.number_of_nodes()} nodes, {aggregated_graph.number_of_edges()} edges") + print(f"Compression ratio: {graph.number_of_nodes() / aggregated_graph.number_of_nodes():.2f}x") + + # 3.4 Visualization + # Create an interactive plot of the clustering results + print("\nGenerating visualization...") + plotter = NetworkPlotter(graph, partition_map=partition_result.mapping) + fig = plotter.plot_clustered() + + # Show results + # fig.show() # Uncomment to open in browser + + # Save the plot for reference + output_plot = "examples/clustered_european_grid.html" + fig.write_html(output_plot) + print(f"Visualization saved to {output_plot}") + +if __name__ == "__main__": + main() From 1088e290d971baa006a4f5f76b8690f199f1c595 Mon Sep 17 00:00:00 2001 From: Clifford Ondieki Date: Thu, 26 Feb 2026 05:16:17 +0100 Subject: [PATCH 23/23] chore: formatting and pypsa related test failure --- .gitignore | 4 + examples/README.md | 36 +++--- examples/pypsa_network_example.py | 207 ++++++++++++++++-------------- npap/cli.py | 1 + npap/input/va_loader.py | 102 +++++++++++---- npap/visualization.py | 2 + tmp_inspect.py | 4 - tmp_path.py | 1 + tmp_pd.py | 7 - tmp_read.py | 6 - tmp_read_repr.py | 6 - 11 files changed, 212 insertions(+), 164 deletions(-) delete mode 100644 tmp_inspect.py delete mode 100644 tmp_pd.py delete mode 100644 tmp_read.py delete mode 100644 tmp_read_repr.py diff --git a/.gitignore b/.gitignore index 62f8bc2..da9e389 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,7 @@ CLAUDE.md # Hypothesis cache .hypothesis/ + +# NPAP Examples data and results +examples/data/ +examples/output/ diff --git a/examples/README.md b/examples/README.md index 584e10f..6c8b668 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,34 +1,30 @@ # NPAP Examples -This directory contains examples demonstrating the use and potential applications of the **Network Partitioning and Aggregation Package (NPAP)**. +This directory contains examples demonstrating how to use NPAP (Network Partitioning & Aggregation Package) for large-scale power system network reduction. -## Available Examples +## Examples -### 1. European Electricity Network Partitioning -- **Script**: `pypsa_network_example.py` -- **Description**: This example process a topologically connected representation of the European high-voltage grid (220 kV to 750 kV) constructed from OpenStreetMap data. -- **Features Demonstrated**: - - Data loading from CSV files (compatible with PyPSA-Eur datasets). - - Geographical partitioning. - - Geographical aggregation. - - Interactive visualization of clustered networks. -- **Data Source**: [Zenodo Record 18619025](https://zenodo.org/records/18619025). +### 1. PyPSA European Network Example (`pypsa_network_example.py`) -## Running the Examples +This example showcases the full NPAP pipeline using a realistic dataset: +- **Data Source**: [Prebuilt Electricity Network for PyPSA-Eur](https://zenodo.org/records/18619025) (Zenodo). +- **Loading**: Uses the `VoltageAwareStrategy` to handle AC lines, transformers, and DC links. +- **Partitioning**: Groups thousands of buses into a user-specified number of clusters based on geographical coordinates. +- **Aggregation**: Reduces the network while conserving physical properties (impedance/reactance) using the `Conservation` mode. +- **Visualization**: Generates an interactive HTML map showing the partitions. -Ensure you have the required dependencies installed (including `requests` for data downloading): +#### Running the example +Ensure you have installed NPAP in editable mode: ```bash -pip install requests pandas plotly networkx +pip install -e "." ``` -To run the PyPSA example: - +Run the script: ```bash python examples/pypsa_network_example.py ``` -The script will automatically download the necessary CSV files from Zenodo (approx. 36 MB) into the `examples/data/` directory. - -## Outputs -Running the example will generate an HTML file (e.g., `examples/clustered_european_grid.html`) which can be opened in any web browser to explore the clustered network. +The script will: +1. Automatically download the required CSV data (~35 MB) from Zenodo if not present in `examples/data/`. +2. Process the network and save an interactive visualization to `examples/output/pypsa_european_partitions.html`. diff --git a/examples/pypsa_network_example.py b/examples/pypsa_network_example.py index 9019e7e..a107a64 100644 --- a/examples/pypsa_network_example.py +++ b/examples/pypsa_network_example.py @@ -1,111 +1,122 @@ """ -NPAP Example: European Electricity Network Partitioning and Aggregation. - -This example showcases how to use NPAP to: -1. Load a real-world European high-voltage electricity network (based on PyPSA-Eur OSM data). -2. Partition the network using geographical and graph-theory-based strategies. -3. Aggregate the network to a reduced representation. -4. Visualize the results. - -Data Source: -Xiong, B., Fioriti, D., Neumann, F., Riepin, I., Brown, T. -Prebuilt Electricity Network for PyPSA-Eur based on OpenStreetMap Data. -Zenodo (2025). https://zenodo.org/records/18619025 +Example: European Network Partitioning and Aggregation using PyPSA Data +======================================================================= + +This example demonstrates how to use NPAP to process a realistic, large-scale +power system network. It uses the "Prebuilt Electricity Network for PyPSA-Eur" +dataset from Zenodo (record 18619025). + +The script will: +1. Download the latest buses, lines, transformers, and DC links data. +2. Load the network using NPAP's VoltageAwareStrategy. +3. Partition the network using geographical K-Means. +4. Aggregate the network using the Transformer Conservation mode. +5. Save a "clustered" visualization showing the partitions. """ -import os -import requests -import pandas as pd +import urllib.request from pathlib import Path -from npap.managers import PartitionAggregatorManager -from npap.interfaces import AggregationMode -from npap.visualization import NetworkPlotter - -# --- 1. CONFIGURATION --- - -# Data files from Zenodo -ZENODO_URL = "https://zenodo.org/records/18619025/files/" -FILES = ["buses.csv", "lines.csv", "transformers.csv"] -DATA_DIR = Path("examples/data") - -# Partitioning settings -N_CLUSTERS = 50 # Target number of clusters - -# --- 2. DATA PREPARATION --- - -def download_data(): - """Download required data files from Zenodo if they don't exist.""" - DATA_DIR.mkdir(parents=True, exist_ok=True) - - for file_name in FILES: - target_path = DATA_DIR / file_name - if not target_path.exists(): - print(f"Downloading {file_name} from Zenodo...") - response = requests.get(f"{ZENODO_URL}{file_name}?download=1") - response.raise_for_status() - with open(target_path, "wb") as f: - f.write(response.content) - print(f"Saved to {target_path}") +from npap import AggregationMode, PartitionAggregatorManager +from npap.logging import LogCategory, log_info + +# Zenodo record details +ZENODO_RECORD = "18619025" +FILES = { + "node_file": "buses.csv", + "line_file": "lines.csv", + "transformer_file": "transformers.csv", + "converter_file": "converters.csv", + "link_file": "links.csv", +} +BASE_URL = f"https://zenodo.org/records/{ZENODO_RECORD}/files" + + +def download_data(data_dir: Path): + """Download required CSV files from Zenodo if they don't exist.""" + data_dir.mkdir(exist_ok=True, parents=True) + + for key, filename in FILES.items(): + target = data_dir / filename + if not target.exists(): + url = f"{BASE_URL}/{filename}?download=1" + log_info(f"Downloading {filename} from Zenodo...", LogCategory.INPUT) + urllib.request.urlretrieve(url, target) else: - print(f"Using local file: {target_path}") + log_info(f"Using local copy of {filename}", LogCategory.INPUT) + + +def run_example(): + """ + Execute the PyPSA network partitioning and aggregation example pipeline. -# --- 3. MAIN WORKFLOW --- + Downloads necessary CSVs, loads the network, maps geographical coordinates, + aggregates parallel edges, performs spatial partitioning, visualizes the + sub-networks, and executes a full topological/physical aggregation. + """ + # Set up paths + example_dir = Path(__file__).parent + data_dir = example_dir / "data" + output_dir = example_dir / "output" + output_dir.mkdir(exist_ok=True) -def main(): - # Ensure data is available - download_data() - - # Initialize NPAP Manager + # 1. Fetch data + download_data(data_dir) + + # 2. Initialize Manager and Load Data + log_info("Initializing NPAP Manager...", LogCategory.MANAGER) manager = PartitionAggregatorManager() - - # 3.1 Load the network - # We use CSVFilesStrategy as the Zenodo data is in CSV format - print("\nLoading network from CSV files...") - graph = manager.load_data( - "csv_files", - node_file=str(DATA_DIR / "buses.csv"), - edge_file=str(DATA_DIR / "lines.csv"), - node_id_col="Bus", # column in Zenodo buses.csv - edge_from_col="bus0", # source column in lines.csv - edge_to_col="bus1" # target column in lines.csv - ) - - print(f"Original network: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges") - - # 3.2 Partitioning - # We'll use a geographical approach for this large-scale European grid - print(f"\nPartitioning network into {N_CLUSTERS} clusters (Geographical)...") - partition_result = manager.partition_graph( - "geographical", - n_clusters=N_CLUSTERS + + # We use VoltageAwareStrategy to respect the hierarchy of AC/DC elements + manager.load_data( + strategy="va_loader", + node_file=str(data_dir / FILES["node_file"]), + line_file=str(data_dir / FILES["line_file"]), + transformer_file=str(data_dir / FILES["transformer_file"]), + converter_file=str(data_dir / FILES["converter_file"]), + link_file=str(data_dir / FILES["link_file"]), + node_id_col="bus_id", # PyPSA uses bus_id + quotechar="'", # Required for WKT geometry in these files ) - - print(f"Partitioning complete. Resulting in {partition_result.n_clusters} clusters.") - - # 3.3 Aggregation - # Reduce the network according to the partition - print("\nAggregating network...") - aggregated_graph = manager.aggregate_graph( - mode=AggregationMode.GEOGRAPHICAL + + log_info( + f"Loaded graph with {manager.get_current_graph().number_of_nodes()} nodes.", + LogCategory.INPUT, ) - - print(f"Aggregated network: {aggregated_graph.number_of_nodes()} nodes, {aggregated_graph.number_of_edges()} edges") - print(f"Compression ratio: {graph.number_of_nodes() / aggregated_graph.number_of_nodes():.2f}x") - - # 3.4 Visualization - # Create an interactive plot of the clustering results - print("\nGenerating visualization...") - plotter = NetworkPlotter(graph, partition_map=partition_result.mapping) - fig = plotter.plot_clustered() - - # Show results - # fig.show() # Uncomment to open in browser - - # Save the plot for reference - output_plot = "examples/clustered_european_grid.html" - fig.write_html(output_plot) - print(f"Visualization saved to {output_plot}") + + # 2.5 Aggregate parallel edges + log_info("Aggregating parallel edges...", LogCategory.MANAGER) + manager.aggregate_parallel_edges() + + # 2.6 Map 'x' and 'y' to 'lon' and 'lat' for GeographicalPartitioning + log_info("Mapping x/y coordinates to lon/lat...", LogCategory.MANAGER) + for node, data in manager.get_current_graph().nodes(data=True): + if "x" in data and "y" in data: + data["lon"] = data["x"] + data["lat"] = data["y"] + + # 3. Partitioning + # We create 15 clusters using geographical K-Means + n_clusters = 15 + log_info(f"Partitioning network into {n_clusters} clusters...", LogCategory.MANAGER) + manager.partition("geographical_kmeans", n_clusters=n_clusters) + + # 4. Visualization of Partitions + log_info("Generating partition visualization...", LogCategory.VISUALIZATION) + fig_path = output_dir / "pypsa_european_partitions.html" + + fig = manager.plot_network(style="clustered", show=False) + fig.write_html(str(fig_path)) + log_info(f"Partition visualization saved to: {fig_path}", LogCategory.VISUALIZATION) + + # 5. Aggregation + # use CONSERVATION mode to preserve electrical properties + log_info("Aggregating network (Conservation mode)...", LogCategory.AGGREGATION) + aggregated = manager.aggregate(mode=AggregationMode.CONSERVATION) + + log_info(f"Aggregated graph has {aggregated.number_of_nodes()} nodes.", LogCategory.AGGREGATION) + log_info("Example complete.", LogCategory.MANAGER) + if __name__ == "__main__": - main() + run_example() diff --git a/npap/cli.py b/npap/cli.py index acd98ec..2477e60 100644 --- a/npap/cli.py +++ b/npap/cli.py @@ -231,6 +231,7 @@ def plot_entry() -> None: ) export_figure(fig, args.output) + def diagnose_entry() -> None: """Visualize reduced PTDF/laplacian matrices from an aggregated graph.""" parser = argparse.ArgumentParser(description="Inspect reduced PTDF/laplacian matrices.") diff --git a/npap/input/va_loader.py b/npap/input/va_loader.py index 384ee67..4edc934 100644 --- a/npap/input/va_loader.py +++ b/npap/input/va_loader.py @@ -39,9 +39,6 @@ class VoltageAwareStrategy(DataLoadingStrategy): REQUIRED_TRANSFORMER_COLUMNS = [ "bus0", "bus1", - "x", - "primary_voltage", - "secondary_voltage", ] REQUIRED_CONVERTER_COLUMNS = ["converter_id", "bus0", "bus1", "voltage"] REQUIRED_LINK_COLUMNS = ["link_id", "bus0", "bus1", "voltage"] @@ -150,26 +147,29 @@ def load( If loading fails. """ try: - delimiter = kwargs.get("delimiter", ",") - decimal = kwargs.get("decimal", ".") + delimiter = kwargs.pop("delimiter", ",") + decimal = kwargs.pop("decimal", ".") + node_id_col_req = kwargs.pop("node_id_col", None) log_debug(f"Loading nodes from {node_file}", LogCategory.INPUT) # Load and validate nodes - nodes_df = self._load_nodes(node_file, delimiter, decimal) + nodes_df = self._load_nodes(node_file, delimiter, decimal, **kwargs) # Load and validate lines - lines_df = self._load_lines(line_file, delimiter, decimal) + lines_df = self._load_lines(line_file, delimiter, decimal, **kwargs) # Load and validate transformers - transformers_df = self._load_transformers(transformer_file, delimiter, decimal) + transformers_df = self._load_transformers( + transformer_file, delimiter, decimal, **kwargs + ) # Load DC link data - converters_df = self._load_converters(converter_file, delimiter, decimal) - links_df = self._load_links(link_file, delimiter, decimal) + converters_df = self._load_converters(converter_file, delimiter, decimal, **kwargs) + links_df = self._load_links(link_file, delimiter, decimal, **kwargs) # Get node ID column - node_id_col = kwargs.get("node_id_col", self._detect_id_column(nodes_df)) + node_id_col = node_id_col_req or self._detect_id_column(nodes_df) if node_id_col not in nodes_df.columns: raise DataLoadingError( f"Node ID column '{node_id_col}' not found in node file", @@ -418,7 +418,7 @@ def _verify_final_connectivity(graph: nx.DiGraph | nx.MultiDiGraph) -> None: # ========================================================================= @staticmethod - def _load_nodes(file_path: str, delimiter: str, decimal: str) -> pd.DataFrame: + def _load_nodes(file_path: str, delimiter: str, decimal: str, **kwargs) -> pd.DataFrame: """ Load and validate nodes DataFrame. @@ -441,14 +441,16 @@ def _load_nodes(file_path: str, delimiter: str, decimal: str) -> pd.DataFrame: DataLoadingError If node file is empty. """ - nodes_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, comment="#") + nodes_df = pd.read_csv( + file_path, delimiter=delimiter, decimal=decimal, comment="#", **kwargs + ) if nodes_df.empty: raise DataLoadingError("Node file is empty", strategy="va_loader") return nodes_df - def _load_lines(self, file_path: str, delimiter: str, decimal: str) -> pd.DataFrame: + def _load_lines(self, file_path: str, delimiter: str, decimal: str, **kwargs) -> pd.DataFrame: """ Load and validate lines DataFrame. @@ -471,7 +473,9 @@ def _load_lines(self, file_path: str, delimiter: str, decimal: str) -> pd.DataFr DataLoadingError If lines file is missing required columns. """ - lines_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, comment="#") + lines_df = pd.read_csv( + file_path, delimiter=delimiter, decimal=decimal, comment="#", **kwargs + ) if lines_df.empty: log_warning( @@ -490,7 +494,9 @@ def _load_lines(self, file_path: str, delimiter: str, decimal: str) -> pd.DataFr return lines_df - def _load_transformers(self, file_path: str, delimiter: str, decimal: str) -> pd.DataFrame: + def _load_transformers( + self, file_path: str, delimiter: str, decimal: str, **kwargs + ) -> pd.DataFrame: """ Load and validate transformers DataFrame. @@ -513,7 +519,9 @@ def _load_transformers(self, file_path: str, delimiter: str, decimal: str) -> pd DataLoadingError If transformers file is missing required columns or has invalid values. """ - transformers_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, comment="#") + transformers_df = pd.read_csv( + file_path, delimiter=delimiter, decimal=decimal, comment="#", **kwargs + ) if transformers_df.empty: log_warning( @@ -532,6 +540,27 @@ def _load_transformers(self, file_path: str, delimiter: str, decimal: str) -> pd details={"available_columns": list(transformers_df.columns)}, ) + # Normalize column names for voltage + voltage_map = { + "voltage_bus0": "primary_voltage", + "voltage_bus1": "secondary_voltage", + "v_nom0": "primary_voltage", + "v_nom1": "secondary_voltage", + } + for old_col, new_col in voltage_map.items(): + if old_col in transformers_df.columns and new_col not in transformers_df.columns: + transformers_df = transformers_df.rename(columns={old_col: new_col}) + + # Ensure required columns are present after renaming + final_required = ["primary_voltage", "secondary_voltage"] + missing_after = [col for col in final_required if col not in transformers_df.columns] + if missing_after: + raise DataLoadingError( + f"Transformers file missing required columns: {missing_after}", + strategy="va_loader", + details={"available_columns": list(transformers_df.columns)}, + ) + # Validate voltage values primary_v = transformers_df["primary_voltage"] secondary_v = transformers_df["secondary_voltage"] @@ -565,7 +594,9 @@ def _load_transformers(self, file_path: str, delimiter: str, decimal: str) -> pd return transformers_df - def _load_converters(self, file_path: str, delimiter: str, decimal: str) -> pd.DataFrame: + def _load_converters( + self, file_path: str, delimiter: str, decimal: str, **kwargs + ) -> pd.DataFrame: """ Load and validate converters DataFrame. @@ -588,7 +619,9 @@ def _load_converters(self, file_path: str, delimiter: str, decimal: str) -> pd.D DataLoadingError If converters file is missing required columns. """ - converters_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, comment="#") + converters_df = pd.read_csv( + file_path, delimiter=delimiter, decimal=decimal, comment="#", **kwargs + ) if converters_df.empty: log_warning( @@ -609,7 +642,7 @@ def _load_converters(self, file_path: str, delimiter: str, decimal: str) -> pd.D return converters_df - def _load_links(self, file_path: str, delimiter: str, decimal: str) -> pd.DataFrame: + def _load_links(self, file_path: str, delimiter: str, decimal: str, **kwargs) -> pd.DataFrame: """ Load and validate DC links DataFrame. @@ -632,7 +665,9 @@ def _load_links(self, file_path: str, delimiter: str, decimal: str) -> pd.DataFr DataLoadingError If links file is missing required columns. """ - links_df = pd.read_csv(file_path, delimiter=delimiter, decimal=decimal, comment="#") + links_df = pd.read_csv( + file_path, delimiter=delimiter, decimal=decimal, comment="#", **kwargs + ) if links_df.empty: log_warning("Links file is empty. No DC links will be created.", LogCategory.INPUT) @@ -790,12 +825,33 @@ def _prepare_transformer_tuples( edge_tuples = [] for record in trafo_records: + # Handle aliases for voltage columns + primary_v = ( + record.get("primary_voltage") + or record.get("voltage_bus0") + or record.get("v_nom0") + or 0 + ) + secondary_v = ( + record.get("secondary_voltage") + or record.get("voltage_bus1") + or record.get("v_nom1") + or 0 + ) + attrs = { "type": EdgeType.TRAFO.value, - "primary_voltage": record["primary_voltage"], - "secondary_voltage": record["secondary_voltage"], + "primary_voltage": primary_v, + "secondary_voltage": secondary_v, } + # Handle optional reactance + if "x" in record and pd.notna(record["x"]): + attrs["x"] = record["x"] + else: + # Default small reactance for transformers if not provided + attrs["x"] = 0.001 + # Add all other columns as attributes for col, val in record.items(): if col not in exclude_cols and col not in attrs and pd.notna(val): diff --git a/npap/visualization.py b/npap/visualization.py index c8c50c9..1197325 100644 --- a/npap/visualization.py +++ b/npap/visualization.py @@ -1075,6 +1075,8 @@ def clone_graph( Deep copy of the original graph. """ return copy.deepcopy(graph) + + def plot_reduced_matrices( graph: nx.Graph, *, diff --git a/tmp_inspect.py b/tmp_inspect.py deleted file mode 100644 index a4469ec..0000000 --- a/tmp_inspect.py +++ /dev/null @@ -1,4 +0,0 @@ -import inspect -import powerplantmatching.data as data -print(data.IRENASTAT) -print(inspect.getsource(data.IRENASTAT)) diff --git a/tmp_path.py b/tmp_path.py index 3584bc6..192d22e 100644 --- a/tmp_path.py +++ b/tmp_path.py @@ -1,2 +1,3 @@ import powerplantmatching + print(powerplantmatching.__file__) diff --git a/tmp_pd.py b/tmp_pd.py deleted file mode 100644 index 42a4c48..0000000 --- a/tmp_pd.py +++ /dev/null @@ -1,7 +0,0 @@ -import pandas as pd -import urllib.request -url='https://zenodo.org/records/10952917/files/IRENASTAT_capacities_2000-2023.csv' -with urllib.request.urlopen(url) as resp: - df=pd.read_csv(resp, comment='#', quotechar='"') -print(df.shape) -print(df.head()) diff --git a/tmp_read.py b/tmp_read.py deleted file mode 100644 index c9317f5..0000000 --- a/tmp_read.py +++ /dev/null @@ -1,6 +0,0 @@ -import urllib.request -url='https://zenodo.org/records/10952917/files/IRENASTAT_capacities_2000-2023.csv' -with urllib.request.urlopen(url) as resp: - for _ in range(20): - line = resp.readline().decode('utf-8', 'replace').rstrip('\n') - print(line) diff --git a/tmp_read_repr.py b/tmp_read_repr.py deleted file mode 100644 index 973cffe..0000000 --- a/tmp_read_repr.py +++ /dev/null @@ -1,6 +0,0 @@ -import urllib.request -url='https://zenodo.org/records/10952917/files/IRENASTAT_capacities_2000-2023.csv' -with urllib.request.urlopen(url) as resp: - for _ in range(12): - line = resp.readline().decode('utf-8', 'replace') - print(repr(line))