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.
- flixopt/__init__.py +57 -49
- flixopt/carrier.py +159 -0
- flixopt/clustering/__init__.py +51 -0
- flixopt/clustering/base.py +1746 -0
- flixopt/clustering/intercluster_helpers.py +201 -0
- flixopt/color_processing.py +372 -0
- flixopt/comparison.py +819 -0
- flixopt/components.py +848 -270
- flixopt/config.py +853 -496
- flixopt/core.py +111 -98
- flixopt/effects.py +294 -284
- flixopt/elements.py +484 -223
- flixopt/features.py +220 -118
- flixopt/flow_system.py +2026 -389
- flixopt/interface.py +504 -286
- flixopt/io.py +1718 -55
- flixopt/linear_converters.py +291 -230
- flixopt/modeling.py +304 -181
- flixopt/network_app.py +2 -1
- flixopt/optimization.py +788 -0
- flixopt/optimize_accessor.py +373 -0
- flixopt/plot_result.py +143 -0
- flixopt/plotting.py +1177 -1034
- flixopt/results.py +1331 -372
- flixopt/solvers.py +12 -4
- flixopt/statistics_accessor.py +2412 -0
- flixopt/stats_accessor.py +75 -0
- flixopt/structure.py +954 -120
- flixopt/topology_accessor.py +676 -0
- flixopt/transform_accessor.py +2277 -0
- flixopt/types.py +120 -0
- flixopt-6.0.0rc7.dist-info/METADATA +290 -0
- flixopt-6.0.0rc7.dist-info/RECORD +36 -0
- {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/WHEEL +1 -1
- flixopt/aggregation.py +0 -382
- flixopt/calculation.py +0 -672
- flixopt/commons.py +0 -51
- flixopt/utils.py +0 -86
- flixopt-3.0.1.dist-info/METADATA +0 -209
- flixopt-3.0.1.dist-info/RECORD +0 -26
- {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/licenses/LICENSE +0 -0
- {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)
|