flixopt 3.0.1__py3-none-any.whl → 6.0.0rc7__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.
Files changed (42) hide show
  1. flixopt/__init__.py +57 -49
  2. flixopt/carrier.py +159 -0
  3. flixopt/clustering/__init__.py +51 -0
  4. flixopt/clustering/base.py +1746 -0
  5. flixopt/clustering/intercluster_helpers.py +201 -0
  6. flixopt/color_processing.py +372 -0
  7. flixopt/comparison.py +819 -0
  8. flixopt/components.py +848 -270
  9. flixopt/config.py +853 -496
  10. flixopt/core.py +111 -98
  11. flixopt/effects.py +294 -284
  12. flixopt/elements.py +484 -223
  13. flixopt/features.py +220 -118
  14. flixopt/flow_system.py +2026 -389
  15. flixopt/interface.py +504 -286
  16. flixopt/io.py +1718 -55
  17. flixopt/linear_converters.py +291 -230
  18. flixopt/modeling.py +304 -181
  19. flixopt/network_app.py +2 -1
  20. flixopt/optimization.py +788 -0
  21. flixopt/optimize_accessor.py +373 -0
  22. flixopt/plot_result.py +143 -0
  23. flixopt/plotting.py +1177 -1034
  24. flixopt/results.py +1331 -372
  25. flixopt/solvers.py +12 -4
  26. flixopt/statistics_accessor.py +2412 -0
  27. flixopt/stats_accessor.py +75 -0
  28. flixopt/structure.py +954 -120
  29. flixopt/topology_accessor.py +676 -0
  30. flixopt/transform_accessor.py +2277 -0
  31. flixopt/types.py +120 -0
  32. flixopt-6.0.0rc7.dist-info/METADATA +290 -0
  33. flixopt-6.0.0rc7.dist-info/RECORD +36 -0
  34. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/WHEEL +1 -1
  35. flixopt/aggregation.py +0 -382
  36. flixopt/calculation.py +0 -672
  37. flixopt/commons.py +0 -51
  38. flixopt/utils.py +0 -86
  39. flixopt-3.0.1.dist-info/METADATA +0 -209
  40. flixopt-3.0.1.dist-info/RECORD +0 -26
  41. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/licenses/LICENSE +0 -0
  42. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,2412 @@
1
+ """Statistics accessor for FlowSystem.
2
+
3
+ This module provides a user-friendly API for analyzing optimization results
4
+ directly from a FlowSystem.
5
+
6
+ Structure:
7
+ - `.statistics` - Data/metrics access (cached xarray Datasets)
8
+ - `.statistics.plot` - Plotting methods using the statistics data
9
+
10
+ Example:
11
+ >>> flow_system.optimize(solver)
12
+ >>> # Data access
13
+ >>> flow_system.statistics.flow_rates
14
+ >>> flow_system.statistics.flow_hours
15
+ >>> # Plotting
16
+ >>> flow_system.statistics.plot.balance('ElectricityBus')
17
+ >>> flow_system.statistics.plot.heatmap('Boiler|on')
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import re
24
+ from typing import TYPE_CHECKING, Any, Literal
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+ import plotly.graph_objects as go
29
+ import xarray as xr
30
+
31
+ from .color_processing import ColorType, hex_to_rgba, process_colors
32
+ from .config import CONFIG
33
+ from .plot_result import PlotResult
34
+ from .structure import VariableCategory
35
+
36
+ if TYPE_CHECKING:
37
+ from .flow_system import FlowSystem
38
+
39
+ logger = logging.getLogger('flixopt')
40
+
41
+ # Type aliases
42
+ SelectType = dict[str, Any]
43
+ """xarray-style selection dict: {'time': slice(...), 'scenario': 'base'}"""
44
+
45
+ FilterType = str | list[str]
46
+ """For include/exclude filtering: 'Boiler' or ['Boiler', 'CHP']"""
47
+
48
+
49
+ # Sankey select types with Literal keys for IDE autocomplete
50
+ FlowSankeySelect = dict[Literal['flow', 'bus', 'component', 'carrier', 'time', 'period', 'scenario'], Any]
51
+ """Select options for flow-based sankey: flow, bus, component, carrier, time, period, scenario."""
52
+
53
+ EffectsSankeySelect = dict[Literal['effect', 'component', 'contributor', 'period', 'scenario'], Any]
54
+ """Select options for effects sankey: effect, component, contributor, period, scenario."""
55
+
56
+
57
+ # Default slot assignments for plotting methods
58
+ # Use None for slots that should be blocked (prevent auto-assignment)
59
+ _SLOT_DEFAULTS: dict[str, dict[str, str | None]] = {
60
+ 'balance': {'x': 'time', 'color': 'variable', 'pattern_shape': None},
61
+ 'carrier_balance': {'x': 'time', 'color': 'variable', 'pattern_shape': None},
62
+ 'flows': {'x': 'time', 'color': 'variable', 'symbol': None},
63
+ 'charge_states': {'x': 'time', 'color': 'variable', 'symbol': None},
64
+ 'storage': {'x': 'time', 'color': 'variable', 'pattern_shape': None},
65
+ 'sizes': {'x': 'variable', 'color': 'variable'},
66
+ 'duration_curve': {'symbol': None}, # x is computed dynamically
67
+ 'effects': {}, # x is computed dynamically
68
+ 'heatmap': {},
69
+ }
70
+
71
+
72
+ def _apply_slot_defaults(plotly_kwargs: dict, method: str) -> None:
73
+ """Apply default slot assignments for a plotting method."""
74
+ defaults = _SLOT_DEFAULTS.get(method, {})
75
+ for slot, value in defaults.items():
76
+ plotly_kwargs.setdefault(slot, value)
77
+
78
+
79
+ def _reshape_time_for_heatmap(
80
+ data: xr.DataArray,
81
+ reshape: tuple[str, str],
82
+ fill: Literal['ffill', 'bfill'] | None = 'ffill',
83
+ ) -> xr.DataArray:
84
+ """Reshape time dimension into 2D (timeframe × timestep) for heatmap display.
85
+
86
+ Args:
87
+ data: DataArray with 'time' dimension.
88
+ reshape: Tuple of (outer_freq, inner_freq), e.g. ('D', 'h') for days × hours.
89
+ fill: Method to fill missing values after resampling.
90
+
91
+ Returns:
92
+ DataArray with 'time' replaced by 'timestep' and 'timeframe' dimensions.
93
+ """
94
+ if 'time' not in data.dims:
95
+ return data
96
+
97
+ timeframes, timesteps_per_frame = reshape
98
+
99
+ # Define formats for different combinations
100
+ formats = {
101
+ ('YS', 'W'): ('%Y', '%W'),
102
+ ('YS', 'D'): ('%Y', '%j'),
103
+ ('YS', 'h'): ('%Y', '%j %H:00'),
104
+ ('MS', 'D'): ('%Y-%m', '%d'),
105
+ ('MS', 'h'): ('%Y-%m', '%d %H:00'),
106
+ ('W', 'D'): ('%Y-w%W', '%w_%A'),
107
+ ('W', 'h'): ('%Y-w%W', '%w_%A %H:00'),
108
+ ('D', 'h'): ('%Y-%m-%d', '%H:00'),
109
+ ('D', '15min'): ('%Y-%m-%d', '%H:%M'),
110
+ ('h', '15min'): ('%Y-%m-%d %H:00', '%M'),
111
+ ('h', 'min'): ('%Y-%m-%d %H:00', '%M'),
112
+ }
113
+
114
+ format_pair = (timeframes, timesteps_per_frame)
115
+ if format_pair not in formats:
116
+ raise ValueError(f'{format_pair} is not a valid format. Choose from {list(formats.keys())}')
117
+ period_format, step_format = formats[format_pair]
118
+
119
+ # Resample along time dimension
120
+ resampled = data.resample(time=timesteps_per_frame).mean()
121
+
122
+ # Apply fill if specified
123
+ if fill == 'ffill':
124
+ resampled = resampled.ffill(dim='time')
125
+ elif fill == 'bfill':
126
+ resampled = resampled.bfill(dim='time')
127
+
128
+ # Create period and step labels
129
+ time_values = pd.to_datetime(resampled.coords['time'].values)
130
+ period_labels = time_values.strftime(period_format)
131
+ step_labels = time_values.strftime(step_format)
132
+
133
+ # Handle special case for weekly day format
134
+ if '%w_%A' in step_format:
135
+ step_labels = pd.Series(step_labels).replace('0_Sunday', '7_Sunday').values
136
+
137
+ # Add period and step as coordinates
138
+ resampled = resampled.assign_coords({'timeframe': ('time', period_labels), 'timestep': ('time', step_labels)})
139
+
140
+ # Convert to multi-index and unstack
141
+ resampled = resampled.set_index(time=['timeframe', 'timestep'])
142
+ result = resampled.unstack('time')
143
+
144
+ # Reorder: timestep, timeframe, then other dimensions
145
+ other_dims = [d for d in result.dims if d not in ['timestep', 'timeframe']]
146
+ return result.transpose('timestep', 'timeframe', *other_dims)
147
+
148
+
149
+ def _iter_all_traces(fig: go.Figure):
150
+ """Iterate over all traces in a figure, including animation frames.
151
+
152
+ Yields traces from fig.data first, then from each frame in fig.frames.
153
+ Useful for applying styling to all traces including those in animations.
154
+
155
+ Args:
156
+ fig: Plotly Figure.
157
+
158
+ Yields:
159
+ Each trace object from the figure.
160
+ """
161
+ yield from fig.data
162
+ for frame in getattr(fig, 'frames', []) or []:
163
+ yield from frame.data
164
+
165
+
166
+ def _style_area_as_bar(fig: go.Figure) -> None:
167
+ """Style area chart traces to look like bar charts with proper pos/neg stacking.
168
+
169
+ Iterates over all traces in fig.data and fig.frames (for animations),
170
+ setting stepped line shape, removing line borders, making fills opaque,
171
+ and assigning stackgroups based on whether values are positive or negative.
172
+
173
+ Handles faceting + animation combinations by building color and classification
174
+ maps from trace names in the base figure.
175
+
176
+ Args:
177
+ fig: Plotly Figure with area chart traces.
178
+ """
179
+ import plotly.express as px
180
+
181
+ default_colors = px.colors.qualitative.Plotly
182
+
183
+ # Build color map from base figure traces
184
+ # trace.name -> color
185
+ color_map: dict[str, str] = {}
186
+ for i, trace in enumerate(fig.data):
187
+ if hasattr(trace, 'line') and trace.line and trace.line.color:
188
+ color_map[trace.name] = trace.line.color
189
+ else:
190
+ color_map[trace.name] = default_colors[i % len(default_colors)]
191
+
192
+ # Classify traces by aggregating sign info across ALL traces (including animation frames)
193
+ # trace.name -> 'positive'|'negative'|'mixed'|'zero'
194
+ class_map: dict[str, str] = {}
195
+ sign_flags: dict[str, dict[str, bool]] = {} # trace.name -> {'has_pos': bool, 'has_neg': bool}
196
+
197
+ for trace in _iter_all_traces(fig):
198
+ if trace.name not in sign_flags:
199
+ sign_flags[trace.name] = {'has_pos': False, 'has_neg': False}
200
+
201
+ y_vals = trace.y
202
+ if y_vals is not None and len(y_vals) > 0:
203
+ y_arr = np.asarray(y_vals)
204
+ y_clean = y_arr[np.abs(y_arr) > 1e-9]
205
+ if len(y_clean) > 0:
206
+ if np.any(y_clean > 0):
207
+ sign_flags[trace.name]['has_pos'] = True
208
+ if np.any(y_clean < 0):
209
+ sign_flags[trace.name]['has_neg'] = True
210
+
211
+ # Compute class_map from aggregated sign flags
212
+ for name, flags in sign_flags.items():
213
+ has_pos, has_neg = flags['has_pos'], flags['has_neg']
214
+ if has_pos and has_neg:
215
+ class_map[name] = 'mixed'
216
+ elif has_neg:
217
+ class_map[name] = 'negative'
218
+ elif has_pos:
219
+ class_map[name] = 'positive'
220
+ else:
221
+ class_map[name] = 'zero'
222
+
223
+ def style_trace(trace: go.Scatter) -> None:
224
+ """Apply bar-like styling to a single trace."""
225
+ # Look up color by trace name
226
+ color = color_map.get(trace.name, default_colors[0])
227
+
228
+ # Look up classification
229
+ cls = class_map.get(trace.name, 'positive')
230
+
231
+ # Set stackgroup based on classification (positive and negative stack separately)
232
+ if cls in ('positive', 'negative'):
233
+ trace.stackgroup = cls
234
+ trace.fillcolor = color
235
+ trace.line = dict(width=0, color=color, shape='hv')
236
+ elif cls == 'mixed':
237
+ # Mixed: show as dashed line, no stacking
238
+ trace.stackgroup = None
239
+ trace.fill = None
240
+ trace.line = dict(width=2, color=color, shape='hv', dash='dash')
241
+ else: # zero
242
+ trace.stackgroup = None
243
+ trace.fill = None
244
+ trace.line = dict(width=0, color=color, shape='hv')
245
+
246
+ # Style all traces (main + animation frames)
247
+ for trace in _iter_all_traces(fig):
248
+ style_trace(trace)
249
+
250
+
251
+ def _apply_unified_hover(fig: go.Figure, unit: str = '', decimals: int = 1) -> None:
252
+ """Apply unified hover mode with clean formatting to any Plotly figure.
253
+
254
+ Sets up 'x unified' hovermode with spike lines and formats hover labels
255
+ as '<b>name</b>: value unit'.
256
+
257
+ Works with any plot type (area, bar, line, scatter).
258
+
259
+ Args:
260
+ fig: Plotly Figure to style.
261
+ unit: Unit string to append (e.g., 'kW', 'MWh'). Empty for no unit.
262
+ decimals: Number of decimal places for values.
263
+ """
264
+ unit_suffix = f' {unit}' if unit else ''
265
+ hover_template = f'<b>%{{fullData.name}}</b>: %{{y:.{decimals}f}}{unit_suffix}<extra></extra>'
266
+
267
+ # Apply to all traces (main + animation frames)
268
+ for trace in _iter_all_traces(fig):
269
+ trace.hovertemplate = hover_template
270
+
271
+ # Layout settings for unified hover
272
+ fig.update_layout(hovermode='x unified')
273
+ # Apply spike settings to all x-axes (for faceted plots with xaxis, xaxis2, xaxis3, etc.)
274
+ fig.update_xaxes(showspikes=True, spikecolor='gray', spikethickness=1)
275
+
276
+
277
+ # --- Helper functions ---
278
+
279
+
280
+ def _prepare_for_heatmap(
281
+ da: xr.DataArray,
282
+ reshape: tuple[str, str] | Literal['auto'] | None,
283
+ ) -> xr.DataArray:
284
+ """Prepare DataArray for heatmap: determine axes, reshape if needed, transpose/squeeze.
285
+
286
+ Args:
287
+ da: DataArray to prepare for heatmap display.
288
+ reshape: Time reshape frequencies as (outer, inner), 'auto' to auto-detect,
289
+ or None to disable reshaping.
290
+ """
291
+
292
+ def finalize(da: xr.DataArray, heatmap_dims: list[str]) -> xr.DataArray:
293
+ """Transpose, squeeze, and clear name if needed."""
294
+ other = [d for d in da.dims if d not in heatmap_dims]
295
+ da = da.transpose(*[d for d in heatmap_dims if d in da.dims], *other)
296
+ for dim in [d for d in da.dims if d not in heatmap_dims and da.sizes[d] == 1]:
297
+ da = da.squeeze(dim, drop=True)
298
+ return da.rename('') if da.sizes.get('variable', 1) > 1 else da
299
+
300
+ def fallback_dims() -> list[str]:
301
+ """Default dims: (variable, time) if multi-var, else first 2 dims with size > 1."""
302
+ if da.sizes.get('variable', 1) > 1:
303
+ return ['variable', 'time']
304
+ dims = [d for d in da.dims if da.sizes[d] > 1][:2]
305
+ return dims if len(dims) >= 2 else list(da.dims)[:2]
306
+
307
+ def can_auto_reshape() -> bool:
308
+ """Check if data is suitable for auto-reshaping (not too many non-time dims)."""
309
+ non_time_dims = [d for d in da.dims if d not in ('time', 'timestep', 'timeframe') and da.sizes[d] > 1]
310
+ # Allow reshape if we have at most 1 other dimension (can facet on it)
311
+ # Or if it's just variable dimension
312
+ return len(non_time_dims) <= 1
313
+
314
+ is_clustered = 'cluster' in da.dims and da.sizes['cluster'] > 1
315
+ has_time = 'time' in da.dims
316
+
317
+ # Clustered: use (time, cluster) as natural 2D
318
+ if is_clustered and reshape in (None, 'auto'):
319
+ return finalize(da, ['time', 'cluster'])
320
+
321
+ # Apply auto-reshape: try ('D', 'h') by default if appropriate
322
+ if reshape == 'auto' and has_time and can_auto_reshape():
323
+ try:
324
+ return finalize(_reshape_time_for_heatmap(da, ('D', 'h')), ['timestep', 'timeframe'])
325
+ except (ValueError, KeyError):
326
+ # Fall through to default dims if reshape fails
327
+ pass
328
+
329
+ # Apply explicit reshape if specified
330
+ if reshape and reshape != 'auto' and has_time:
331
+ return finalize(_reshape_time_for_heatmap(da, reshape), ['timestep', 'timeframe'])
332
+
333
+ return finalize(da, fallback_dims())
334
+
335
+
336
+ def _filter_by_pattern(
337
+ names: list[str],
338
+ include: FilterType | None,
339
+ exclude: FilterType | None,
340
+ ) -> list[str]:
341
+ """Filter names using substring matching."""
342
+ result = names.copy()
343
+ if include is not None:
344
+ patterns = [include] if isinstance(include, str) else include
345
+ result = [n for n in result if any(p in n for p in patterns)]
346
+ if exclude is not None:
347
+ patterns = [exclude] if isinstance(exclude, str) else exclude
348
+ result = [n for n in result if not any(p in n for p in patterns)]
349
+ return result
350
+
351
+
352
+ def _apply_selection(ds: xr.Dataset, select: SelectType | None, drop: bool = True) -> xr.Dataset:
353
+ """Apply xarray-style selection to dataset.
354
+
355
+ Args:
356
+ ds: Dataset to select from.
357
+ select: xarray-style selection dict.
358
+ drop: If True (default), drop dimensions that become scalar after selection.
359
+ This prevents auto-faceting when selecting a single value.
360
+ """
361
+ if select is None:
362
+ return ds
363
+ valid_select = {k: v for k, v in select.items() if k in ds.dims or k in ds.coords}
364
+ if valid_select:
365
+ ds = ds.sel(valid_select, drop=drop)
366
+ return ds
367
+
368
+
369
+ def add_line_overlay(
370
+ fig: go.Figure,
371
+ da: xr.DataArray,
372
+ *,
373
+ x: str | None = None,
374
+ facet_col: str | None = None,
375
+ facet_row: str | None = None,
376
+ animation_frame: str | None = None,
377
+ color: str | None = None,
378
+ line_color: str = 'black',
379
+ name: str | None = None,
380
+ secondary_y: bool = False,
381
+ y_title: str | None = None,
382
+ showlegend: bool = True,
383
+ ) -> None:
384
+ """Add line traces on top of existing figure, optionally on secondary y-axis.
385
+
386
+ This function creates line traces from a DataArray and adds them to an existing
387
+ figure. When using secondary_y=True, it correctly handles faceted figures by
388
+ creating matching secondary axes for each primary axis.
389
+
390
+ Args:
391
+ fig: Plotly figure to add traces to.
392
+ da: DataArray to plot as lines.
393
+ x: Dimension to use for x-axis. If None, auto-detects 'time' or first dim.
394
+ facet_col: Dimension for column facets (must match primary figure).
395
+ facet_row: Dimension for row facets (must match primary figure).
396
+ animation_frame: Dimension for animation slider (must match primary figure).
397
+ color: Dimension to color by (creates multiple lines).
398
+ line_color: Color for lines when color is None.
399
+ name: Legend name for the traces.
400
+ secondary_y: If True, plot on secondary y-axis.
401
+ y_title: Title for the y-axis (secondary if secondary_y=True).
402
+ showlegend: Whether to show legend entries.
403
+ """
404
+ if da.size == 0:
405
+ return
406
+
407
+ # Auto-detect x dimension if not specified
408
+ if x is None:
409
+ x = 'time' if 'time' in da.dims else da.dims[0]
410
+
411
+ # Build kwargs for line plot, only passing facet params if specified
412
+ line_kwargs: dict[str, Any] = {'x': x}
413
+ if color is not None:
414
+ line_kwargs['color'] = color
415
+ if facet_col is not None:
416
+ line_kwargs['facet_col'] = facet_col
417
+ if facet_row is not None:
418
+ line_kwargs['facet_row'] = facet_row
419
+ if animation_frame is not None:
420
+ line_kwargs['animation_frame'] = animation_frame
421
+
422
+ # Create line figure with same facets
423
+ line_fig = da.plotly.line(**line_kwargs)
424
+
425
+ if secondary_y:
426
+ # Get the primary y-axes from the bar figure to create matching secondary axes
427
+ primary_yaxes = [key for key in fig.layout if key.startswith('yaxis')]
428
+
429
+ # For each primary y-axis, create a secondary y-axis.
430
+ # Secondary axis numbering strategy:
431
+ # - Primary axes are named 'yaxis', 'yaxis2', 'yaxis3', etc.
432
+ # - We use +100 offset (yaxis101, yaxis102, ...) to avoid conflicts
433
+ # - Each secondary axis 'overlays' its corresponding primary axis
434
+ for i, primary_key in enumerate(sorted(primary_yaxes, key=lambda x: int(x[5:]) if x[5:] else 0)):
435
+ primary_num = primary_key[5:] if primary_key[5:] else '1'
436
+ secondary_num = int(primary_num) + 100
437
+ secondary_key = f'yaxis{secondary_num}'
438
+ secondary_anchor = f'x{primary_num}' if primary_num != '1' else 'x'
439
+
440
+ fig.layout[secondary_key] = dict(
441
+ overlaying=f'y{primary_num}' if primary_num != '1' else 'y',
442
+ side='right',
443
+ showgrid=False,
444
+ title=y_title if i == len(primary_yaxes) - 1 else None,
445
+ anchor=secondary_anchor,
446
+ )
447
+
448
+ # Add line traces with correct axis assignments
449
+ for i, trace in enumerate(line_fig.data):
450
+ if name is not None:
451
+ trace.name = name
452
+ if color is None:
453
+ trace.line = dict(color=line_color, width=2)
454
+
455
+ if secondary_y:
456
+ primary_num = i + 1 if i > 0 else 1
457
+ trace.yaxis = f'y{primary_num + 100}'
458
+
459
+ trace.showlegend = showlegend and (i == 0)
460
+ if name is not None:
461
+ trace.legendgroup = name
462
+ fig.add_trace(trace)
463
+
464
+
465
+ def _filter_by_carrier(ds: xr.Dataset, carrier: str | list[str] | None) -> xr.Dataset:
466
+ """Filter dataset variables by carrier attribute.
467
+
468
+ Args:
469
+ ds: Dataset with variables that have 'carrier' attributes.
470
+ carrier: Carrier name(s) to keep. None means no filtering.
471
+
472
+ Returns:
473
+ Dataset containing only variables matching the carrier(s).
474
+ """
475
+ if carrier is None:
476
+ return ds
477
+
478
+ carriers = [carrier] if isinstance(carrier, str) else carrier
479
+ carriers = [c.lower() for c in carriers]
480
+
481
+ matching_vars = [var for var in ds.data_vars if ds[var].attrs.get('carrier', '').lower() in carriers]
482
+ return ds[matching_vars] if matching_vars else xr.Dataset()
483
+
484
+
485
+ def _dataset_to_long_df(ds: xr.Dataset, value_name: str = 'value', var_name: str = 'variable') -> pd.DataFrame:
486
+ """Convert xarray Dataset to long-form DataFrame for plotly express."""
487
+ if not ds.data_vars:
488
+ return pd.DataFrame()
489
+ if all(ds[var].ndim == 0 for var in ds.data_vars):
490
+ rows = [{var_name: var, value_name: float(ds[var].values)} for var in ds.data_vars]
491
+ return pd.DataFrame(rows)
492
+ df = ds.to_dataframe().reset_index()
493
+ # Only use coordinates that are actually present as columns after reset_index
494
+ coord_cols = [c for c in ds.coords.keys() if c in df.columns]
495
+ return df.melt(id_vars=coord_cols, var_name=var_name, value_name=value_name)
496
+
497
+
498
+ def _build_color_kwargs(colors: ColorType | None, labels: list[str]) -> dict[str, Any]:
499
+ """Build color kwargs for plotly based on color type.
500
+
501
+ Args:
502
+ colors: Dict (color_discrete_map), list (color_discrete_sequence),
503
+ or string (colorscale name to convert to dict).
504
+ labels: Variable labels for creating dict from colorscale name.
505
+
506
+ Returns:
507
+ Dict with either 'color_discrete_map' or 'color_discrete_sequence'.
508
+ """
509
+ if colors is None:
510
+ return {}
511
+ if isinstance(colors, dict):
512
+ return {'color_discrete_map': colors}
513
+ if isinstance(colors, list):
514
+ return {'color_discrete_sequence': colors}
515
+ if isinstance(colors, str):
516
+ return {'color_discrete_map': process_colors(colors, labels)}
517
+ return {}
518
+
519
+
520
+ # --- Statistics Accessor (data only) ---
521
+
522
+
523
+ class StatisticsAccessor:
524
+ """Statistics accessor for FlowSystem. Access via ``flow_system.statistics``.
525
+
526
+ This accessor provides cached data properties for optimization results.
527
+ Use ``.plot`` for visualization methods.
528
+
529
+ Data Properties:
530
+ ``flow_rates`` : xr.Dataset
531
+ Flow rates for all flows.
532
+ ``flow_hours`` : xr.Dataset
533
+ Flow hours (energy) for all flows.
534
+ ``sizes`` : xr.Dataset
535
+ Sizes for all flows.
536
+ ``charge_states`` : xr.Dataset
537
+ Charge states for all storage components.
538
+ ``temporal_effects`` : xr.Dataset
539
+ Temporal effects per contributor per timestep.
540
+ ``periodic_effects`` : xr.Dataset
541
+ Periodic (investment) effects per contributor.
542
+ ``total_effects`` : xr.Dataset
543
+ Total effects (temporal + periodic) per contributor.
544
+ ``effect_share_factors`` : dict
545
+ Conversion factors between effects.
546
+
547
+ Examples:
548
+ >>> flow_system.optimize(solver)
549
+ >>> flow_system.statistics.flow_rates # Get data
550
+ >>> flow_system.statistics.plot.balance('Bus') # Plot
551
+ """
552
+
553
+ def __init__(self, flow_system: FlowSystem) -> None:
554
+ self._fs = flow_system
555
+ # Cached data
556
+ self._flow_rates: xr.Dataset | None = None
557
+ self._flow_hours: xr.Dataset | None = None
558
+ self._flow_sizes: xr.Dataset | None = None
559
+ self._storage_sizes: xr.Dataset | None = None
560
+ self._sizes: xr.Dataset | None = None
561
+ self._charge_states: xr.Dataset | None = None
562
+ self._effect_share_factors: dict[str, dict] | None = None
563
+ self._temporal_effects: xr.Dataset | None = None
564
+ self._periodic_effects: xr.Dataset | None = None
565
+ self._total_effects: xr.Dataset | None = None
566
+ # Plotting accessor (lazy)
567
+ self._plot: StatisticsPlotAccessor | None = None
568
+
569
+ def _require_solution(self) -> xr.Dataset:
570
+ """Get solution, raising if not available."""
571
+ if self._fs.solution is None:
572
+ raise RuntimeError('FlowSystem has no solution. Run optimize() or solve() first.')
573
+ return self._fs.solution
574
+
575
+ @property
576
+ def carrier_colors(self) -> dict[str, str]:
577
+ """Cached mapping of carrier name to color.
578
+
579
+ Delegates to topology accessor for centralized color caching.
580
+
581
+ Returns:
582
+ Dict mapping carrier names (lowercase) to hex color strings.
583
+ """
584
+ return self._fs.topology.carrier_colors
585
+
586
+ @property
587
+ def component_colors(self) -> dict[str, str]:
588
+ """Cached mapping of component label to color.
589
+
590
+ Delegates to topology accessor for centralized color caching.
591
+
592
+ Returns:
593
+ Dict mapping component labels to hex color strings.
594
+ """
595
+ return self._fs.topology.component_colors
596
+
597
+ @property
598
+ def bus_colors(self) -> dict[str, str]:
599
+ """Cached mapping of bus label to color (from carrier).
600
+
601
+ Delegates to topology accessor for centralized color caching.
602
+
603
+ Returns:
604
+ Dict mapping bus labels to hex color strings.
605
+ """
606
+ return self._fs.topology.bus_colors
607
+
608
+ @property
609
+ def carrier_units(self) -> dict[str, str]:
610
+ """Cached mapping of carrier name to unit string.
611
+
612
+ Delegates to topology accessor for centralized unit caching.
613
+
614
+ Returns:
615
+ Dict mapping carrier names (lowercase) to unit strings.
616
+ """
617
+ return self._fs.topology.carrier_units
618
+
619
+ @property
620
+ def effect_units(self) -> dict[str, str]:
621
+ """Cached mapping of effect label to unit string.
622
+
623
+ Delegates to topology accessor for centralized unit caching.
624
+
625
+ Returns:
626
+ Dict mapping effect labels to unit strings.
627
+ """
628
+ return self._fs.topology.effect_units
629
+
630
+ @property
631
+ def plot(self) -> StatisticsPlotAccessor:
632
+ """Access plotting methods for statistics.
633
+
634
+ Returns:
635
+ A StatisticsPlotAccessor instance.
636
+
637
+ Examples:
638
+ >>> flow_system.statistics.plot.balance('ElectricityBus')
639
+ >>> flow_system.statistics.plot.heatmap('Boiler|on')
640
+ """
641
+ if self._plot is None:
642
+ self._plot = StatisticsPlotAccessor(self)
643
+ return self._plot
644
+
645
+ @property
646
+ def flow_rates(self) -> xr.Dataset:
647
+ """All flow rates as a Dataset with flow labels as variable names.
648
+
649
+ Each variable has attributes:
650
+ - 'carrier': carrier type (e.g., 'heat', 'electricity', 'gas')
651
+ - 'unit': carrier unit (e.g., 'kW')
652
+ """
653
+ self._require_solution()
654
+ if self._flow_rates is None:
655
+ flow_rate_vars = self._fs.get_variables_by_category(VariableCategory.FLOW_RATE)
656
+ flow_carriers = self._fs.flow_carriers # Cached lookup
657
+ carrier_units = self.carrier_units # Cached lookup
658
+ data_vars = {}
659
+ for v in flow_rate_vars:
660
+ flow_label = v.rsplit('|', 1)[0] # Extract label from 'label|flow_rate'
661
+ da = self._fs.solution[v].copy()
662
+ # Add carrier and unit as attributes
663
+ carrier = flow_carriers.get(flow_label)
664
+ da.attrs['carrier'] = carrier
665
+ da.attrs['unit'] = carrier_units.get(carrier, '') if carrier else ''
666
+ data_vars[flow_label] = da
667
+ self._flow_rates = xr.Dataset(data_vars)
668
+ return self._flow_rates
669
+
670
+ @property
671
+ def flow_hours(self) -> xr.Dataset:
672
+ """All flow hours (energy) as a Dataset with flow labels as variable names.
673
+
674
+ Each variable has attributes:
675
+ - 'carrier': carrier type (e.g., 'heat', 'electricity', 'gas')
676
+ - 'unit': energy unit (e.g., 'kWh', 'm3/s*h')
677
+ """
678
+ self._require_solution()
679
+ if self._flow_hours is None:
680
+ hours = self._fs.timestep_duration
681
+ flow_rates = self.flow_rates
682
+ # Multiply and preserve/transform attributes
683
+ data_vars = {}
684
+ for var in flow_rates.data_vars:
685
+ da = flow_rates[var] * hours
686
+ da.attrs['carrier'] = flow_rates[var].attrs.get('carrier')
687
+ # Convert power unit to energy unit (e.g., 'kW' -> 'kWh', 'm3/s' -> 'm3/s*h')
688
+ power_unit = flow_rates[var].attrs.get('unit', '')
689
+ da.attrs['unit'] = f'{power_unit}*h' if power_unit else ''
690
+ data_vars[var] = da
691
+ self._flow_hours = xr.Dataset(data_vars)
692
+ return self._flow_hours
693
+
694
+ @property
695
+ def flow_sizes(self) -> xr.Dataset:
696
+ """Flow sizes as a Dataset with flow labels as variable names."""
697
+ self._require_solution()
698
+ if self._flow_sizes is None:
699
+ flow_size_vars = self._fs.get_variables_by_category(VariableCategory.FLOW_SIZE)
700
+ self._flow_sizes = xr.Dataset({v.rsplit('|', 1)[0]: self._fs.solution[v] for v in flow_size_vars})
701
+ return self._flow_sizes
702
+
703
+ @property
704
+ def storage_sizes(self) -> xr.Dataset:
705
+ """Storage capacity sizes as a Dataset with storage labels as variable names."""
706
+ self._require_solution()
707
+ if self._storage_sizes is None:
708
+ storage_size_vars = self._fs.get_variables_by_category(VariableCategory.STORAGE_SIZE)
709
+ self._storage_sizes = xr.Dataset({v.rsplit('|', 1)[0]: self._fs.solution[v] for v in storage_size_vars})
710
+ return self._storage_sizes
711
+
712
+ @property
713
+ def sizes(self) -> xr.Dataset:
714
+ """All investment sizes (flows and storage capacities) as a Dataset."""
715
+ if self._sizes is None:
716
+ self._sizes = xr.merge([self.flow_sizes, self.storage_sizes])
717
+ return self._sizes
718
+
719
+ @property
720
+ def charge_states(self) -> xr.Dataset:
721
+ """All storage charge states as a Dataset with storage labels as variable names."""
722
+ self._require_solution()
723
+ if self._charge_states is None:
724
+ charge_vars = self._fs.get_variables_by_category(VariableCategory.CHARGE_STATE)
725
+ self._charge_states = xr.Dataset({v.rsplit('|', 1)[0]: self._fs.solution[v] for v in charge_vars})
726
+ return self._charge_states
727
+
728
+ @property
729
+ def effect_share_factors(self) -> dict[str, dict]:
730
+ """Effect share factors for temporal and periodic modes.
731
+
732
+ Returns:
733
+ Dict with 'temporal' and 'periodic' keys, each containing
734
+ conversion factors between effects.
735
+ """
736
+ self._require_solution()
737
+ if self._effect_share_factors is None:
738
+ factors = self._fs.effects.calculate_effect_share_factors()
739
+ self._effect_share_factors = {'temporal': factors[0], 'periodic': factors[1]}
740
+ return self._effect_share_factors
741
+
742
+ @property
743
+ def temporal_effects(self) -> xr.Dataset:
744
+ """Temporal effects per contributor per timestep.
745
+
746
+ Returns a Dataset where each effect is a data variable with dimensions
747
+ [time, contributor] (plus period/scenario if present).
748
+
749
+ Coordinates:
750
+ - contributor: Individual contributor labels
751
+ - component: Parent component label for groupby operations
752
+ - component_type: Component type (e.g., 'Boiler', 'Source', 'Sink')
753
+
754
+ Examples:
755
+ >>> # Get costs per contributor per timestep
756
+ >>> statistics.temporal_effects['costs']
757
+ >>> # Sum over all contributors to get total costs per timestep
758
+ >>> statistics.temporal_effects['costs'].sum('contributor')
759
+ >>> # Group by component
760
+ >>> statistics.temporal_effects['costs'].groupby('component').sum()
761
+
762
+ Returns:
763
+ xr.Dataset with effects as variables and contributor dimension.
764
+ """
765
+ self._require_solution()
766
+ if self._temporal_effects is None:
767
+ ds = self._create_effects_dataset('temporal')
768
+ dim_order = ['time', 'period', 'scenario', 'contributor']
769
+ self._temporal_effects = ds.transpose(*dim_order, missing_dims='ignore')
770
+ return self._temporal_effects
771
+
772
+ @property
773
+ def periodic_effects(self) -> xr.Dataset:
774
+ """Periodic (investment) effects per contributor.
775
+
776
+ Returns a Dataset where each effect is a data variable with dimensions
777
+ [contributor] (plus period/scenario if present).
778
+
779
+ Coordinates:
780
+ - contributor: Individual contributor labels
781
+ - component: Parent component label for groupby operations
782
+ - component_type: Component type (e.g., 'Boiler', 'Source', 'Sink')
783
+
784
+ Examples:
785
+ >>> # Get investment costs per contributor
786
+ >>> statistics.periodic_effects['costs']
787
+ >>> # Sum over all contributors to get total investment costs
788
+ >>> statistics.periodic_effects['costs'].sum('contributor')
789
+ >>> # Group by component
790
+ >>> statistics.periodic_effects['costs'].groupby('component').sum()
791
+
792
+ Returns:
793
+ xr.Dataset with effects as variables and contributor dimension.
794
+ """
795
+ self._require_solution()
796
+ if self._periodic_effects is None:
797
+ ds = self._create_effects_dataset('periodic')
798
+ dim_order = ['period', 'scenario', 'contributor']
799
+ self._periodic_effects = ds.transpose(*dim_order, missing_dims='ignore')
800
+ return self._periodic_effects
801
+
802
+ @property
803
+ def total_effects(self) -> xr.Dataset:
804
+ """Total effects (temporal + periodic) per contributor.
805
+
806
+ Returns a Dataset where each effect is a data variable with dimensions
807
+ [contributor] (plus period/scenario if present).
808
+
809
+ Coordinates:
810
+ - contributor: Individual contributor labels
811
+ - component: Parent component label for groupby operations
812
+ - component_type: Component type (e.g., 'Boiler', 'Source', 'Sink')
813
+
814
+ Examples:
815
+ >>> # Get total costs per contributor
816
+ >>> statistics.total_effects['costs']
817
+ >>> # Sum over all contributors to get total system costs
818
+ >>> statistics.total_effects['costs'].sum('contributor')
819
+ >>> # Group by component
820
+ >>> statistics.total_effects['costs'].groupby('component').sum()
821
+ >>> # Group by component type
822
+ >>> statistics.total_effects['costs'].groupby('component_type').sum()
823
+
824
+ Returns:
825
+ xr.Dataset with effects as variables and contributor dimension.
826
+ """
827
+ self._require_solution()
828
+ if self._total_effects is None:
829
+ ds = self._create_effects_dataset('total')
830
+ dim_order = ['period', 'scenario', 'contributor']
831
+ self._total_effects = ds.transpose(*dim_order, missing_dims='ignore')
832
+ return self._total_effects
833
+
834
+ def get_effect_shares(
835
+ self,
836
+ element: str,
837
+ effect: str,
838
+ mode: Literal['temporal', 'periodic'] | None = None,
839
+ include_flows: bool = False,
840
+ ) -> xr.Dataset:
841
+ """Retrieve individual effect shares for a specific element and effect.
842
+
843
+ Args:
844
+ element: The element identifier (component or flow label).
845
+ effect: The effect identifier.
846
+ mode: 'temporal', 'periodic', or None for both.
847
+ include_flows: Whether to include effects from flows connected to this element.
848
+
849
+ Returns:
850
+ xr.Dataset containing the requested effect shares.
851
+
852
+ Raises:
853
+ ValueError: If the effect is not available or mode is invalid.
854
+ """
855
+ self._require_solution()
856
+
857
+ if effect not in self._fs.effects:
858
+ raise ValueError(f'Effect {effect} is not available.')
859
+
860
+ if mode is None:
861
+ return xr.merge(
862
+ [
863
+ self.get_effect_shares(
864
+ element=element, effect=effect, mode='temporal', include_flows=include_flows
865
+ ),
866
+ self.get_effect_shares(
867
+ element=element, effect=effect, mode='periodic', include_flows=include_flows
868
+ ),
869
+ ]
870
+ )
871
+
872
+ if mode not in ['temporal', 'periodic']:
873
+ raise ValueError(f'Mode {mode} is not available. Choose between "temporal" and "periodic".')
874
+
875
+ ds = xr.Dataset()
876
+ label = f'{element}->{effect}({mode})'
877
+ if label in self._fs.solution:
878
+ ds = xr.Dataset({label: self._fs.solution[label]})
879
+
880
+ if include_flows:
881
+ if element not in self._fs.components:
882
+ raise ValueError(f'Only use Components when retrieving Effects including flows. Got {element}')
883
+ comp = self._fs.components[element]
884
+ flows = [f.label_full.split('|')[0] for f in comp.inputs + comp.outputs]
885
+ return xr.merge(
886
+ [ds]
887
+ + [
888
+ self.get_effect_shares(element=flow, effect=effect, mode=mode, include_flows=False)
889
+ for flow in flows
890
+ ]
891
+ )
892
+
893
+ return ds
894
+
895
+ def _create_template_for_mode(self, mode: Literal['temporal', 'periodic', 'total']) -> xr.DataArray:
896
+ """Create a template DataArray with the correct dimensions for a given mode."""
897
+ coords = {}
898
+ if mode == 'temporal':
899
+ # Use solution's time coordinates if available (handles expanded solutions with extra timestep)
900
+ solution = self._fs.solution
901
+ if solution is not None and 'time' in solution.dims:
902
+ coords['time'] = solution.coords['time'].values
903
+ else:
904
+ coords['time'] = self._fs.timesteps
905
+ if self._fs.periods is not None:
906
+ coords['period'] = self._fs.periods
907
+ if self._fs.scenarios is not None:
908
+ coords['scenario'] = self._fs.scenarios
909
+
910
+ if coords:
911
+ shape = tuple(len(coords[dim]) for dim in coords)
912
+ return xr.DataArray(np.full(shape, np.nan, dtype=float), coords=coords, dims=list(coords.keys()))
913
+ else:
914
+ return xr.DataArray(np.nan)
915
+
916
+ def _create_effects_dataset(self, mode: Literal['temporal', 'periodic', 'total']) -> xr.Dataset:
917
+ """Create dataset containing effect totals for all contributors.
918
+
919
+ Detects contributors (flows, components, etc.) from solution data variables.
920
+ Excludes effect-to-effect shares which are intermediate conversions.
921
+ Provides component and component_type coordinates for flexible groupby operations.
922
+ """
923
+ solution = self._fs.solution
924
+ template = self._create_template_for_mode(mode)
925
+
926
+ # Detect contributors from solution data variables
927
+ # Pattern: {contributor}->{effect}(temporal) or {contributor}->{effect}(periodic)
928
+ contributor_pattern = re.compile(r'^(.+)->(.+)\((temporal|periodic)\)$')
929
+ effect_labels = set(self._fs.effects.keys())
930
+
931
+ detected_contributors: set[str] = set()
932
+ for var in solution.data_vars:
933
+ match = contributor_pattern.match(str(var))
934
+ if match:
935
+ contributor = match.group(1)
936
+ # Exclude effect-to-effect shares (e.g., costs(temporal) -> Effect1(temporal))
937
+ base_name = contributor.split('(')[0] if '(' in contributor else contributor
938
+ if base_name not in effect_labels:
939
+ detected_contributors.add(contributor)
940
+
941
+ contributors = sorted(detected_contributors)
942
+
943
+ # Build metadata for each contributor
944
+ def get_parent_component(contributor: str) -> str:
945
+ if contributor in self._fs.flows:
946
+ return self._fs.flows[contributor].component
947
+ elif contributor in self._fs.components:
948
+ return contributor
949
+ return contributor
950
+
951
+ def get_contributor_type(contributor: str) -> str:
952
+ if contributor in self._fs.flows:
953
+ parent = self._fs.flows[contributor].component
954
+ return type(self._fs.components[parent]).__name__
955
+ elif contributor in self._fs.components:
956
+ return type(self._fs.components[contributor]).__name__
957
+ elif contributor in self._fs.buses:
958
+ return type(self._fs.buses[contributor]).__name__
959
+ return 'Unknown'
960
+
961
+ parents = [get_parent_component(c) for c in contributors]
962
+ contributor_types = [get_contributor_type(c) for c in contributors]
963
+
964
+ # Determine modes to process
965
+ modes_to_process = ['temporal', 'periodic'] if mode == 'total' else [mode]
966
+
967
+ ds = xr.Dataset()
968
+
969
+ for effect in self._fs.effects:
970
+ contributor_arrays = []
971
+
972
+ for contributor in contributors:
973
+ share_total: xr.DataArray | None = None
974
+
975
+ for current_mode in modes_to_process:
976
+ # Get conversion factors: which source effects contribute to this target effect
977
+ conversion_factors = {
978
+ key[0]: value
979
+ for key, value in self.effect_share_factors[current_mode].items()
980
+ if key[1] == effect
981
+ }
982
+ conversion_factors[effect] = 1 # Direct contribution
983
+
984
+ for source_effect, factor in conversion_factors.items():
985
+ label = f'{contributor}->{source_effect}({current_mode})'
986
+ if label in solution:
987
+ da = solution[label] * factor
988
+ # For total mode, sum temporal over time (apply cluster_weight for proper weighting)
989
+ # Sum over all temporal dimensions (time, and cluster if present)
990
+ if mode == 'total' and current_mode == 'temporal' and 'time' in da.dims:
991
+ weighted = da * self._fs.weights.get('cluster', 1.0)
992
+ temporal_dims = [d for d in weighted.dims if d not in ('period', 'scenario')]
993
+ da = weighted.sum(temporal_dims)
994
+ if share_total is None:
995
+ share_total = da
996
+ else:
997
+ share_total = share_total + da
998
+
999
+ # If no share found, use NaN template
1000
+ if share_total is None:
1001
+ share_total = xr.full_like(template, np.nan, dtype=float)
1002
+
1003
+ contributor_arrays.append(share_total.expand_dims(contributor=[contributor]))
1004
+
1005
+ # Concatenate all contributors for this effect
1006
+ da = xr.concat(contributor_arrays, dim='contributor', coords='minimal', join='outer').rename(effect)
1007
+ # Add unit attribute from effect definition
1008
+ da.attrs['unit'] = self.effect_units.get(effect, '')
1009
+ ds[effect] = da
1010
+
1011
+ # Add groupby coordinates for contributor dimension
1012
+ ds = ds.assign_coords(
1013
+ component=('contributor', parents),
1014
+ component_type=('contributor', contributor_types),
1015
+ )
1016
+
1017
+ # Validation: check totals match solution
1018
+ suffix_map = {'temporal': '(temporal)|per_timestep', 'periodic': '(periodic)', 'total': ''}
1019
+ for effect in self._fs.effects:
1020
+ label = f'{effect}{suffix_map[mode]}'
1021
+ if label in solution:
1022
+ computed = ds[effect].sum('contributor')
1023
+ found = solution[label]
1024
+ if not np.allclose(computed.fillna(0).values, found.fillna(0).values, equal_nan=True):
1025
+ logger.critical(
1026
+ f'Results for {effect}({mode}) in effects_dataset doesnt match {label}\n{computed=}\n, {found=}'
1027
+ )
1028
+
1029
+ return ds
1030
+
1031
+
1032
+ # --- Sankey Plot Accessor ---
1033
+
1034
+
1035
+ class SankeyPlotAccessor:
1036
+ """Sankey diagram accessor. Access via ``flow_system.statistics.plot.sankey``.
1037
+
1038
+ Provides typed methods for different sankey diagram types.
1039
+
1040
+ Examples:
1041
+ >>> fs.statistics.plot.sankey.flows(select={'bus': 'HeatBus'})
1042
+ >>> fs.statistics.plot.sankey.effects(select={'effect': 'costs'})
1043
+ >>> fs.statistics.plot.sankey.sizes(select={'component': 'Boiler'})
1044
+ """
1045
+
1046
+ def __init__(self, plot_accessor: StatisticsPlotAccessor) -> None:
1047
+ self._plot = plot_accessor
1048
+ self._stats = plot_accessor._stats
1049
+ self._fs = plot_accessor._fs
1050
+
1051
+ def _extract_flow_filters(
1052
+ self, select: FlowSankeySelect | None
1053
+ ) -> tuple[SelectType | None, list[str] | None, list[str] | None, list[str] | None, list[str] | None]:
1054
+ """Extract special filters from select dict.
1055
+
1056
+ Returns:
1057
+ Tuple of (xarray_select, flow_filter, bus_filter, component_filter, carrier_filter).
1058
+ """
1059
+ if select is None:
1060
+ return None, None, None, None, None
1061
+
1062
+ select = dict(select) # Copy to avoid mutating original
1063
+ flow_filter = select.pop('flow', None)
1064
+ bus_filter = select.pop('bus', None)
1065
+ component_filter = select.pop('component', None)
1066
+ carrier_filter = select.pop('carrier', None)
1067
+
1068
+ # Normalize to lists
1069
+ if isinstance(flow_filter, str):
1070
+ flow_filter = [flow_filter]
1071
+ if isinstance(bus_filter, str):
1072
+ bus_filter = [bus_filter]
1073
+ if isinstance(component_filter, str):
1074
+ component_filter = [component_filter]
1075
+ if isinstance(carrier_filter, str):
1076
+ carrier_filter = [carrier_filter]
1077
+
1078
+ return select if select else None, flow_filter, bus_filter, component_filter, carrier_filter
1079
+
1080
+ def _build_flow_links(
1081
+ self,
1082
+ ds: xr.Dataset,
1083
+ flow_filter: list[str] | None = None,
1084
+ bus_filter: list[str] | None = None,
1085
+ component_filter: list[str] | None = None,
1086
+ carrier_filter: list[str] | None = None,
1087
+ min_value: float = 1e-6,
1088
+ ) -> tuple[set[str], dict[str, list]]:
1089
+ """Build Sankey nodes and links from flow data."""
1090
+ nodes: set[str] = set()
1091
+ links: dict[str, list] = {'source': [], 'target': [], 'value': [], 'label': [], 'carrier': []}
1092
+
1093
+ # Normalize carrier filter to lowercase
1094
+ if carrier_filter is not None:
1095
+ carrier_filter = [c.lower() for c in carrier_filter]
1096
+
1097
+ # Use flow_rates to get carrier names from xarray attributes (already computed)
1098
+ flow_rates = self._stats.flow_rates
1099
+
1100
+ for flow in self._fs.flows.values():
1101
+ label = flow.label_full
1102
+ if label not in ds:
1103
+ continue
1104
+
1105
+ # Apply filters
1106
+ if flow_filter is not None and label not in flow_filter:
1107
+ continue
1108
+ bus_label = flow.bus
1109
+ comp_label = flow.component
1110
+ if bus_filter is not None and bus_label not in bus_filter:
1111
+ continue
1112
+
1113
+ # Get carrier name from flow_rates xarray attribute (efficient lookup)
1114
+ carrier_name = flow_rates[label].attrs.get('carrier') if label in flow_rates else None
1115
+
1116
+ if carrier_filter is not None:
1117
+ if carrier_name is None or carrier_name.lower() not in carrier_filter:
1118
+ continue
1119
+ if component_filter is not None and comp_label not in component_filter:
1120
+ continue
1121
+
1122
+ value = float(ds[label].values)
1123
+ if abs(value) < min_value:
1124
+ continue
1125
+
1126
+ if flow.is_input_in_component:
1127
+ source, target = bus_label, comp_label
1128
+ else:
1129
+ source, target = comp_label, bus_label
1130
+
1131
+ nodes.add(source)
1132
+ nodes.add(target)
1133
+ links['source'].append(source)
1134
+ links['target'].append(target)
1135
+ links['value'].append(abs(value))
1136
+ links['label'].append(label)
1137
+ links['carrier'].append(carrier_name)
1138
+
1139
+ return nodes, links
1140
+
1141
+ def _create_figure(
1142
+ self,
1143
+ nodes: set[str],
1144
+ links: dict[str, list],
1145
+ colors: ColorType | None,
1146
+ title: str,
1147
+ **plotly_kwargs: Any,
1148
+ ) -> go.Figure:
1149
+ """Create Plotly Sankey figure."""
1150
+ node_list = list(nodes)
1151
+ node_indices = {n: i for i, n in enumerate(node_list)}
1152
+
1153
+ # Build node colors: buses use carrier colors, components use process_colors
1154
+ node_colors = self._get_node_colors(node_list, colors)
1155
+
1156
+ # Build link colors from carrier colors (subtle/semi-transparent)
1157
+ link_colors = self._get_link_colors(links.get('carrier', []))
1158
+
1159
+ link_dict: dict[str, Any] = dict(
1160
+ source=[node_indices[s] for s in links['source']],
1161
+ target=[node_indices[t] for t in links['target']],
1162
+ value=links['value'],
1163
+ label=links['label'],
1164
+ )
1165
+ if link_colors:
1166
+ link_dict['color'] = link_colors
1167
+
1168
+ fig = go.Figure(
1169
+ data=[
1170
+ go.Sankey(
1171
+ node=dict(
1172
+ pad=15, thickness=20, line=dict(color='black', width=0.5), label=node_list, color=node_colors
1173
+ ),
1174
+ link=link_dict,
1175
+ )
1176
+ ]
1177
+ )
1178
+ fig.update_layout(title=title, **plotly_kwargs)
1179
+ return fig
1180
+
1181
+ def _get_node_colors(self, node_list: list[str], colors: ColorType | None) -> list[str]:
1182
+ """Get colors for nodes: buses use bus_colors, components use component_colors."""
1183
+ # Get cached colors
1184
+ bus_colors = self._stats.bus_colors
1185
+ component_colors = self._stats.component_colors
1186
+
1187
+ # Get fallback colors for nodes without explicit colors
1188
+ uncolored = [n for n in node_list if n not in bus_colors and n not in component_colors]
1189
+ fallback_colors = process_colors(colors, uncolored) if uncolored else {}
1190
+
1191
+ node_colors = []
1192
+ for node in node_list:
1193
+ if node in bus_colors:
1194
+ node_colors.append(bus_colors[node])
1195
+ elif node in component_colors:
1196
+ node_colors.append(component_colors[node])
1197
+ else:
1198
+ node_colors.append(fallback_colors[node])
1199
+
1200
+ return node_colors
1201
+
1202
+ def _get_link_colors(self, carriers: list[str | None]) -> list[str]:
1203
+ """Get subtle/semi-transparent colors for links based on their carriers."""
1204
+ if not carriers:
1205
+ return []
1206
+
1207
+ # Use cached carrier colors for efficiency
1208
+ carrier_colors = self._stats.carrier_colors
1209
+
1210
+ link_colors = []
1211
+ for carrier_name in carriers:
1212
+ hex_color = carrier_colors.get(carrier_name.lower()) if carrier_name else None
1213
+ link_colors.append(hex_to_rgba(hex_color, alpha=0.4) if hex_color else hex_to_rgba('', alpha=0.4))
1214
+
1215
+ return link_colors
1216
+
1217
+ def _finalize(self, fig: go.Figure, links: dict[str, list], show: bool | None) -> PlotResult:
1218
+ """Create PlotResult and optionally show figure."""
1219
+ coords: dict[str, Any] = {
1220
+ 'link': range(len(links['value'])),
1221
+ 'source': ('link', links['source']),
1222
+ 'target': ('link', links['target']),
1223
+ 'label': ('link', links['label']),
1224
+ }
1225
+ # Add carrier if present
1226
+ if 'carrier' in links:
1227
+ coords['carrier'] = ('link', links['carrier'])
1228
+
1229
+ sankey_ds = xr.Dataset({'value': ('link', links['value'])}, coords=coords)
1230
+
1231
+ if show is None:
1232
+ show = CONFIG.Plotting.default_show
1233
+ if show:
1234
+ fig.show()
1235
+
1236
+ return PlotResult(data=sankey_ds, figure=fig)
1237
+
1238
+ def flows(
1239
+ self,
1240
+ *,
1241
+ aggregate: Literal['sum', 'mean'] = 'sum',
1242
+ select: FlowSankeySelect | None = None,
1243
+ colors: ColorType | None = None,
1244
+ show: bool | None = None,
1245
+ **plotly_kwargs: Any,
1246
+ ) -> PlotResult:
1247
+ """Plot Sankey diagram of energy/material flow amounts.
1248
+
1249
+ Args:
1250
+ aggregate: How to aggregate over time ('sum' or 'mean').
1251
+ select: Filter options:
1252
+ - flow: filter by flow label (e.g., 'Boiler|Q_th')
1253
+ - bus: filter by bus label (e.g., 'HeatBus')
1254
+ - component: filter by component label (e.g., 'Boiler')
1255
+ - time: select specific time (e.g., 100 or '2023-01-01')
1256
+ - period, scenario: xarray dimension selection
1257
+ colors: Color specification for nodes.
1258
+ show: Whether to display the figure.
1259
+ **plotly_kwargs: Additional arguments passed to Plotly layout.
1260
+
1261
+ Returns:
1262
+ PlotResult with Sankey flow data and figure.
1263
+ """
1264
+ self._stats._require_solution()
1265
+ xr_select, flow_filter, bus_filter, component_filter, carrier_filter = self._extract_flow_filters(select)
1266
+
1267
+ ds = self._stats.flow_hours.copy()
1268
+
1269
+ # Apply period/scenario weights
1270
+ if 'period' in ds.dims and self._fs.period_weights is not None:
1271
+ ds = ds * self._fs.period_weights
1272
+ if 'scenario' in ds.dims and self._fs.scenario_weights is not None:
1273
+ weights = self._fs.scenario_weights / self._fs.scenario_weights.sum()
1274
+ ds = ds * weights
1275
+
1276
+ ds = _apply_selection(ds, xr_select)
1277
+
1278
+ # Aggregate remaining dimensions
1279
+ if 'time' in ds.dims:
1280
+ ds = getattr(ds, aggregate)(dim='time')
1281
+ for dim in ['period', 'scenario']:
1282
+ if dim in ds.dims:
1283
+ ds = ds.sum(dim=dim)
1284
+
1285
+ nodes, links = self._build_flow_links(ds, flow_filter, bus_filter, component_filter, carrier_filter)
1286
+ fig = self._create_figure(nodes, links, colors, 'Energy Flow', **plotly_kwargs)
1287
+ return self._finalize(fig, links, show)
1288
+
1289
+ def sizes(
1290
+ self,
1291
+ *,
1292
+ select: FlowSankeySelect | None = None,
1293
+ max_size: float | None = None,
1294
+ colors: ColorType | None = None,
1295
+ show: bool | None = None,
1296
+ **plotly_kwargs: Any,
1297
+ ) -> PlotResult:
1298
+ """Plot Sankey diagram of investment sizes/capacities.
1299
+
1300
+ Args:
1301
+ select: Filter options:
1302
+ - flow: filter by flow label (e.g., 'Boiler|Q_th')
1303
+ - bus: filter by bus label (e.g., 'HeatBus')
1304
+ - component: filter by component label (e.g., 'Boiler')
1305
+ - period, scenario: xarray dimension selection
1306
+ max_size: Filter flows with sizes exceeding this value.
1307
+ colors: Color specification for nodes.
1308
+ show: Whether to display the figure.
1309
+ **plotly_kwargs: Additional arguments passed to Plotly layout.
1310
+
1311
+ Returns:
1312
+ PlotResult with Sankey size data and figure.
1313
+ """
1314
+ self._stats._require_solution()
1315
+ xr_select, flow_filter, bus_filter, component_filter, carrier_filter = self._extract_flow_filters(select)
1316
+
1317
+ ds = self._stats.sizes.copy()
1318
+ ds = _apply_selection(ds, xr_select)
1319
+
1320
+ # Collapse remaining dimensions
1321
+ for dim in ['period', 'scenario']:
1322
+ if dim in ds.dims:
1323
+ ds = ds.max(dim=dim)
1324
+
1325
+ # Apply max_size filter
1326
+ if max_size is not None and ds.data_vars:
1327
+ valid_labels = [lbl for lbl in ds.data_vars if float(ds[lbl].max()) < max_size]
1328
+ ds = ds[valid_labels]
1329
+
1330
+ nodes, links = self._build_flow_links(ds, flow_filter, bus_filter, component_filter, carrier_filter)
1331
+ fig = self._create_figure(nodes, links, colors, 'Investment Sizes (Capacities)', **plotly_kwargs)
1332
+ return self._finalize(fig, links, show)
1333
+
1334
+ def peak_flow(
1335
+ self,
1336
+ *,
1337
+ select: FlowSankeySelect | None = None,
1338
+ colors: ColorType | None = None,
1339
+ show: bool | None = None,
1340
+ **plotly_kwargs: Any,
1341
+ ) -> PlotResult:
1342
+ """Plot Sankey diagram of peak (maximum) flow rates.
1343
+
1344
+ Args:
1345
+ select: Filter options:
1346
+ - flow: filter by flow label (e.g., 'Boiler|Q_th')
1347
+ - bus: filter by bus label (e.g., 'HeatBus')
1348
+ - component: filter by component label (e.g., 'Boiler')
1349
+ - time, period, scenario: xarray dimension selection
1350
+ colors: Color specification for nodes.
1351
+ show: Whether to display the figure.
1352
+ **plotly_kwargs: Additional arguments passed to Plotly layout.
1353
+
1354
+ Returns:
1355
+ PlotResult with Sankey peak flow data and figure.
1356
+ """
1357
+ self._stats._require_solution()
1358
+ xr_select, flow_filter, bus_filter, component_filter, carrier_filter = self._extract_flow_filters(select)
1359
+
1360
+ ds = self._stats.flow_rates.copy()
1361
+ ds = _apply_selection(ds, xr_select)
1362
+
1363
+ # Take max over all dimensions
1364
+ for dim in ['time', 'period', 'scenario']:
1365
+ if dim in ds.dims:
1366
+ ds = ds.max(dim=dim)
1367
+
1368
+ nodes, links = self._build_flow_links(ds, flow_filter, bus_filter, component_filter, carrier_filter)
1369
+ fig = self._create_figure(nodes, links, colors, 'Peak Flow Rates', **plotly_kwargs)
1370
+ return self._finalize(fig, links, show)
1371
+
1372
+ def effects(
1373
+ self,
1374
+ *,
1375
+ select: EffectsSankeySelect | None = None,
1376
+ colors: ColorType | None = None,
1377
+ show: bool | None = None,
1378
+ **plotly_kwargs: Any,
1379
+ ) -> PlotResult:
1380
+ """Plot Sankey diagram of component contributions to effects.
1381
+
1382
+ Shows how each component contributes to costs, CO2, and other effects.
1383
+
1384
+ Args:
1385
+ select: Filter options:
1386
+ - effect: filter which effects are shown (e.g., 'costs', ['costs', 'CO2'])
1387
+ - component: filter by component label (e.g., 'Boiler')
1388
+ - contributor: filter by contributor label (e.g., 'Boiler|Q_th')
1389
+ - period, scenario: xarray dimension selection
1390
+ colors: Color specification for nodes.
1391
+ show: Whether to display the figure.
1392
+ **plotly_kwargs: Additional arguments passed to Plotly layout.
1393
+
1394
+ Returns:
1395
+ PlotResult with Sankey effects data and figure.
1396
+ """
1397
+ self._stats._require_solution()
1398
+ total_effects = self._stats.total_effects
1399
+
1400
+ # Extract special filters from select
1401
+ effect_filter: list[str] | None = None
1402
+ component_filter: list[str] | None = None
1403
+ contributor_filter: list[str] | None = None
1404
+ xr_select: SelectType | None = None
1405
+
1406
+ if select is not None:
1407
+ select = dict(select) # Copy to avoid mutating
1408
+ effect_filter = select.pop('effect', None)
1409
+ component_filter = select.pop('component', None)
1410
+ contributor_filter = select.pop('contributor', None)
1411
+ xr_select = select if select else None
1412
+
1413
+ # Normalize to lists
1414
+ if isinstance(effect_filter, str):
1415
+ effect_filter = [effect_filter]
1416
+ if isinstance(component_filter, str):
1417
+ component_filter = [component_filter]
1418
+ if isinstance(contributor_filter, str):
1419
+ contributor_filter = [contributor_filter]
1420
+
1421
+ # Determine which effects to include
1422
+ effect_names = list(total_effects.data_vars)
1423
+ if effect_filter is not None:
1424
+ effect_names = [e for e in effect_names if e in effect_filter]
1425
+
1426
+ # Collect all links: component -> effect
1427
+ nodes: set[str] = set()
1428
+ links: dict[str, list] = {'source': [], 'target': [], 'value': [], 'label': []}
1429
+
1430
+ for effect_name in effect_names:
1431
+ effect_data = total_effects[effect_name]
1432
+ effect_data = _apply_selection(effect_data, xr_select)
1433
+
1434
+ # Sum over remaining dimensions
1435
+ for dim in ['period', 'scenario']:
1436
+ if dim in effect_data.dims:
1437
+ effect_data = effect_data.sum(dim=dim)
1438
+
1439
+ contributors = effect_data.coords['contributor'].values
1440
+ components = effect_data.coords['component'].values
1441
+
1442
+ for contributor, component in zip(contributors, components, strict=False):
1443
+ if component_filter is not None and component not in component_filter:
1444
+ continue
1445
+ if contributor_filter is not None and contributor not in contributor_filter:
1446
+ continue
1447
+
1448
+ value = float(effect_data.sel(contributor=contributor).values)
1449
+ if not np.isfinite(value) or abs(value) < 1e-6:
1450
+ continue
1451
+
1452
+ source = str(component)
1453
+ target = f'[{effect_name}]'
1454
+
1455
+ nodes.add(source)
1456
+ nodes.add(target)
1457
+ links['source'].append(source)
1458
+ links['target'].append(target)
1459
+ links['value'].append(abs(value))
1460
+ links['label'].append(f'{contributor} → {effect_name}: {value:.2f}')
1461
+
1462
+ fig = self._create_figure(nodes, links, colors, 'Effect Contributions by Component', **plotly_kwargs)
1463
+ return self._finalize(fig, links, show)
1464
+
1465
+
1466
+ # --- Statistics Plot Accessor ---
1467
+
1468
+
1469
+ class StatisticsPlotAccessor:
1470
+ """Plot accessor for statistics. Access via ``flow_system.statistics.plot``.
1471
+
1472
+ All methods return PlotResult with both data and figure.
1473
+ """
1474
+
1475
+ def __init__(self, statistics: StatisticsAccessor) -> None:
1476
+ self._stats = statistics
1477
+ self._fs = statistics._fs
1478
+ self._sankey: SankeyPlotAccessor | None = None
1479
+
1480
+ @property
1481
+ def sankey(self) -> SankeyPlotAccessor:
1482
+ """Access sankey diagram methods with typed select options.
1483
+
1484
+ Returns:
1485
+ SankeyPlotAccessor with methods: flows(), sizes(), peak_flow(), effects()
1486
+
1487
+ Examples:
1488
+ >>> fs.statistics.plot.sankey.flows(select={'bus': 'HeatBus'})
1489
+ >>> fs.statistics.plot.sankey.effects(select={'effect': 'costs'})
1490
+ """
1491
+ if self._sankey is None:
1492
+ self._sankey = SankeyPlotAccessor(self)
1493
+ return self._sankey
1494
+
1495
+ def _get_color_map_for_balance(self, node: str, flow_labels: list[str]) -> dict[str, str]:
1496
+ """Build color map for balance plot.
1497
+
1498
+ - Bus balance: colors from component.color (using cached component_colors)
1499
+ - Component balance: colors from flow's carrier (using cached carrier_colors)
1500
+
1501
+ Raises:
1502
+ RuntimeError: If FlowSystem is not connected_and_transformed.
1503
+ """
1504
+ if not self._fs.connected_and_transformed:
1505
+ raise RuntimeError(
1506
+ 'FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.'
1507
+ )
1508
+
1509
+ is_bus = node in self._fs.buses
1510
+ color_map = {}
1511
+ uncolored = []
1512
+
1513
+ # Get cached colors for efficient lookup
1514
+ carrier_colors = self._stats.carrier_colors
1515
+ component_colors = self._stats.component_colors
1516
+ flow_rates = self._stats.flow_rates
1517
+
1518
+ for label in flow_labels:
1519
+ if is_bus:
1520
+ # Use cached component colors
1521
+ comp_label = self._fs.flows[label].component
1522
+ color = component_colors.get(comp_label)
1523
+ else:
1524
+ # Use carrier name from xarray attribute (already computed) + cached colors
1525
+ carrier_name = flow_rates[label].attrs.get('carrier') if label in flow_rates else None
1526
+ color = carrier_colors.get(carrier_name) if carrier_name else None
1527
+
1528
+ if color:
1529
+ color_map[label] = color
1530
+ else:
1531
+ uncolored.append(label)
1532
+
1533
+ if uncolored:
1534
+ color_map.update(process_colors(None, uncolored))
1535
+
1536
+ return color_map
1537
+
1538
+ def _resolve_variable_names(self, variables: list[str], solution: xr.Dataset) -> list[str]:
1539
+ """Resolve flow labels to variable names with fallback.
1540
+
1541
+ For each variable:
1542
+ 1. First check if it exists in the dataset as-is
1543
+ 2. If not found and doesn't contain '|', try adding '|flow_rate' suffix
1544
+ 3. If still not found, try '|charge_state' suffix (for storages)
1545
+
1546
+ Args:
1547
+ variables: List of flow labels or variable names.
1548
+ solution: The solution dataset to check variable existence.
1549
+
1550
+ Returns:
1551
+ List of resolved variable names.
1552
+ """
1553
+ resolved = []
1554
+ for var in variables:
1555
+ if var in solution:
1556
+ # Variable exists as-is, use it directly
1557
+ resolved.append(var)
1558
+ elif '|' not in var:
1559
+ # Not found and no '|', try common suffixes
1560
+ flow_rate_var = f'{var}|flow_rate'
1561
+ charge_state_var = f'{var}|charge_state'
1562
+ if flow_rate_var in solution:
1563
+ resolved.append(flow_rate_var)
1564
+ elif charge_state_var in solution:
1565
+ resolved.append(charge_state_var)
1566
+ else:
1567
+ # Let it fail with the original name for clear error message
1568
+ resolved.append(var)
1569
+ else:
1570
+ # Contains '|' but not in solution - let it fail with original name
1571
+ resolved.append(var)
1572
+ return resolved
1573
+
1574
+ def balance(
1575
+ self,
1576
+ node: str,
1577
+ *,
1578
+ select: SelectType | None = None,
1579
+ include: FilterType | None = None,
1580
+ exclude: FilterType | None = None,
1581
+ unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate',
1582
+ colors: ColorType | None = None,
1583
+ show: bool | None = None,
1584
+ data_only: bool = False,
1585
+ **plotly_kwargs: Any,
1586
+ ) -> PlotResult:
1587
+ """Plot node balance (inputs vs outputs) for a Bus or Component.
1588
+
1589
+ Args:
1590
+ node: Label of the Bus or Component to plot.
1591
+ select: xarray-style selection dict.
1592
+ include: Only include flows containing these substrings.
1593
+ exclude: Exclude flows containing these substrings.
1594
+ unit: 'flow_rate' (power) or 'flow_hours' (energy).
1595
+ colors: Color specification (colorscale name, color list, or label-to-color dict).
1596
+ show: Whether to display the plot.
1597
+ data_only: If True, skip figure creation and return only data (for performance).
1598
+ **plotly_kwargs: Additional arguments passed to the plotly accessor (e.g.,
1599
+ facet_col, facet_row, animation_frame).
1600
+
1601
+ Returns:
1602
+ PlotResult with .data and .figure.
1603
+ """
1604
+ self._stats._require_solution()
1605
+
1606
+ # Get the element
1607
+ if node in self._fs.buses:
1608
+ element = self._fs.buses[node]
1609
+ elif node in self._fs.components:
1610
+ element = self._fs.components[node]
1611
+ else:
1612
+ raise KeyError(f"'{node}' not found in buses or components")
1613
+
1614
+ input_labels = [f.label_full for f in element.inputs]
1615
+ output_labels = [f.label_full for f in element.outputs]
1616
+ all_labels = input_labels + output_labels
1617
+
1618
+ filtered_labels = _filter_by_pattern(all_labels, include, exclude)
1619
+ if not filtered_labels:
1620
+ logger.warning(f'No flows remaining after filtering for node {node}')
1621
+ return PlotResult(data=xr.Dataset(), figure=go.Figure())
1622
+
1623
+ # Get data from statistics
1624
+ if unit == 'flow_rate':
1625
+ ds = self._stats.flow_rates[[lbl for lbl in filtered_labels if lbl in self._stats.flow_rates]]
1626
+ else:
1627
+ ds = self._stats.flow_hours[[lbl for lbl in filtered_labels if lbl in self._stats.flow_hours]]
1628
+
1629
+ # Negate inputs
1630
+ for label in input_labels:
1631
+ if label in ds:
1632
+ ds[label] = -ds[label]
1633
+
1634
+ ds = _apply_selection(ds, select)
1635
+
1636
+ # Build color kwargs - use default colors from element attributes if not specified
1637
+ if colors is None:
1638
+ color_kwargs = {'color_discrete_map': self._get_color_map_for_balance(node, list(ds.data_vars))}
1639
+ else:
1640
+ color_kwargs = _build_color_kwargs(colors, list(ds.data_vars))
1641
+
1642
+ # Early return for data_only mode (skip figure creation for performance)
1643
+ if data_only:
1644
+ return PlotResult(data=ds, figure=go.Figure())
1645
+
1646
+ # Get unit label from first data variable's attributes
1647
+ unit_label = ''
1648
+ if ds.data_vars:
1649
+ first_var = next(iter(ds.data_vars))
1650
+ unit_label = ds[first_var].attrs.get('unit', '')
1651
+
1652
+ _apply_slot_defaults(plotly_kwargs, 'balance')
1653
+ fig = ds.plotly.area(
1654
+ title=f'{node} [{unit_label}]' if unit_label else node,
1655
+ line_shape='hv',
1656
+ **color_kwargs,
1657
+ **plotly_kwargs,
1658
+ )
1659
+ _style_area_as_bar(fig)
1660
+ _apply_unified_hover(fig, unit=unit_label)
1661
+
1662
+ if show is None:
1663
+ show = CONFIG.Plotting.default_show
1664
+ if show:
1665
+ fig.show()
1666
+
1667
+ return PlotResult(data=ds, figure=fig)
1668
+
1669
+ def carrier_balance(
1670
+ self,
1671
+ carrier: str,
1672
+ *,
1673
+ select: SelectType | None = None,
1674
+ include: FilterType | None = None,
1675
+ exclude: FilterType | None = None,
1676
+ unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate',
1677
+ colors: ColorType | None = None,
1678
+ show: bool | None = None,
1679
+ data_only: bool = False,
1680
+ **plotly_kwargs: Any,
1681
+ ) -> PlotResult:
1682
+ """Plot carrier-level balance showing all flows of a carrier type.
1683
+
1684
+ Shows production (positive) and consumption (negative) of a carrier
1685
+ across all buses of that carrier type in the system.
1686
+
1687
+ Args:
1688
+ carrier: Carrier name (e.g., 'heat', 'electricity', 'gas').
1689
+ select: xarray-style selection dict.
1690
+ include: Only include flows containing these substrings.
1691
+ exclude: Exclude flows containing these substrings.
1692
+ unit: 'flow_rate' (power) or 'flow_hours' (energy).
1693
+ colors: Color specification (colorscale name, color list, or label-to-color dict).
1694
+ show: Whether to display the plot.
1695
+ data_only: If True, skip figure creation and return only data (for performance).
1696
+ **plotly_kwargs: Additional arguments passed to the plotly accessor (e.g.,
1697
+ facet_col, facet_row, animation_frame).
1698
+
1699
+ Returns:
1700
+ PlotResult with .data and .figure.
1701
+
1702
+ Examples:
1703
+ >>> fs.statistics.plot.carrier_balance('heat')
1704
+ >>> fs.statistics.plot.carrier_balance('electricity', unit='flow_hours')
1705
+
1706
+ Notes:
1707
+ - Inputs to carrier buses (from sources/converters) are shown as positive
1708
+ - Outputs from carrier buses (to sinks/converters) are shown as negative
1709
+ - Internal transfers between buses of the same carrier appear on both sides
1710
+ """
1711
+ self._stats._require_solution()
1712
+ carrier = carrier.lower()
1713
+
1714
+ # Find all buses with this carrier
1715
+ carrier_buses = [bus for bus in self._fs.buses.values() if bus.carrier == carrier]
1716
+ if not carrier_buses:
1717
+ raise KeyError(f"No buses found with carrier '{carrier}'")
1718
+
1719
+ # Collect all flows connected to these buses
1720
+ input_labels: list[str] = [] # Inputs to buses = production
1721
+ output_labels: list[str] = [] # Outputs from buses = consumption
1722
+
1723
+ for bus in carrier_buses:
1724
+ for flow in bus.inputs:
1725
+ input_labels.append(flow.label_full)
1726
+ for flow in bus.outputs:
1727
+ output_labels.append(flow.label_full)
1728
+
1729
+ all_labels = input_labels + output_labels
1730
+ filtered_labels = _filter_by_pattern(all_labels, include, exclude)
1731
+ if not filtered_labels:
1732
+ logger.warning(f'No flows remaining after filtering for carrier {carrier}')
1733
+ return PlotResult(data=xr.Dataset(), figure=go.Figure())
1734
+
1735
+ # Get data from statistics
1736
+ if unit == 'flow_rate':
1737
+ ds = self._stats.flow_rates[[lbl for lbl in filtered_labels if lbl in self._stats.flow_rates]]
1738
+ else:
1739
+ ds = self._stats.flow_hours[[lbl for lbl in filtered_labels if lbl in self._stats.flow_hours]]
1740
+
1741
+ # Negate outputs (consumption) - opposite convention from bus balance
1742
+ for label in output_labels:
1743
+ if label in ds:
1744
+ ds[label] = -ds[label]
1745
+
1746
+ ds = _apply_selection(ds, select)
1747
+
1748
+ # Build color kwargs
1749
+ if colors is None:
1750
+ component_colors = self._stats.component_colors
1751
+ color_map = {}
1752
+ uncolored = []
1753
+ for label in ds.data_vars:
1754
+ flow = self._fs.flows.get(label)
1755
+ if flow:
1756
+ color = component_colors.get(flow.component)
1757
+ if color:
1758
+ color_map[label] = color
1759
+ continue
1760
+ uncolored.append(label)
1761
+ if uncolored:
1762
+ color_map.update(process_colors(None, uncolored))
1763
+ color_kwargs = {'color_discrete_map': color_map}
1764
+ else:
1765
+ color_kwargs = _build_color_kwargs(colors, list(ds.data_vars))
1766
+
1767
+ # Early return for data_only mode (skip figure creation for performance)
1768
+ if data_only:
1769
+ return PlotResult(data=ds, figure=go.Figure())
1770
+
1771
+ # Get unit label from carrier or first data variable
1772
+ unit_label = ''
1773
+ if ds.data_vars:
1774
+ first_var = next(iter(ds.data_vars))
1775
+ unit_label = ds[first_var].attrs.get('unit', '')
1776
+
1777
+ _apply_slot_defaults(plotly_kwargs, 'carrier_balance')
1778
+ fig = ds.plotly.area(
1779
+ title=f'{carrier.capitalize()} Balance [{unit_label}]' if unit_label else f'{carrier.capitalize()} Balance',
1780
+ line_shape='hv',
1781
+ **color_kwargs,
1782
+ **plotly_kwargs,
1783
+ )
1784
+ _style_area_as_bar(fig)
1785
+ _apply_unified_hover(fig, unit=unit_label)
1786
+
1787
+ if show is None:
1788
+ show = CONFIG.Plotting.default_show
1789
+ if show:
1790
+ fig.show()
1791
+
1792
+ return PlotResult(data=ds, figure=fig)
1793
+
1794
+ def heatmap(
1795
+ self,
1796
+ variables: str | list[str],
1797
+ *,
1798
+ select: SelectType | None = None,
1799
+ reshape: tuple[str, str] | Literal['auto'] | None = ('D', 'h'),
1800
+ colors: str | list[str] | None = None,
1801
+ show: bool | None = None,
1802
+ data_only: bool = False,
1803
+ **plotly_kwargs: Any,
1804
+ ) -> PlotResult:
1805
+ """Plot heatmap of time series data.
1806
+
1807
+ By default, time is reshaped into days × hours for clear daily pattern visualization.
1808
+ For clustered data, the natural (cluster, time) shape is used instead.
1809
+
1810
+ Multiple variables are shown as facets. If no time dimension exists, reshaping
1811
+ is skipped and data dimensions are used directly.
1812
+
1813
+ Args:
1814
+ variables: Flow label(s) or variable name(s). Flow labels like 'Boiler(Q_th)'
1815
+ are automatically resolved to 'Boiler(Q_th)|flow_rate'. Full variable
1816
+ names like 'Storage|charge_state' are used as-is.
1817
+ select: xarray-style selection, e.g. {'scenario': 'Base Case'}.
1818
+ reshape: Time reshape frequencies as (outer, inner). Default ``('D', 'h')``
1819
+ reshapes into days × hours. Use None to disable reshaping and use
1820
+ data dimensions directly.
1821
+ colors: Colorscale name (str) or list of colors for heatmap coloring.
1822
+ Dicts are not supported for heatmaps (use str or list[str]).
1823
+ show: Whether to display the figure.
1824
+ data_only: If True, skip figure creation and return only data (for performance).
1825
+ **plotly_kwargs: Additional arguments passed to plotly accessor (e.g.,
1826
+ facet_col, animation_frame).
1827
+
1828
+ Returns:
1829
+ PlotResult with processed data and figure.
1830
+ """
1831
+ solution = self._stats._require_solution()
1832
+ if isinstance(variables, str):
1833
+ variables = [variables]
1834
+
1835
+ # Resolve, select, and stack into single DataArray
1836
+ resolved = self._resolve_variable_names(variables, solution)
1837
+ ds = _apply_selection(solution[resolved], select)
1838
+ da = xr.concat([ds[v] for v in ds.data_vars], dim=pd.Index(list(ds.data_vars), name='variable'))
1839
+
1840
+ # Prepare for heatmap (reshape, transpose, squeeze)
1841
+ da = _prepare_for_heatmap(da, reshape)
1842
+
1843
+ # Early return for data_only mode (skip figure creation for performance)
1844
+ if data_only:
1845
+ return PlotResult(data=da.to_dataset(name='value'), figure=go.Figure())
1846
+
1847
+ # Only pass colors if not already in plotly_kwargs (avoid duplicate arg error)
1848
+ if 'color_continuous_scale' not in plotly_kwargs:
1849
+ plotly_kwargs['color_continuous_scale'] = colors
1850
+ fig = da.plotly.imshow(**plotly_kwargs)
1851
+
1852
+ if show is None:
1853
+ show = CONFIG.Plotting.default_show
1854
+ if show:
1855
+ fig.show()
1856
+
1857
+ return PlotResult(data=da.to_dataset(name='value'), figure=fig)
1858
+
1859
+ def flows(
1860
+ self,
1861
+ *,
1862
+ start: str | list[str] | None = None,
1863
+ end: str | list[str] | None = None,
1864
+ component: str | list[str] | None = None,
1865
+ select: SelectType | None = None,
1866
+ unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate',
1867
+ colors: ColorType | None = None,
1868
+ show: bool | None = None,
1869
+ data_only: bool = False,
1870
+ **plotly_kwargs: Any,
1871
+ ) -> PlotResult:
1872
+ """Plot flow rates filtered by start/end nodes or component.
1873
+
1874
+ Args:
1875
+ start: Filter by source node(s).
1876
+ end: Filter by destination node(s).
1877
+ component: Filter by parent component(s).
1878
+ select: xarray-style selection.
1879
+ unit: 'flow_rate' or 'flow_hours'.
1880
+ colors: Color specification (colorscale name, color list, or label-to-color dict).
1881
+ show: Whether to display.
1882
+ data_only: If True, skip figure creation and return only data (for performance).
1883
+ **plotly_kwargs: Additional arguments passed to the plotly accessor (e.g.,
1884
+ facet_col, facet_row, animation_frame).
1885
+
1886
+ Returns:
1887
+ PlotResult with flow data.
1888
+ """
1889
+ self._stats._require_solution()
1890
+
1891
+ ds = self._stats.flow_rates if unit == 'flow_rate' else self._stats.flow_hours
1892
+
1893
+ # Filter by connection
1894
+ if start is not None or end is not None or component is not None:
1895
+ matching_labels = []
1896
+ starts = [start] if isinstance(start, str) else (start or [])
1897
+ ends = [end] if isinstance(end, str) else (end or [])
1898
+ components = [component] if isinstance(component, str) else (component or [])
1899
+
1900
+ for flow in self._fs.flows.values():
1901
+ # Get bus label (could be string or Bus object)
1902
+ bus_label = flow.bus
1903
+ comp_label = flow.component
1904
+
1905
+ # start/end filtering based on flow direction
1906
+ if flow.is_input_in_component:
1907
+ # Flow goes: bus -> component, so start=bus, end=component
1908
+ if starts and bus_label not in starts:
1909
+ continue
1910
+ if ends and comp_label not in ends:
1911
+ continue
1912
+ else:
1913
+ # Flow goes: component -> bus, so start=component, end=bus
1914
+ if starts and comp_label not in starts:
1915
+ continue
1916
+ if ends and bus_label not in ends:
1917
+ continue
1918
+
1919
+ if components and comp_label not in components:
1920
+ continue
1921
+ matching_labels.append(flow.label_full)
1922
+
1923
+ ds = ds[[lbl for lbl in matching_labels if lbl in ds]]
1924
+
1925
+ ds = _apply_selection(ds, select)
1926
+
1927
+ # Early return for data_only mode (skip figure creation for performance)
1928
+ if data_only:
1929
+ return PlotResult(data=ds, figure=go.Figure())
1930
+
1931
+ # Get unit label from first data variable's attributes
1932
+ unit_label = ''
1933
+ if ds.data_vars:
1934
+ first_var = next(iter(ds.data_vars))
1935
+ unit_label = ds[first_var].attrs.get('unit', '')
1936
+
1937
+ # Build color kwargs
1938
+ color_kwargs = _build_color_kwargs(colors, list(ds.data_vars))
1939
+
1940
+ _apply_slot_defaults(plotly_kwargs, 'flows')
1941
+ fig = ds.plotly.line(
1942
+ title=f'Flows [{unit_label}]' if unit_label else 'Flows',
1943
+ **color_kwargs,
1944
+ **plotly_kwargs,
1945
+ )
1946
+
1947
+ if show is None:
1948
+ show = CONFIG.Plotting.default_show
1949
+ if show:
1950
+ fig.show()
1951
+
1952
+ return PlotResult(data=ds, figure=fig)
1953
+
1954
+ def sizes(
1955
+ self,
1956
+ *,
1957
+ max_size: float | None = 1e6,
1958
+ select: SelectType | None = None,
1959
+ colors: ColorType | None = None,
1960
+ show: bool | None = None,
1961
+ data_only: bool = False,
1962
+ **plotly_kwargs: Any,
1963
+ ) -> PlotResult:
1964
+ """Plot investment sizes (capacities) of flows.
1965
+
1966
+ Args:
1967
+ max_size: Maximum size to include (filters defaults).
1968
+ select: xarray-style selection.
1969
+ colors: Color specification (colorscale name, color list, or label-to-color dict).
1970
+ show: Whether to display.
1971
+ data_only: If True, skip figure creation and return only data (for performance).
1972
+ **plotly_kwargs: Additional arguments passed to the plotly accessor (e.g.,
1973
+ facet_col, facet_row, animation_frame).
1974
+
1975
+ Returns:
1976
+ PlotResult with size data.
1977
+ """
1978
+ self._stats._require_solution()
1979
+ ds = self._stats.sizes
1980
+
1981
+ ds = _apply_selection(ds, select)
1982
+
1983
+ if max_size is not None and ds.data_vars:
1984
+ valid_labels = [lbl for lbl in ds.data_vars if float(ds[lbl].max()) < max_size]
1985
+ ds = ds[valid_labels]
1986
+
1987
+ # Early return for data_only mode (skip figure creation for performance)
1988
+ if data_only:
1989
+ return PlotResult(data=ds, figure=go.Figure())
1990
+
1991
+ if not ds.data_vars:
1992
+ fig = go.Figure()
1993
+ else:
1994
+ # Build color kwargs
1995
+ color_kwargs = _build_color_kwargs(colors, list(ds.data_vars))
1996
+ _apply_slot_defaults(plotly_kwargs, 'sizes')
1997
+ fig = ds.plotly.bar(
1998
+ title='Investment Sizes',
1999
+ labels={'value': 'Size'},
2000
+ **color_kwargs,
2001
+ **plotly_kwargs,
2002
+ )
2003
+
2004
+ if show is None:
2005
+ show = CONFIG.Plotting.default_show
2006
+ if show:
2007
+ fig.show()
2008
+
2009
+ return PlotResult(data=ds, figure=fig)
2010
+
2011
+ def duration_curve(
2012
+ self,
2013
+ variables: str | list[str],
2014
+ *,
2015
+ select: SelectType | None = None,
2016
+ normalize: bool = False,
2017
+ colors: ColorType | None = None,
2018
+ show: bool | None = None,
2019
+ data_only: bool = False,
2020
+ **plotly_kwargs: Any,
2021
+ ) -> PlotResult:
2022
+ """Plot load duration curves (sorted time series).
2023
+
2024
+ Args:
2025
+ variables: Flow label(s) or variable name(s). Flow labels like 'Boiler(Q_th)'
2026
+ are looked up in flow_rates. Full variable names like 'Boiler(Q_th)|flow_rate'
2027
+ are stripped to their flow label. Other variables (e.g., 'Storage|charge_state')
2028
+ are looked up in the solution directly.
2029
+ select: xarray-style selection.
2030
+ normalize: If True, normalize x-axis to 0-100%.
2031
+ colors: Color specification (colorscale name, color list, or label-to-color dict).
2032
+ show: Whether to display.
2033
+ data_only: If True, skip figure creation and return only data (for performance).
2034
+ **plotly_kwargs: Additional arguments passed to the plotly accessor (e.g.,
2035
+ facet_col, facet_row, animation_frame).
2036
+
2037
+ Returns:
2038
+ PlotResult with sorted duration curve data.
2039
+ """
2040
+ solution = self._stats._require_solution()
2041
+
2042
+ if isinstance(variables, str):
2043
+ variables = [variables]
2044
+
2045
+ # Normalize variable names: strip |flow_rate suffix for flow_rates lookup
2046
+ flow_rates = self._stats.flow_rates
2047
+ normalized_vars = []
2048
+ for var in variables:
2049
+ # Strip |flow_rate suffix if present
2050
+ if var.endswith('|flow_rate'):
2051
+ var = var[: -len('|flow_rate')]
2052
+ normalized_vars.append(var)
2053
+
2054
+ # Try to get from flow_rates first, fall back to solution for non-flow variables
2055
+ ds_parts = []
2056
+ for var in normalized_vars:
2057
+ if var in flow_rates:
2058
+ ds_parts.append(flow_rates[[var]])
2059
+ elif var in solution:
2060
+ ds_parts.append(solution[[var]])
2061
+ else:
2062
+ # Try with |flow_rate suffix as last resort
2063
+ flow_rate_var = f'{var}|flow_rate'
2064
+ if flow_rate_var in solution:
2065
+ ds_parts.append(solution[[flow_rate_var]].rename({flow_rate_var: var}))
2066
+ else:
2067
+ raise KeyError(f"Variable '{var}' not found in flow_rates or solution")
2068
+
2069
+ ds = xr.merge(ds_parts)
2070
+ ds = _apply_selection(ds, select)
2071
+
2072
+ result_ds = ds.fxstats.to_duration_curve(normalize=normalize)
2073
+
2074
+ # Early return for data_only mode (skip figure creation for performance)
2075
+ if data_only:
2076
+ return PlotResult(data=result_ds, figure=go.Figure())
2077
+
2078
+ # Get unit label from first data variable's attributes
2079
+ unit_label = ''
2080
+ if ds.data_vars:
2081
+ first_var = next(iter(ds.data_vars))
2082
+ unit_label = ds[first_var].attrs.get('unit', '')
2083
+
2084
+ # Build color kwargs
2085
+ color_kwargs = _build_color_kwargs(colors, list(result_ds.data_vars))
2086
+
2087
+ plotly_kwargs.setdefault('x', 'duration_pct' if normalize else 'duration')
2088
+ _apply_slot_defaults(plotly_kwargs, 'duration_curve')
2089
+ fig = result_ds.plotly.line(
2090
+ title=f'Duration Curve [{unit_label}]' if unit_label else 'Duration Curve',
2091
+ **color_kwargs,
2092
+ **plotly_kwargs,
2093
+ )
2094
+
2095
+ x_label = 'Duration [%]' if normalize else 'Timesteps'
2096
+ fig.update_xaxes(title_text=x_label)
2097
+
2098
+ if show is None:
2099
+ show = CONFIG.Plotting.default_show
2100
+ if show:
2101
+ fig.show()
2102
+
2103
+ return PlotResult(data=result_ds, figure=fig)
2104
+
2105
+ def effects(
2106
+ self,
2107
+ aspect: Literal['total', 'temporal', 'periodic'] = 'total',
2108
+ *,
2109
+ effect: str | None = None,
2110
+ by: Literal['component', 'contributor', 'time'] | None = None,
2111
+ select: SelectType | None = None,
2112
+ colors: ColorType | None = None,
2113
+ show: bool | None = None,
2114
+ data_only: bool = False,
2115
+ **plotly_kwargs: Any,
2116
+ ) -> PlotResult:
2117
+ """Plot effect (cost, emissions, etc.) breakdown.
2118
+
2119
+ Args:
2120
+ aspect: Which aspect to plot - 'total', 'temporal', or 'periodic'.
2121
+ effect: Specific effect name to plot (e.g., 'costs', 'CO2').
2122
+ If None, plots all effects.
2123
+ by: Group by 'component', 'contributor' (individual flows), 'time',
2124
+ or None to show aggregated totals per effect.
2125
+ select: xarray-style selection.
2126
+ colors: Color specification (colorscale name, color list, or label-to-color dict).
2127
+ show: Whether to display.
2128
+ data_only: If True, skip figure creation and return only data (for performance).
2129
+ **plotly_kwargs: Additional arguments passed to the plotly accessor (e.g.,
2130
+ facet_col, facet_row, animation_frame).
2131
+
2132
+ Returns:
2133
+ PlotResult with effect breakdown data.
2134
+
2135
+ Examples:
2136
+ >>> flow_system.statistics.plot.effects() # Aggregated totals per effect
2137
+ >>> flow_system.statistics.plot.effects(effect='costs') # Just costs
2138
+ >>> flow_system.statistics.plot.effects(by='component') # Breakdown by component
2139
+ >>> flow_system.statistics.plot.effects(by='contributor') # By individual flows
2140
+ >>> flow_system.statistics.plot.effects(aspect='temporal', by='time') # Over time
2141
+ """
2142
+ self._stats._require_solution()
2143
+
2144
+ # Get the appropriate effects dataset based on aspect
2145
+ effects_ds = {
2146
+ 'total': self._stats.total_effects,
2147
+ 'temporal': self._stats.temporal_effects,
2148
+ 'periodic': self._stats.periodic_effects,
2149
+ }.get(aspect)
2150
+ if effects_ds is None:
2151
+ raise ValueError(f"Aspect '{aspect}' not valid. Choose from 'total', 'temporal', 'periodic'.")
2152
+
2153
+ # Filter to specific effect(s) and apply selection
2154
+ if effect is not None:
2155
+ if effect not in effects_ds:
2156
+ raise ValueError(f"Effect '{effect}' not found. Available: {list(effects_ds.data_vars)}")
2157
+ ds = effects_ds[[effect]]
2158
+ else:
2159
+ ds = effects_ds
2160
+
2161
+ # Group by component (default) unless by='contributor'
2162
+ if by != 'contributor' and 'contributor' in ds.dims:
2163
+ ds = ds.groupby('component').sum()
2164
+
2165
+ ds = _apply_selection(ds, select)
2166
+
2167
+ # Sum over dimensions based on 'by' parameter
2168
+ if by is None:
2169
+ for dim in ['time', 'component', 'contributor']:
2170
+ if dim in ds.dims:
2171
+ ds = ds.sum(dim=dim)
2172
+ x_col, color_col = 'variable', 'variable'
2173
+ elif by == 'component':
2174
+ if 'time' in ds.dims:
2175
+ ds = ds.sum(dim='time')
2176
+ x_col = 'component'
2177
+ color_col = 'variable' if len(ds.data_vars) > 1 else 'component'
2178
+ elif by == 'contributor':
2179
+ if 'time' in ds.dims:
2180
+ ds = ds.sum(dim='time')
2181
+ x_col = 'contributor'
2182
+ color_col = 'variable' if len(ds.data_vars) > 1 else 'contributor'
2183
+ elif by == 'time':
2184
+ if 'time' not in ds.dims:
2185
+ raise ValueError(f"Cannot plot by 'time' for aspect '{aspect}' - no time dimension.")
2186
+ for dim in ['component', 'contributor']:
2187
+ if dim in ds.dims:
2188
+ ds = ds.sum(dim=dim)
2189
+ x_col = 'time'
2190
+ color_col = 'variable' if len(ds.data_vars) > 1 else None
2191
+ else:
2192
+ raise ValueError(f"'by' must be one of 'component', 'contributor', 'time', or None, got {by!r}")
2193
+
2194
+ # Early return for data_only mode (skip figure creation for performance)
2195
+ if data_only:
2196
+ return PlotResult(data=ds, figure=go.Figure())
2197
+
2198
+ # Build title
2199
+ effect_label = effect or 'Effects'
2200
+ title = f'{effect_label} ({aspect})' if by is None else f'{effect_label} ({aspect}) by {by}'
2201
+
2202
+ # Allow user override of color via plotly_kwargs
2203
+ color = plotly_kwargs.pop('color', color_col)
2204
+
2205
+ # Build color kwargs
2206
+ color_dim = color or color_col or 'variable'
2207
+ if color_dim in ds.coords:
2208
+ labels = list(ds.coords[color_dim].values)
2209
+ elif color_dim == 'variable':
2210
+ labels = list(ds.data_vars)
2211
+ else:
2212
+ labels = []
2213
+ color_kwargs = _build_color_kwargs(colors, labels) if labels else {}
2214
+
2215
+ plotly_kwargs.setdefault('x', x_col)
2216
+ _apply_slot_defaults(plotly_kwargs, 'effects')
2217
+ fig = ds.plotly.bar(
2218
+ color=color,
2219
+ title=title,
2220
+ **color_kwargs,
2221
+ **plotly_kwargs,
2222
+ )
2223
+ fig.update_layout(bargap=0, bargroupgap=0)
2224
+ fig.update_traces(marker_line_width=0)
2225
+
2226
+ if show is None:
2227
+ show = CONFIG.Plotting.default_show
2228
+ if show:
2229
+ fig.show()
2230
+
2231
+ return PlotResult(data=ds, figure=fig)
2232
+
2233
+ def charge_states(
2234
+ self,
2235
+ storages: str | list[str] | None = None,
2236
+ *,
2237
+ select: SelectType | None = None,
2238
+ colors: ColorType | None = None,
2239
+ show: bool | None = None,
2240
+ data_only: bool = False,
2241
+ **plotly_kwargs: Any,
2242
+ ) -> PlotResult:
2243
+ """Plot storage charge states over time.
2244
+
2245
+ Args:
2246
+ storages: Storage label(s) to plot. If None, plots all storages.
2247
+ select: xarray-style selection.
2248
+ colors: Color specification (colorscale name, color list, or label-to-color dict).
2249
+ show: Whether to display.
2250
+ data_only: If True, skip figure creation and return only data (for performance).
2251
+ **plotly_kwargs: Additional arguments passed to the plotly accessor (e.g.,
2252
+ facet_col, facet_row, animation_frame).
2253
+
2254
+ Returns:
2255
+ PlotResult with charge state data.
2256
+ """
2257
+ self._stats._require_solution()
2258
+ ds = self._stats.charge_states
2259
+
2260
+ if storages is not None:
2261
+ if isinstance(storages, str):
2262
+ storages = [storages]
2263
+ ds = ds[[s for s in storages if s in ds]]
2264
+
2265
+ ds = _apply_selection(ds, select)
2266
+
2267
+ # Early return for data_only mode (skip figure creation for performance)
2268
+ if data_only:
2269
+ return PlotResult(data=ds, figure=go.Figure())
2270
+
2271
+ # Build color kwargs
2272
+ color_kwargs = _build_color_kwargs(colors, list(ds.data_vars))
2273
+
2274
+ _apply_slot_defaults(plotly_kwargs, 'charge_states')
2275
+ fig = ds.plotly.line(
2276
+ title='Storage Charge States',
2277
+ **color_kwargs,
2278
+ **plotly_kwargs,
2279
+ )
2280
+ fig.update_yaxes(title_text='Charge State')
2281
+
2282
+ if show is None:
2283
+ show = CONFIG.Plotting.default_show
2284
+ if show:
2285
+ fig.show()
2286
+
2287
+ return PlotResult(data=ds, figure=fig)
2288
+
2289
+ def storage(
2290
+ self,
2291
+ storage: str,
2292
+ *,
2293
+ select: SelectType | None = None,
2294
+ unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate',
2295
+ colors: ColorType | None = None,
2296
+ charge_state_color: str = 'black',
2297
+ show: bool | None = None,
2298
+ data_only: bool = False,
2299
+ **plotly_kwargs: Any,
2300
+ ) -> PlotResult:
2301
+ """Plot storage operation: balance and charge state in vertically stacked subplots.
2302
+
2303
+ Creates two subplots sharing the x-axis:
2304
+ - Top: Charging/discharging flows as stacked bars (inputs negative, outputs positive)
2305
+ - Bottom: Charge state over time as a line
2306
+
2307
+ Args:
2308
+ storage: Storage component label.
2309
+ select: xarray-style selection.
2310
+ unit: 'flow_rate' (power) or 'flow_hours' (energy).
2311
+ colors: Color specification for flow bars.
2312
+ charge_state_color: Color for the charge state line overlay.
2313
+ show: Whether to display.
2314
+ data_only: If True, skip figure creation and return only data (for performance).
2315
+ **plotly_kwargs: Additional arguments passed to the plotly accessor (e.g.,
2316
+ facet_col, facet_row, animation_frame).
2317
+
2318
+ Returns:
2319
+ PlotResult with combined balance and charge state data.
2320
+
2321
+ Raises:
2322
+ KeyError: If storage component not found.
2323
+ ValueError: If component is not a storage.
2324
+ """
2325
+ self._stats._require_solution()
2326
+
2327
+ # Get the storage component
2328
+ if storage not in self._fs.components:
2329
+ raise KeyError(f"'{storage}' not found in components")
2330
+
2331
+ component = self._fs.components[storage]
2332
+
2333
+ # Check if it's a storage by looking for charge_state variable
2334
+ charge_state_var = f'{storage}|charge_state'
2335
+ if charge_state_var not in self._fs.solution:
2336
+ raise ValueError(f"'{storage}' is not a storage (no charge_state variable found)")
2337
+
2338
+ # Get flow data
2339
+ input_labels = [f.label_full for f in component.inputs]
2340
+ output_labels = [f.label_full for f in component.outputs]
2341
+ all_labels = input_labels + output_labels
2342
+
2343
+ if unit == 'flow_rate':
2344
+ ds = self._stats.flow_rates[[lbl for lbl in all_labels if lbl in self._stats.flow_rates]]
2345
+ else:
2346
+ ds = self._stats.flow_hours[[lbl for lbl in all_labels if lbl in self._stats.flow_hours]]
2347
+
2348
+ # Negate outputs for balance view (discharging shown as negative)
2349
+ for label in output_labels:
2350
+ if label in ds:
2351
+ ds[label] = -ds[label]
2352
+
2353
+ # Get charge state and add to dataset
2354
+ charge_state = self._fs.solution[charge_state_var].rename(storage)
2355
+ ds['charge_state'] = charge_state
2356
+
2357
+ # Apply selection
2358
+ ds = _apply_selection(ds, select)
2359
+
2360
+ # Early return for data_only mode (skip figure creation for performance)
2361
+ if data_only:
2362
+ return PlotResult(data=ds, figure=go.Figure())
2363
+
2364
+ # Separate flow data from charge_state
2365
+ flow_labels = [lbl for lbl in ds.data_vars if lbl != 'charge_state']
2366
+ flow_ds = ds[flow_labels]
2367
+ charge_da = ds['charge_state']
2368
+
2369
+ # Build color kwargs - use default colors from element attributes if not specified
2370
+ if colors is None:
2371
+ color_kwargs = {'color_discrete_map': self._get_color_map_for_balance(storage, flow_labels)}
2372
+ else:
2373
+ color_kwargs = _build_color_kwargs(colors, flow_labels)
2374
+
2375
+ # Get unit label from flow data
2376
+ unit_label = ''
2377
+ if flow_ds.data_vars:
2378
+ first_var = next(iter(flow_ds.data_vars))
2379
+ unit_label = flow_ds[first_var].attrs.get('unit', '')
2380
+
2381
+ # Create stacked area chart for flows (styled as bar)
2382
+ _apply_slot_defaults(plotly_kwargs, 'storage')
2383
+ fig = flow_ds.plotly.area(
2384
+ title=f'{storage} Operation [{unit_label}]' if unit_label else f'{storage} Operation',
2385
+ line_shape='hv',
2386
+ **color_kwargs,
2387
+ **plotly_kwargs,
2388
+ )
2389
+ _style_area_as_bar(fig)
2390
+ _apply_unified_hover(fig, unit=unit_label)
2391
+
2392
+ # Add charge state as line on secondary y-axis
2393
+ # Only pass faceting kwargs that add_line_overlay accepts
2394
+ overlay_kwargs = {
2395
+ k: v for k, v in plotly_kwargs.items() if k in ('x', 'facet_col', 'facet_row', 'animation_frame')
2396
+ }
2397
+ add_line_overlay(
2398
+ fig,
2399
+ charge_da,
2400
+ line_color=charge_state_color,
2401
+ name='charge_state',
2402
+ secondary_y=True,
2403
+ y_title='Charge State',
2404
+ **overlay_kwargs,
2405
+ )
2406
+
2407
+ if show is None:
2408
+ show = CONFIG.Plotting.default_show
2409
+ if show:
2410
+ fig.show()
2411
+
2412
+ return PlotResult(data=ds, figure=fig)