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
flixopt/comparison.py ADDED
@@ -0,0 +1,819 @@
1
+ """Compare multiple FlowSystems side-by-side."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from typing import TYPE_CHECKING, Any, Literal
7
+
8
+ import xarray as xr
9
+ from xarray_plotly import SLOT_ORDERS
10
+
11
+ from .config import CONFIG
12
+ from .plot_result import PlotResult
13
+ from .statistics_accessor import (
14
+ ColorType,
15
+ SelectType,
16
+ _build_color_kwargs,
17
+ add_line_overlay,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from .flow_system import FlowSystem
22
+
23
+ __all__ = ['Comparison']
24
+
25
+ # Extract all unique slot names from xarray_plotly
26
+ _CASE_SLOTS = frozenset(slot for slots in SLOT_ORDERS.values() for slot in slots)
27
+
28
+
29
+ def _apply_slot_defaults(plotly_kwargs: dict, defaults: dict[str, str | None]) -> None:
30
+ """Apply default slot assignments to plotly kwargs.
31
+
32
+ Args:
33
+ plotly_kwargs: The kwargs dict to update (modified in place).
34
+ defaults: Default slot assignments. None values block slots.
35
+ """
36
+ # Check if 'case' is already assigned by user to any slot
37
+ case_already_assigned = any(plotly_kwargs.get(s) == 'case' for s in _CASE_SLOTS)
38
+
39
+ for slot, value in defaults.items():
40
+ if value == 'case' and case_already_assigned:
41
+ # Skip case assignment if user already assigned 'case' to another slot
42
+ continue
43
+ plotly_kwargs.setdefault(slot, value)
44
+
45
+
46
+ class Comparison:
47
+ """Compare multiple FlowSystems side-by-side.
48
+
49
+ Combines solutions, statistics, and inputs from multiple FlowSystems into
50
+ unified xarray Datasets with a 'case' dimension. The existing plotting
51
+ infrastructure automatically handles faceting by the 'case' dimension.
52
+
53
+ For comparing solutions/statistics, all FlowSystems must be optimized and
54
+ have matching dimensions. For comparing inputs only, optimization is not
55
+ required.
56
+
57
+ Args:
58
+ flow_systems: List of FlowSystems to compare.
59
+ names: Optional names for each case. If None, uses FlowSystem.name.
60
+
61
+ Raises:
62
+ ValueError: If case names are not unique.
63
+ RuntimeError: If accessing solution/statistics without optimized FlowSystems.
64
+
65
+ Examples:
66
+ ```python
67
+ # Compare two systems (uses FlowSystem.name by default)
68
+ comp = fx.Comparison([fs_base, fs_modified])
69
+
70
+ # Or with custom names
71
+ comp = fx.Comparison([fs_base, fs_modified], names=['baseline', 'modified'])
72
+
73
+ # Side-by-side plots (auto-facets by 'case')
74
+ comp.statistics.plot.balance('Heat')
75
+ comp.statistics.flow_rates.plotly.line()
76
+
77
+ # Access combined data
78
+ comp.solution # xr.Dataset with 'case' dimension
79
+ comp.statistics.flow_rates # xr.Dataset with 'case' dimension
80
+
81
+ # Compute differences relative to first case
82
+ comp.diff() # Returns xr.Dataset of differences
83
+ comp.diff('baseline') # Or specify reference by name
84
+
85
+ # For systems with different dimensions, align first:
86
+ fs_both = ... # Has scenario dimension
87
+ fs_mild = fs_both.transform.sel(scenario='Mild') # Select one scenario
88
+ fs_other = ... # Also select to match
89
+ comp = fx.Comparison([fs_mild, fs_other]) # Now dimensions match
90
+ ```
91
+ """
92
+
93
+ def __init__(self, flow_systems: list[FlowSystem], names: list[str] | None = None) -> None:
94
+ if len(flow_systems) < 2:
95
+ raise ValueError('Comparison requires at least 2 FlowSystems')
96
+
97
+ self._systems = flow_systems
98
+ self._names = names or [fs.name or f'System {i}' for i, fs in enumerate(flow_systems)]
99
+
100
+ if len(self._names) != len(self._systems):
101
+ raise ValueError(
102
+ f'Number of names ({len(self._names)}) must match number of FlowSystems ({len(self._systems)})'
103
+ )
104
+
105
+ if len(set(self._names)) != len(self._names):
106
+ raise ValueError(f'Case names must be unique, got: {self._names}')
107
+
108
+ # Caches
109
+ self._solution: xr.Dataset | None = None
110
+ self._statistics: ComparisonStatistics | None = None
111
+ self._inputs: xr.Dataset | None = None
112
+
113
+ # Core dimensions that must match across FlowSystems
114
+ # Note: 'cluster' and 'cluster_boundary' are auxiliary dimensions from clustering
115
+ _CORE_DIMS = {'time', 'period', 'scenario'}
116
+
117
+ def _warn_mismatched_dimensions(self, datasets: list[xr.Dataset]) -> None:
118
+ """Warn if datasets have mismatched dimensions or coordinates.
119
+
120
+ xarray handles mismatches gracefully with join='outer', but this may
121
+ introduce NaN values for non-overlapping coordinates.
122
+ """
123
+ ref_ds = datasets[0]
124
+ ref_core_dims = set(ref_ds.dims) & self._CORE_DIMS
125
+ ref_name = self._names[0]
126
+
127
+ for ds, name in zip(datasets[1:], self._names[1:], strict=True):
128
+ ds_core_dims = set(ds.dims) & self._CORE_DIMS
129
+ if ds_core_dims != ref_core_dims:
130
+ missing = ref_core_dims - ds_core_dims
131
+ extra = ds_core_dims - ref_core_dims
132
+ msg_parts = [f"Dimension mismatch between '{ref_name}' and '{name}'."]
133
+ if missing:
134
+ msg_parts.append(f'Missing: {missing}.')
135
+ if extra:
136
+ msg_parts.append(f'Extra: {extra}.')
137
+ msg_parts.append('This may introduce NaN values.')
138
+ warnings.warn(' '.join(msg_parts), stacklevel=4)
139
+
140
+ # Check coordinate alignment
141
+ for dim in ref_core_dims & ds_core_dims:
142
+ ref_coords = ref_ds.coords[dim].values
143
+ ds_coords = ds.coords[dim].values
144
+ if len(ref_coords) != len(ds_coords) or not (ref_coords == ds_coords).all():
145
+ warnings.warn(
146
+ f"Coordinates differ for '{dim}' between '{ref_name}' and '{name}'. "
147
+ f'This may introduce NaN values.',
148
+ stacklevel=4,
149
+ )
150
+
151
+ @property
152
+ def names(self) -> list[str]:
153
+ """Case names for each FlowSystem."""
154
+ return self._names
155
+
156
+ def _require_solutions(self) -> None:
157
+ """Validate all FlowSystems have solutions."""
158
+ for fs in self._systems:
159
+ if fs.solution is None:
160
+ raise RuntimeError(f"FlowSystem '{fs.name}' has no solution. Run optimize() first.")
161
+
162
+ @property
163
+ def solution(self) -> xr.Dataset:
164
+ """Combined solution Dataset with 'case' dimension."""
165
+ if self._solution is None:
166
+ self._require_solutions()
167
+ datasets = [fs.solution for fs in self._systems]
168
+ self._warn_mismatched_dimensions(datasets)
169
+ self._solution = xr.concat(
170
+ [ds.expand_dims(case=[name]) for ds, name in zip(datasets, self._names, strict=True)],
171
+ dim='case',
172
+ join='outer',
173
+ fill_value=float('nan'),
174
+ )
175
+ return self._solution
176
+
177
+ @property
178
+ def statistics(self) -> ComparisonStatistics:
179
+ """Combined statistics accessor with 'case' dimension."""
180
+ if self._statistics is None:
181
+ self._statistics = ComparisonStatistics(self)
182
+ return self._statistics
183
+
184
+ def diff(self, reference: str | int = 0) -> xr.Dataset:
185
+ """Compute differences relative to a reference case.
186
+
187
+ Args:
188
+ reference: Reference case name or index (default: 0, first case).
189
+
190
+ Returns:
191
+ Dataset with differences (each case minus reference).
192
+ """
193
+ if isinstance(reference, str):
194
+ if reference not in self._names:
195
+ raise ValueError(f"Reference '{reference}' not found. Available: {self._names}")
196
+ ref_idx = self._names.index(reference)
197
+ else:
198
+ ref_idx = reference
199
+ n_cases = len(self._names)
200
+ if not (-n_cases <= ref_idx < n_cases):
201
+ raise IndexError(f'Reference index {ref_idx} out of range for {n_cases} cases.')
202
+
203
+ ref_data = self.solution.isel(case=ref_idx)
204
+ return self.solution - ref_data
205
+
206
+ @property
207
+ def inputs(self) -> xr.Dataset:
208
+ """Combined input data Dataset with 'case' dimension.
209
+
210
+ Concatenates input parameters from all FlowSystems. Each FlowSystem's
211
+ ``.inputs`` Dataset is combined with a 'case' dimension.
212
+
213
+ Returns:
214
+ xr.Dataset with all input parameters. Variable naming follows
215
+ the pattern ``{element.label_full}|{parameter_name}``.
216
+
217
+ Examples:
218
+ ```python
219
+ comp = fx.Comparison([fs1, fs2], names=['Base', 'Modified'])
220
+ comp.inputs # All inputs with 'case' dimension
221
+ comp.inputs['Boiler(Q_th)|relative_minimum'] # Specific parameter
222
+ ```
223
+ """
224
+ if self._inputs is None:
225
+ datasets = [fs.to_dataset(include_solution=False) for fs in self._systems]
226
+ self._warn_mismatched_dimensions(datasets)
227
+ self._inputs = xr.concat(
228
+ [ds.expand_dims(case=[name]) for ds, name in zip(datasets, self._names, strict=True)],
229
+ dim='case',
230
+ join='outer',
231
+ fill_value=float('nan'),
232
+ )
233
+ return self._inputs
234
+
235
+
236
+ class ComparisonStatistics:
237
+ """Combined statistics accessor for comparing FlowSystems.
238
+
239
+ Mirrors StatisticsAccessor properties, concatenating data with a 'case' dimension.
240
+ Access via ``Comparison.statistics``.
241
+ """
242
+
243
+ def __init__(self, comparison: Comparison) -> None:
244
+ self._comp = comparison
245
+ # Caches for dataset properties
246
+ self._flow_rates: xr.Dataset | None = None
247
+ self._flow_hours: xr.Dataset | None = None
248
+ self._flow_sizes: xr.Dataset | None = None
249
+ self._storage_sizes: xr.Dataset | None = None
250
+ self._sizes: xr.Dataset | None = None
251
+ self._charge_states: xr.Dataset | None = None
252
+ self._temporal_effects: xr.Dataset | None = None
253
+ self._periodic_effects: xr.Dataset | None = None
254
+ self._total_effects: xr.Dataset | None = None
255
+ # Caches for dict properties
256
+ self._carrier_colors: dict[str, str] | None = None
257
+ self._component_colors: dict[str, str] | None = None
258
+ self._bus_colors: dict[str, str] | None = None
259
+ self._carrier_units: dict[str, str] | None = None
260
+ self._effect_units: dict[str, str] | None = None
261
+ # Plot accessor
262
+ self._plot: ComparisonStatisticsPlot | None = None
263
+
264
+ def _concat_property(self, prop_name: str) -> xr.Dataset:
265
+ """Concatenate a statistics property across all cases."""
266
+ datasets = []
267
+ for fs, name in zip(self._comp._systems, self._comp._names, strict=True):
268
+ try:
269
+ ds = getattr(fs.statistics, prop_name)
270
+ datasets.append(ds.expand_dims(case=[name]))
271
+ except RuntimeError as e:
272
+ warnings.warn(f"Skipping case '{name}': {e}", stacklevel=3)
273
+ continue
274
+ if not datasets:
275
+ return xr.Dataset()
276
+ return xr.concat(datasets, dim='case', join='outer', fill_value=float('nan'))
277
+
278
+ def _merge_dict_property(self, prop_name: str) -> dict[str, str]:
279
+ """Merge a dict property from all cases (later cases override)."""
280
+ result: dict[str, str] = {}
281
+ for fs in self._comp._systems:
282
+ result.update(getattr(fs.statistics, prop_name))
283
+ return result
284
+
285
+ @property
286
+ def flow_rates(self) -> xr.Dataset:
287
+ """Combined flow rates with 'case' dimension."""
288
+ if self._flow_rates is None:
289
+ self._flow_rates = self._concat_property('flow_rates')
290
+ return self._flow_rates
291
+
292
+ @property
293
+ def flow_hours(self) -> xr.Dataset:
294
+ """Combined flow hours (energy) with 'case' dimension."""
295
+ if self._flow_hours is None:
296
+ self._flow_hours = self._concat_property('flow_hours')
297
+ return self._flow_hours
298
+
299
+ @property
300
+ def flow_sizes(self) -> xr.Dataset:
301
+ """Combined flow investment sizes with 'case' dimension."""
302
+ if self._flow_sizes is None:
303
+ self._flow_sizes = self._concat_property('flow_sizes')
304
+ return self._flow_sizes
305
+
306
+ @property
307
+ def storage_sizes(self) -> xr.Dataset:
308
+ """Combined storage capacity sizes with 'case' dimension."""
309
+ if self._storage_sizes is None:
310
+ self._storage_sizes = self._concat_property('storage_sizes')
311
+ return self._storage_sizes
312
+
313
+ @property
314
+ def sizes(self) -> xr.Dataset:
315
+ """Combined sizes (flow + storage) with 'case' dimension."""
316
+ if self._sizes is None:
317
+ self._sizes = self._concat_property('sizes')
318
+ return self._sizes
319
+
320
+ @property
321
+ def charge_states(self) -> xr.Dataset:
322
+ """Combined storage charge states with 'case' dimension."""
323
+ if self._charge_states is None:
324
+ self._charge_states = self._concat_property('charge_states')
325
+ return self._charge_states
326
+
327
+ @property
328
+ def temporal_effects(self) -> xr.Dataset:
329
+ """Combined temporal effects with 'case' dimension."""
330
+ if self._temporal_effects is None:
331
+ self._temporal_effects = self._concat_property('temporal_effects')
332
+ return self._temporal_effects
333
+
334
+ @property
335
+ def periodic_effects(self) -> xr.Dataset:
336
+ """Combined periodic effects with 'case' dimension."""
337
+ if self._periodic_effects is None:
338
+ self._periodic_effects = self._concat_property('periodic_effects')
339
+ return self._periodic_effects
340
+
341
+ @property
342
+ def total_effects(self) -> xr.Dataset:
343
+ """Combined total effects with 'case' dimension."""
344
+ if self._total_effects is None:
345
+ self._total_effects = self._concat_property('total_effects')
346
+ return self._total_effects
347
+
348
+ @property
349
+ def carrier_colors(self) -> dict[str, str]:
350
+ """Merged carrier colors from all cases."""
351
+ if self._carrier_colors is None:
352
+ self._carrier_colors = self._merge_dict_property('carrier_colors')
353
+ return self._carrier_colors
354
+
355
+ @property
356
+ def component_colors(self) -> dict[str, str]:
357
+ """Merged component colors from all cases."""
358
+ if self._component_colors is None:
359
+ self._component_colors = self._merge_dict_property('component_colors')
360
+ return self._component_colors
361
+
362
+ @property
363
+ def bus_colors(self) -> dict[str, str]:
364
+ """Merged bus colors from all cases."""
365
+ if self._bus_colors is None:
366
+ self._bus_colors = self._merge_dict_property('bus_colors')
367
+ return self._bus_colors
368
+
369
+ @property
370
+ def carrier_units(self) -> dict[str, str]:
371
+ """Merged carrier units from all cases."""
372
+ if self._carrier_units is None:
373
+ self._carrier_units = self._merge_dict_property('carrier_units')
374
+ return self._carrier_units
375
+
376
+ @property
377
+ def effect_units(self) -> dict[str, str]:
378
+ """Merged effect units from all cases."""
379
+ if self._effect_units is None:
380
+ self._effect_units = self._merge_dict_property('effect_units')
381
+ return self._effect_units
382
+
383
+ @property
384
+ def plot(self) -> ComparisonStatisticsPlot:
385
+ """Access plot methods for comparison statistics."""
386
+ if self._plot is None:
387
+ self._plot = ComparisonStatisticsPlot(self)
388
+ return self._plot
389
+
390
+
391
+ class ComparisonStatisticsPlot:
392
+ """Plot accessor for comparison statistics.
393
+
394
+ Wraps StatisticsPlotAccessor methods, combining data from all FlowSystems
395
+ with a 'case' dimension for faceting.
396
+ """
397
+
398
+ def __init__(self, statistics: ComparisonStatistics) -> None:
399
+ self._stats = statistics
400
+ self._comp = statistics._comp
401
+
402
+ def _combine_data(self, method_name: str, *args, **kwargs) -> tuple[xr.Dataset, str]:
403
+ """Call plot method on each system and combine data. Returns (combined_data, title)."""
404
+ datasets = []
405
+ title = ''
406
+ # Use data_only=True to skip figure creation for performance
407
+ kwargs = {**kwargs, 'show': False, 'data_only': True}
408
+
409
+ for fs, case_name in zip(self._comp._systems, self._comp._names, strict=True):
410
+ try:
411
+ result = getattr(fs.statistics.plot, method_name)(*args, **kwargs)
412
+ datasets.append(result.data.expand_dims(case=[case_name]))
413
+ except (KeyError, ValueError) as e:
414
+ warnings.warn(
415
+ f"Skipping case '{case_name}' in {method_name}: {e}",
416
+ stacklevel=3,
417
+ )
418
+ continue
419
+
420
+ if not datasets:
421
+ return xr.Dataset(), ''
422
+
423
+ return xr.concat(datasets, dim='case', join='outer', fill_value=float('nan')), title
424
+
425
+ def _finalize(self, ds: xr.Dataset, fig, show: bool | None) -> PlotResult:
426
+ """Handle show and return PlotResult."""
427
+ import plotly.graph_objects as go
428
+
429
+ if show is None:
430
+ show = CONFIG.Plotting.default_show
431
+ if show and fig:
432
+ fig.show()
433
+ return PlotResult(data=ds, figure=fig or go.Figure())
434
+
435
+ def balance(
436
+ self,
437
+ node: str,
438
+ *,
439
+ select: SelectType | None = None,
440
+ include: str | list[str] | None = None,
441
+ exclude: str | list[str] | None = None,
442
+ unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate',
443
+ colors: ColorType | None = None,
444
+ show: bool | None = None,
445
+ **plotly_kwargs: Any,
446
+ ) -> PlotResult:
447
+ """Plot node balance comparison across cases.
448
+
449
+ Args:
450
+ node: Bus or component label to plot balance for.
451
+ select: xarray-style selection.
452
+ include: Filter to include only matching flow labels.
453
+ exclude: Filter to exclude matching flow labels.
454
+ unit: 'flow_rate' or 'flow_hours'.
455
+ colors: Color specification (dict, list, or colorscale name).
456
+ show: Whether to display the figure.
457
+ **plotly_kwargs: Additional arguments passed to plotly.
458
+
459
+ Returns:
460
+ PlotResult with combined balance data and figure.
461
+ """
462
+ ds, _ = self._combine_data('balance', node, select=select, include=include, exclude=exclude, unit=unit)
463
+ if not ds.data_vars:
464
+ return self._finalize(ds, None, show)
465
+
466
+ defaults = {'x': 'time', 'color': 'variable', 'pattern_shape': None, 'facet_col': 'case'}
467
+ _apply_slot_defaults(plotly_kwargs, defaults)
468
+ color_kwargs = _build_color_kwargs(colors, list(ds.data_vars))
469
+ fig = ds.plotly.bar(
470
+ title=f'{node} Balance Comparison',
471
+ **color_kwargs,
472
+ **plotly_kwargs,
473
+ )
474
+ fig.update_layout(barmode='relative', bargap=0, bargroupgap=0)
475
+ fig.update_traces(marker_line_width=0)
476
+ return self._finalize(ds, fig, show)
477
+
478
+ def carrier_balance(
479
+ self,
480
+ carrier: str,
481
+ *,
482
+ select: SelectType | None = None,
483
+ include: str | list[str] | None = None,
484
+ exclude: str | list[str] | None = None,
485
+ unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate',
486
+ colors: ColorType | None = None,
487
+ show: bool | None = None,
488
+ **plotly_kwargs: Any,
489
+ ) -> PlotResult:
490
+ """Plot carrier balance comparison across cases.
491
+
492
+ Args:
493
+ carrier: Carrier name to plot balance for.
494
+ select: xarray-style selection.
495
+ include: Filter to include only matching flow labels.
496
+ exclude: Filter to exclude matching flow labels.
497
+ unit: 'flow_rate' or 'flow_hours'.
498
+ colors: Color specification (dict, list, or colorscale name).
499
+ show: Whether to display the figure.
500
+ **plotly_kwargs: Additional arguments passed to plotly.
501
+
502
+ Returns:
503
+ PlotResult with combined carrier balance data and figure.
504
+ """
505
+ ds, _ = self._combine_data(
506
+ 'carrier_balance', carrier, select=select, include=include, exclude=exclude, unit=unit
507
+ )
508
+ if not ds.data_vars:
509
+ return self._finalize(ds, None, show)
510
+
511
+ defaults = {'x': 'time', 'color': 'variable', 'pattern_shape': None, 'facet_col': 'case'}
512
+ _apply_slot_defaults(plotly_kwargs, defaults)
513
+ color_kwargs = _build_color_kwargs(colors, list(ds.data_vars))
514
+ fig = ds.plotly.bar(
515
+ title=f'{carrier.capitalize()} Balance Comparison',
516
+ **color_kwargs,
517
+ **plotly_kwargs,
518
+ )
519
+ fig.update_layout(barmode='relative', bargap=0, bargroupgap=0)
520
+ fig.update_traces(marker_line_width=0)
521
+ return self._finalize(ds, fig, show)
522
+
523
+ def flows(
524
+ self,
525
+ *,
526
+ start: str | list[str] | None = None,
527
+ end: str | list[str] | None = None,
528
+ component: str | list[str] | None = None,
529
+ select: SelectType | None = None,
530
+ unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate',
531
+ colors: ColorType | None = None,
532
+ show: bool | None = None,
533
+ **plotly_kwargs: Any,
534
+ ) -> PlotResult:
535
+ """Plot flows comparison across cases.
536
+
537
+ Args:
538
+ start: Filter by source node(s).
539
+ end: Filter by destination node(s).
540
+ component: Filter by parent component(s).
541
+ select: xarray-style selection.
542
+ unit: 'flow_rate' or 'flow_hours'.
543
+ colors: Color specification (dict, list, or colorscale name).
544
+ show: Whether to display the figure.
545
+ **plotly_kwargs: Additional arguments passed to plotly.
546
+
547
+ Returns:
548
+ PlotResult with combined flows data and figure.
549
+ """
550
+ ds, _ = self._combine_data('flows', start=start, end=end, component=component, select=select, unit=unit)
551
+ if not ds.data_vars:
552
+ return self._finalize(ds, None, show)
553
+
554
+ defaults = {'x': 'time', 'color': 'variable', 'symbol': None, 'line_dash': 'case'}
555
+ _apply_slot_defaults(plotly_kwargs, defaults)
556
+ color_kwargs = _build_color_kwargs(colors, list(ds.data_vars))
557
+ fig = ds.plotly.line(
558
+ title='Flows Comparison',
559
+ **color_kwargs,
560
+ **plotly_kwargs,
561
+ )
562
+ return self._finalize(ds, fig, show)
563
+
564
+ def storage(
565
+ self,
566
+ storage: str,
567
+ *,
568
+ select: SelectType | None = None,
569
+ unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate',
570
+ colors: ColorType | None = None,
571
+ show: bool | None = None,
572
+ **plotly_kwargs: Any,
573
+ ) -> PlotResult:
574
+ """Plot storage operation comparison across cases.
575
+
576
+ Args:
577
+ storage: Storage component label.
578
+ select: xarray-style selection.
579
+ unit: 'flow_rate' or 'flow_hours'.
580
+ colors: Color specification for flow bars.
581
+ show: Whether to display the figure.
582
+ **plotly_kwargs: Additional arguments passed to plotly.
583
+
584
+ Returns:
585
+ PlotResult with combined storage operation data and figure.
586
+ """
587
+ ds, _ = self._combine_data('storage', storage, select=select, unit=unit)
588
+ if not ds.data_vars:
589
+ return self._finalize(ds, None, show)
590
+
591
+ # Separate flows from charge_state
592
+ flow_vars = [v for v in ds.data_vars if v != 'charge_state']
593
+ flow_ds = ds[flow_vars] if flow_vars else xr.Dataset()
594
+
595
+ defaults = {'x': 'time', 'color': 'variable', 'pattern_shape': None, 'facet_col': 'case'}
596
+ _apply_slot_defaults(plotly_kwargs, defaults)
597
+ color_kwargs = _build_color_kwargs(colors, flow_vars)
598
+ fig = flow_ds.plotly.bar(
599
+ title=f'{storage} Operation Comparison',
600
+ **color_kwargs,
601
+ **plotly_kwargs,
602
+ )
603
+ fig.update_layout(barmode='relative', bargap=0, bargroupgap=0)
604
+ fig.update_traces(marker_line_width=0)
605
+
606
+ # Add charge state as line overlay on secondary y-axis
607
+ if 'charge_state' in ds:
608
+ # Only pass faceting kwargs that add_line_overlay accepts
609
+ overlay_kwargs = {
610
+ k: v for k, v in plotly_kwargs.items() if k in ('x', 'facet_col', 'facet_row', 'animation_frame')
611
+ }
612
+ add_line_overlay(
613
+ fig,
614
+ ds['charge_state'],
615
+ color='case',
616
+ name='charge_state',
617
+ secondary_y=True,
618
+ y_title='Charge State',
619
+ **overlay_kwargs,
620
+ )
621
+
622
+ return self._finalize(ds, fig, show)
623
+
624
+ def charge_states(
625
+ self,
626
+ storages: str | list[str] | None = None,
627
+ *,
628
+ select: SelectType | None = None,
629
+ colors: ColorType | None = None,
630
+ show: bool | None = None,
631
+ **plotly_kwargs: Any,
632
+ ) -> PlotResult:
633
+ """Plot charge states comparison across cases.
634
+
635
+ Args:
636
+ storages: Storage label(s) to plot. If None, plots all.
637
+ select: xarray-style selection.
638
+ colors: Color specification (dict, list, or colorscale name).
639
+ show: Whether to display the figure.
640
+ **plotly_kwargs: Additional arguments passed to plotly.
641
+
642
+ Returns:
643
+ PlotResult with combined charge state data and figure.
644
+ """
645
+ ds, _ = self._combine_data('charge_states', storages, select=select)
646
+ if not ds.data_vars:
647
+ return self._finalize(ds, None, show)
648
+
649
+ defaults = {'x': 'time', 'color': 'variable', 'symbol': None, 'line_dash': 'case'}
650
+ _apply_slot_defaults(plotly_kwargs, defaults)
651
+ color_kwargs = _build_color_kwargs(colors, list(ds.data_vars))
652
+ fig = ds.plotly.line(
653
+ title='Charge States Comparison',
654
+ **color_kwargs,
655
+ **plotly_kwargs,
656
+ )
657
+ return self._finalize(ds, fig, show)
658
+
659
+ def duration_curve(
660
+ self,
661
+ variables: str | list[str],
662
+ *,
663
+ select: SelectType | None = None,
664
+ normalize: bool = False,
665
+ colors: ColorType | None = None,
666
+ show: bool | None = None,
667
+ **plotly_kwargs: Any,
668
+ ) -> PlotResult:
669
+ """Plot duration curves comparison across cases.
670
+
671
+ Args:
672
+ variables: Flow label(s) or variable name(s) to plot.
673
+ select: xarray-style selection.
674
+ normalize: If True, normalize x-axis to 0-100%.
675
+ colors: Color specification (dict, list, or colorscale name).
676
+ show: Whether to display the figure.
677
+ **plotly_kwargs: Additional arguments passed to plotly.
678
+
679
+ Returns:
680
+ PlotResult with combined duration curve data and figure.
681
+ """
682
+ ds, _ = self._combine_data('duration_curve', variables, select=select, normalize=normalize)
683
+ if not ds.data_vars:
684
+ return self._finalize(ds, None, show)
685
+
686
+ defaults = {
687
+ 'x': 'duration_pct' if normalize else 'duration',
688
+ 'color': 'variable',
689
+ 'symbol': None,
690
+ 'line_dash': 'case',
691
+ }
692
+ _apply_slot_defaults(plotly_kwargs, defaults)
693
+ color_kwargs = _build_color_kwargs(colors, list(ds.data_vars))
694
+ fig = ds.plotly.line(
695
+ title='Duration Curve Comparison',
696
+ **color_kwargs,
697
+ **plotly_kwargs,
698
+ )
699
+ return self._finalize(ds, fig, show)
700
+
701
+ def sizes(
702
+ self,
703
+ *,
704
+ max_size: float | None = 1e6,
705
+ select: SelectType | None = None,
706
+ colors: ColorType | None = None,
707
+ show: bool | None = None,
708
+ **plotly_kwargs: Any,
709
+ ) -> PlotResult:
710
+ """Plot investment sizes comparison across cases.
711
+
712
+ Args:
713
+ max_size: Maximum size to include (filters defaults).
714
+ select: xarray-style selection.
715
+ colors: Color specification (dict, list, or colorscale name).
716
+ show: Whether to display the figure.
717
+ **plotly_kwargs: Additional arguments passed to plotly.
718
+
719
+ Returns:
720
+ PlotResult with combined sizes data and figure.
721
+ """
722
+ ds, _ = self._combine_data('sizes', max_size=max_size, select=select)
723
+ if not ds.data_vars:
724
+ return self._finalize(ds, None, show)
725
+
726
+ defaults = {'x': 'variable', 'color': 'case'}
727
+ _apply_slot_defaults(plotly_kwargs, defaults)
728
+ color_kwargs = _build_color_kwargs(colors, list(ds.data_vars))
729
+ fig = ds.plotly.bar(
730
+ title='Investment Sizes Comparison',
731
+ labels={'value': 'Size'},
732
+ barmode='group',
733
+ **color_kwargs,
734
+ **plotly_kwargs,
735
+ )
736
+ return self._finalize(ds, fig, show)
737
+
738
+ def effects(
739
+ self,
740
+ aspect: Literal['total', 'temporal', 'periodic'] = 'total',
741
+ *,
742
+ effect: str | None = None,
743
+ by: Literal['component', 'contributor', 'time'] | None = None,
744
+ select: SelectType | None = None,
745
+ colors: ColorType | None = None,
746
+ show: bool | None = None,
747
+ **plotly_kwargs: Any,
748
+ ) -> PlotResult:
749
+ """Plot effects comparison across cases.
750
+
751
+ Args:
752
+ aspect: Which aspect to plot - 'total', 'temporal', or 'periodic'.
753
+ effect: Specific effect name to plot. If None, plots all.
754
+ by: Group by 'component', 'contributor', or 'time'.
755
+ select: xarray-style selection.
756
+ colors: Color specification (dict, list, or colorscale name).
757
+ show: Whether to display the figure.
758
+ **plotly_kwargs: Additional arguments passed to plotly.
759
+
760
+ Returns:
761
+ PlotResult with combined effects data and figure.
762
+ """
763
+ ds, _ = self._combine_data('effects', aspect, effect=effect, by=by, select=select)
764
+ if not ds.data_vars:
765
+ return self._finalize(ds, None, show)
766
+
767
+ defaults = {'x': by if by else 'variable', 'color': 'case'}
768
+ _apply_slot_defaults(plotly_kwargs, defaults)
769
+ color_kwargs = _build_color_kwargs(colors, list(ds.data_vars))
770
+ fig = ds.plotly.bar(
771
+ title=f'Effects Comparison ({aspect})',
772
+ barmode='group',
773
+ **color_kwargs,
774
+ **plotly_kwargs,
775
+ )
776
+ fig.update_layout(bargap=0, bargroupgap=0)
777
+ fig.update_traces(marker_line_width=0)
778
+ return self._finalize(ds, fig, show)
779
+
780
+ def heatmap(
781
+ self,
782
+ variables: str | list[str],
783
+ *,
784
+ select: SelectType | None = None,
785
+ reshape: tuple[str, str] | Literal['auto'] | None = 'auto',
786
+ colors: str | list[str] | None = None,
787
+ show: bool | None = None,
788
+ **plotly_kwargs: Any,
789
+ ) -> PlotResult:
790
+ """Plot heatmap comparison across cases.
791
+
792
+ Args:
793
+ variables: Flow label(s) or variable name(s) to plot.
794
+ select: xarray-style selection.
795
+ reshape: Time reshape frequencies, 'auto', or None.
796
+ colors: Colorscale name or list of colors.
797
+ show: Whether to display the figure.
798
+ **plotly_kwargs: Additional arguments passed to plotly.
799
+
800
+ Returns:
801
+ PlotResult with combined heatmap data and figure.
802
+ """
803
+ ds, _ = self._combine_data('heatmap', variables, select=select, reshape=reshape)
804
+ if not ds.data_vars:
805
+ return self._finalize(ds, None, show)
806
+
807
+ da = ds[next(iter(ds.data_vars))]
808
+
809
+ defaults = {'facet_col': 'case'}
810
+ _apply_slot_defaults(plotly_kwargs, defaults)
811
+ # Handle colorscale
812
+ if colors is not None and 'color_continuous_scale' not in plotly_kwargs:
813
+ plotly_kwargs['color_continuous_scale'] = colors
814
+
815
+ fig = da.plotly.imshow(
816
+ title='Heatmap Comparison',
817
+ **plotly_kwargs,
818
+ )
819
+ return self._finalize(ds, fig, show)