pypsa-explorer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ """
2
+ PyPSA Explorer - Interactive dashboard for visualizing and analyzing PyPSA energy system networks.
3
+
4
+ This package provides a comprehensive web-based dashboard for exploring PyPSA networks
5
+ with interactive visualizations, filtering capabilities, and multi-network support.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+ __author__ = "Open Energy Transition"
10
+ __email__ = "info@openenergytransition.org"
11
+
12
+ from pypsa_explorer.app import create_app, run_dashboard
13
+ from pypsa_explorer.utils.network_loader import load_networks
14
+
15
+ __all__ = [
16
+ "create_app",
17
+ "run_dashboard",
18
+ "load_networks",
19
+ "__version__",
20
+ ]
pypsa_explorer/app.py ADDED
@@ -0,0 +1,127 @@
1
+ """Main application module for PyPSA Explorer dashboard."""
2
+
3
+ import dash
4
+ import dash_bootstrap_components as dbc
5
+ import pypsa
6
+ import pypsa.consistency
7
+
8
+ from pypsa_explorer.callbacks import register_all_callbacks
9
+ from pypsa_explorer.config import get_html_template, setup_plotly_theme
10
+ from pypsa_explorer.layouts.dashboard import create_dashboard_layout
11
+ from pypsa_explorer.utils.helpers import resolve_default_network_path
12
+ from pypsa_explorer.utils.network_loader import load_networks
13
+
14
+
15
+ def create_app(
16
+ networks_input: dict[str, pypsa.Network | str] | str | None = None,
17
+ title: str = "PyPSA Explorer",
18
+ debug: bool = False, # noqa: ARG001
19
+ *,
20
+ load_default_on_start: bool = True,
21
+ default_network_path: str = "demo-network.nc",
22
+ ) -> dash.Dash:
23
+ """
24
+ Create and configure the Dash application.
25
+
26
+ Parameters
27
+ ----------
28
+ networks_input : dict, str, or None
29
+ Networks to load. Can be:
30
+ - dict: {label: network_object_or_path}
31
+ - str: Path to a single network file
32
+ - None: Behaviour depends on ``load_default_on_start``
33
+ title : str
34
+ Dashboard title
35
+ debug : bool
36
+ Whether to run in debug mode
37
+ load_default_on_start : bool
38
+ When ``True`` (default) load the bundled demo network if ``networks_input`` is ``None``.
39
+ When ``False`` start without networks and rely on runtime uploads or sample loading.
40
+ default_network_path : str
41
+ Filesystem path to the bundled demo network used when loading the example network.
42
+
43
+ Returns
44
+ -------
45
+ dash.Dash
46
+ Configured Dash application instance
47
+ """
48
+ # Setup Plotly theme
49
+ setup_plotly_theme()
50
+
51
+ resolved_default_path = resolve_default_network_path(default_network_path)
52
+ default_path_str = str(resolved_default_path) if resolved_default_path else default_network_path
53
+
54
+ # Load networks while allowing empty start states
55
+ if networks_input is None and not load_default_on_start:
56
+ networks: dict[str, pypsa.Network] = {}
57
+ else:
58
+ networks = load_networks(networks_input, default_network_path=default_path_str)
59
+
60
+ # Get the first network as the active network initially (if any)
61
+ network_labels = list(networks.keys())
62
+ active_network_label = network_labels[0] if network_labels else None
63
+
64
+ # Initialize Dash app
65
+ app = dash.Dash(
66
+ __name__,
67
+ external_stylesheets=[dbc.themes.BOOTSTRAP],
68
+ title=title,
69
+ )
70
+
71
+ # Set custom HTML template with embedded CSS
72
+ app.index_string = get_html_template()
73
+
74
+ # Create layout
75
+ app.layout = create_dashboard_layout(
76
+ networks,
77
+ active_network_label,
78
+ default_network_path=default_path_str,
79
+ )
80
+
81
+ # Register all callbacks
82
+ register_all_callbacks(app, networks, default_network_path=default_path_str)
83
+
84
+ return app
85
+
86
+
87
+ def run_dashboard(
88
+ networks_input: dict[str, pypsa.Network | str] | str | None = None,
89
+ debug: bool = True,
90
+ host: str = "127.0.0.1",
91
+ port: int = 8050,
92
+ *,
93
+ load_default_on_start: bool = True,
94
+ default_network_path: str = "demo-network.nc",
95
+ ) -> None:
96
+ """
97
+ Run the PyPSA Explorer dashboard.
98
+
99
+ Parameters
100
+ ----------
101
+ networks_input : dict, str, or None
102
+ Networks to load. Can be:
103
+ - dict: {label: network_object_or_path} mapping labels to Network objects or file paths
104
+ - str: Single path to a network file
105
+ - None: Behaviour depends on ``load_default_on_start``
106
+ debug : bool
107
+ Whether to run in debug mode
108
+ host : str
109
+ Host to run the server on
110
+ port : int
111
+ Port to run the server on
112
+ load_default_on_start : bool
113
+ Controls whether the bundled demo network loads automatically when ``networks_input`` is ``None``.
114
+ default_network_path : str
115
+ Filesystem path to the bundled demo network used for the sample loader.
116
+ """
117
+ app = create_app(
118
+ networks_input,
119
+ debug=debug,
120
+ load_default_on_start=load_default_on_start,
121
+ default_network_path=default_network_path,
122
+ )
123
+
124
+ print(f"Starting PyPSA Explorer Dashboard on http://{host}:{port}")
125
+ print("Press Ctrl+C to stop the server")
126
+
127
+ app.run(debug=debug, host=host, port=port)
@@ -0,0 +1,36 @@
1
+ """Callback functions for PyPSA Explorer dashboard interactivity."""
2
+
3
+ from pypsa_explorer.callbacks.data_explorer import register_data_explorer_callbacks
4
+ from pypsa_explorer.callbacks.filters import register_filter_callbacks
5
+ from pypsa_explorer.callbacks.navigation import register_navigation_callbacks
6
+ from pypsa_explorer.callbacks.network import register_network_callbacks
7
+ from pypsa_explorer.callbacks.theme import register_theme_callbacks
8
+ from pypsa_explorer.callbacks.visualizations import register_visualization_callbacks
9
+
10
+ __all__ = [
11
+ "register_data_explorer_callbacks",
12
+ "register_filter_callbacks",
13
+ "register_navigation_callbacks",
14
+ "register_network_callbacks",
15
+ "register_theme_callbacks",
16
+ "register_visualization_callbacks",
17
+ ]
18
+
19
+
20
+ def register_all_callbacks(app, networks: dict, *, default_network_path: str) -> None:
21
+ """
22
+ Register all dashboard callbacks.
23
+
24
+ Parameters
25
+ ----------
26
+ app : dash.Dash
27
+ The Dash application instance
28
+ networks : dict
29
+ Dictionary of loaded PyPSA networks
30
+ """
31
+ register_filter_callbacks(app)
32
+ register_navigation_callbacks(app)
33
+ register_network_callbacks(app, networks, default_network_path=default_network_path)
34
+ register_visualization_callbacks(app, networks)
35
+ register_data_explorer_callbacks(app, networks)
36
+ register_theme_callbacks(app)
@@ -0,0 +1,227 @@
1
+ """Data explorer callbacks for interactive component dataframe viewing."""
2
+
3
+ import logging
4
+
5
+ import dash
6
+ import pandas as pd
7
+ import pypsa
8
+ from dash import Input, Output, State, ctx, no_update
9
+
10
+ from pypsa_explorer.utils.data_table import dataframe_to_datatable, get_timeseries_attributes
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Maximum number of rows to display in tables
15
+ MAX_TABLE_ROWS = 5000
16
+
17
+ # Mapping of KPI card IDs to component names
18
+ KPI_COMPONENT_MAP = {
19
+ "kpi-card-buses": "buses",
20
+ "kpi-card-generators": "generators",
21
+ "kpi-card-lines": "lines",
22
+ "kpi-card-links": "links",
23
+ "kpi-card-storage_units": "storage_units",
24
+ "kpi-card-stores": "stores",
25
+ }
26
+
27
+ # Human-readable labels for components
28
+ COMPONENT_LABELS = {
29
+ "buses": "Nodes",
30
+ "generators": "Generators",
31
+ "lines": "Lines",
32
+ "links": "Links",
33
+ "storage_units": "Storage Units",
34
+ "stores": "Stores",
35
+ }
36
+
37
+
38
+ def register_data_explorer_callbacks(app: dash.Dash, networks: dict[str, pypsa.Network]) -> None:
39
+ """Register callbacks for the data explorer modal."""
40
+
41
+ @app.callback(
42
+ [
43
+ Output("data-explorer-modal", "is_open"),
44
+ Output("data-explorer-modal-title", "children"),
45
+ Output("static-data-table", "data"),
46
+ Output("static-data-table", "columns"),
47
+ Output("timeseries-attribute-selector", "options"),
48
+ Output("timeseries-attribute-selector", "value"),
49
+ Output("active-component-store", "data"),
50
+ ],
51
+ [
52
+ Input("kpi-card-buses", "n_clicks"),
53
+ Input("kpi-card-generators", "n_clicks"),
54
+ Input("kpi-card-lines", "n_clicks"),
55
+ Input("kpi-card-links", "n_clicks"),
56
+ Input("kpi-card-storage_units", "n_clicks"),
57
+ Input("kpi-card-stores", "n_clicks"),
58
+ Input("close-data-explorer-modal", "n_clicks"),
59
+ ],
60
+ [
61
+ State("network-selector", "data"),
62
+ State("data-explorer-modal", "is_open"),
63
+ ],
64
+ )
65
+ def toggle_modal_and_load_data(
66
+ buses_clicks: int,
67
+ generators_clicks: int,
68
+ lines_clicks: int,
69
+ links_clicks: int,
70
+ storage_units_clicks: int,
71
+ stores_clicks: int,
72
+ close_clicks: int, # noqa: ARG001
73
+ network_label: str | None,
74
+ is_open: bool, # noqa: ARG001
75
+ ) -> tuple[bool, str, list[dict], list[dict], list[dict], str | None, str]:
76
+ """Toggle modal and load component data when KPI card is clicked."""
77
+ # Get the ID of the component that triggered the callback
78
+ triggered_id = ctx.triggered_id
79
+
80
+ # If close button was clicked, close the modal
81
+ if triggered_id == "close-data-explorer-modal":
82
+ return False, no_update, no_update, no_update, no_update, no_update, no_update # type: ignore[return-value]
83
+
84
+ # If no KPI card was clicked, don't update
85
+ if triggered_id not in KPI_COMPONENT_MAP:
86
+ return no_update, no_update, no_update, no_update, no_update, no_update, no_update # type: ignore[return-value]
87
+
88
+ # Check if n_clicks is greater than 0 (actual click, not component recreation)
89
+ # When KPI cards are recreated during network switch, they reset to n_clicks=0
90
+ # This check prevents the modal from opening on network changes
91
+ click_counts = {
92
+ "kpi-card-buses": buses_clicks,
93
+ "kpi-card-generators": generators_clicks,
94
+ "kpi-card-lines": lines_clicks,
95
+ "kpi-card-links": links_clicks,
96
+ "kpi-card-storage_units": storage_units_clicks,
97
+ "kpi-card-stores": stores_clicks,
98
+ }
99
+
100
+ triggered_clicks = click_counts.get(triggered_id, 0)
101
+
102
+ # Only proceed if there was an actual click (n_clicks > 0)
103
+ if triggered_clicks == 0:
104
+ return no_update, no_update, no_update, no_update, no_update, no_update, no_update # type: ignore[return-value]
105
+
106
+ # Get the component name from the triggered card
107
+ component_name = KPI_COMPONENT_MAP[triggered_id]
108
+ component_label = COMPONENT_LABELS[component_name]
109
+
110
+ if not network_label or network_label not in networks:
111
+ return no_update, no_update, no_update, no_update, no_update, no_update, no_update # type: ignore[return-value]
112
+
113
+ try:
114
+ # Get the active network
115
+ n = networks[network_label]
116
+
117
+ # Get the component dataframe
118
+ if not hasattr(n, component_name):
119
+ logger.warning(f"Component '{component_name}' not found in network '{network_label}'")
120
+ return (
121
+ True,
122
+ f"{component_label} - No Data Available",
123
+ [],
124
+ [],
125
+ [],
126
+ None,
127
+ component_name,
128
+ )
129
+
130
+ component_df = getattr(n, component_name)
131
+
132
+ # Convert dataframe to dict for DataTable using utility
133
+ if isinstance(component_df, pd.DataFrame):
134
+ data, columns = dataframe_to_datatable(component_df)
135
+ else:
136
+ logger.warning(f"Component '{component_name}' is not a DataFrame")
137
+ data = []
138
+ columns = []
139
+
140
+ # Get available time-series attributes using efficient utility
141
+ timeseries_attrs = get_timeseries_attributes(n, component_name)
142
+ timeseries_options = [{"label": attr, "value": attr} for attr in timeseries_attrs]
143
+
144
+ return (
145
+ True, # Open modal
146
+ f"{component_label} Data ({len(component_df):,} records)",
147
+ data,
148
+ columns,
149
+ timeseries_options,
150
+ timeseries_options[0]["value"] if timeseries_options else None,
151
+ component_name, # Store component name for efficient lookup
152
+ )
153
+
154
+ except Exception as e:
155
+ logger.error(f"Error loading component data for '{component_name}': {e}")
156
+ return (
157
+ True,
158
+ f"{component_label} - Error Loading Data",
159
+ [],
160
+ [],
161
+ [],
162
+ None,
163
+ component_name,
164
+ )
165
+
166
+ @app.callback(
167
+ [
168
+ Output("timeseries-data-table", "data"),
169
+ Output("timeseries-data-table", "columns"),
170
+ ],
171
+ [
172
+ Input("timeseries-attribute-selector", "value"),
173
+ ],
174
+ [
175
+ State("active-component-store", "data"),
176
+ State("network-selector", "data"),
177
+ ],
178
+ )
179
+ def update_timeseries_data(
180
+ selected_attribute: str | None,
181
+ component_name: str | None,
182
+ network_label: str | None,
183
+ ) -> tuple[list[dict], list[dict]]:
184
+ """Update time-series data table when attribute is selected."""
185
+ if not selected_attribute or not component_name:
186
+ return [], []
187
+
188
+ if not network_label or network_label not in networks:
189
+ return [], []
190
+
191
+ try:
192
+ # Get the active network
193
+ n = networks[network_label]
194
+
195
+ # Get time-series data
196
+ timeseries_component = f"{component_name}_t"
197
+ if not hasattr(n, timeseries_component):
198
+ logger.warning(f"Time-series component '{timeseries_component}' not found in network '{network_label}'")
199
+ return [], []
200
+
201
+ ts_obj = getattr(n, timeseries_component)
202
+ if not hasattr(ts_obj, selected_attribute):
203
+ logger.warning(
204
+ f"Attribute '{selected_attribute}' not found in time-series component '{timeseries_component}'"
205
+ )
206
+ return [], []
207
+
208
+ ts_df = getattr(ts_obj, selected_attribute)
209
+
210
+ if isinstance(ts_df, pd.DataFrame):
211
+ # Check if dataframe exceeds maximum rows
212
+ if len(ts_df) > MAX_TABLE_ROWS:
213
+ logger.warning(
214
+ f"Time-series data for '{selected_attribute}' has {len(ts_df):,} rows, "
215
+ f"limiting to first {MAX_TABLE_ROWS:,} rows for display"
216
+ )
217
+
218
+ # Use utility function for efficient conversion with uniform sampling
219
+ data, columns = dataframe_to_datatable(ts_df, max_rows=MAX_TABLE_ROWS)
220
+ return data, columns
221
+
222
+ logger.warning(f"Time-series attribute '{selected_attribute}' is not a DataFrame")
223
+ return [], []
224
+
225
+ except Exception as e:
226
+ logger.error(f"Error loading time-series data for '{selected_attribute}': {e}")
227
+ return [], []
@@ -0,0 +1,40 @@
1
+ """Filter-related callbacks for PyPSA Explorer dashboard."""
2
+
3
+ from typing import Any
4
+
5
+ from dash import Input, Output
6
+
7
+
8
+ def register_filter_callbacks(app) -> None:
9
+ """Register filter-related callbacks."""
10
+
11
+ @app.callback(
12
+ [
13
+ Output("global-country-selector", "disabled"),
14
+ Output("global-country-selector", "value"),
15
+ ],
16
+ [Input("global-country-mode", "value")],
17
+ )
18
+ def toggle_global_country_selector(mode: str) -> tuple[bool, list[Any]]:
19
+ """Enable/disable country selector based on mode."""
20
+ if mode == "All":
21
+ return True, []
22
+ else:
23
+ return False, []
24
+
25
+ @app.callback(
26
+ [
27
+ Output("global-carrier-selector-container", "style"),
28
+ Output("carrier-not-applicable-text", "style"),
29
+ Output("active-tab-store", "data"),
30
+ ],
31
+ [Input("tabs", "value")],
32
+ )
33
+ def handle_tab_specific_ui(active_tab: str) -> tuple[dict[str, str], dict[str, str], str]:
34
+ """Handle UI elements based on the active tab."""
35
+ if active_tab in ["capex", "opex"]:
36
+ # Hide carrier selector for CAPEX/OPEX tabs
37
+ return {"display": "none"}, {"display": "block"}, active_tab
38
+ else:
39
+ # Show carrier selector for other tabs
40
+ return {"display": "block"}, {"display": "none"}, active_tab
@@ -0,0 +1,55 @@
1
+ """Navigation callbacks for PyPSA Explorer dashboard."""
2
+
3
+ from typing import cast
4
+
5
+ from dash import Input, Output, State, no_update
6
+
7
+
8
+ def register_navigation_callbacks(app) -> None:
9
+ """Register navigation-related callbacks."""
10
+
11
+ @app.callback(
12
+ [
13
+ Output("welcome-content", "style"),
14
+ Output("dashboard-content", "style"),
15
+ Output("page-state", "data"),
16
+ Output("top-bar-network-selector", "style"),
17
+ ],
18
+ [Input("enter-dashboard-btn", "n_clicks")],
19
+ [
20
+ State("page-state", "data"),
21
+ State("network-registry", "data"),
22
+ ],
23
+ )
24
+ def navigate_pages(
25
+ n_clicks: int | None,
26
+ page_state: dict[str, str],
27
+ registry: dict[str, list[str]] | None = None,
28
+ ) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]:
29
+ """Manage navigation between welcome page and dashboard."""
30
+ page_state = page_state or {}
31
+ has_network = bool(registry and registry.get("order"))
32
+ current_page = page_state.get("current_page")
33
+
34
+ if n_clicks and current_page == "welcome" and has_network:
35
+ return (
36
+ {"display": "none"},
37
+ {"display": "block"},
38
+ {"current_page": "dashboard"},
39
+ {"display": "flex"},
40
+ )
41
+ if current_page == "dashboard" and has_network:
42
+ return (
43
+ cast(dict[str, str], no_update),
44
+ cast(dict[str, str], no_update),
45
+ cast(dict[str, str], no_update),
46
+ {"display": "flex"},
47
+ )
48
+
49
+ # No networks or still on welcome page keeps selector hidden
50
+ return (
51
+ cast(dict[str, str], no_update),
52
+ cast(dict[str, str], no_update),
53
+ cast(dict[str, str], no_update),
54
+ {"display": "none"},
55
+ )