flixopt 3.1.0__py3-none-any.whl → 3.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of flixopt might be problematic. Click here for more details.
- flixopt/aggregation.py +13 -4
- flixopt/calculation.py +2 -3
- flixopt/color_processing.py +261 -0
- flixopt/components.py +12 -10
- flixopt/config.py +59 -4
- flixopt/effects.py +11 -13
- flixopt/flow_system.py +5 -3
- flixopt/interface.py +2 -1
- flixopt/io.py +239 -22
- flixopt/plotting.py +583 -789
- flixopt/results.py +475 -70
- flixopt/structure.py +4 -9
- {flixopt-3.1.0.dist-info → flixopt-3.2.0.dist-info}/METADATA +2 -2
- flixopt-3.2.0.dist-info/RECORD +26 -0
- flixopt/utils.py +0 -86
- flixopt-3.1.0.dist-info/RECORD +0 -26
- {flixopt-3.1.0.dist-info → flixopt-3.2.0.dist-info}/WHEEL +0 -0
- {flixopt-3.1.0.dist-info → flixopt-3.2.0.dist-info}/licenses/LICENSE +0 -0
- {flixopt-3.1.0.dist-info → flixopt-3.2.0.dist-info}/top_level.txt +0 -0
flixopt/results.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import copy
|
|
3
4
|
import datetime
|
|
4
|
-
import json
|
|
5
5
|
import logging
|
|
6
6
|
import pathlib
|
|
7
7
|
import warnings
|
|
@@ -11,10 +11,11 @@ import linopy
|
|
|
11
11
|
import numpy as np
|
|
12
12
|
import pandas as pd
|
|
13
13
|
import xarray as xr
|
|
14
|
-
import yaml
|
|
15
14
|
|
|
16
15
|
from . import io as fx_io
|
|
17
16
|
from . import plotting
|
|
17
|
+
from .color_processing import process_colors
|
|
18
|
+
from .config import CONFIG
|
|
18
19
|
from .flow_system import FlowSystem
|
|
19
20
|
|
|
20
21
|
if TYPE_CHECKING:
|
|
@@ -29,6 +30,23 @@ if TYPE_CHECKING:
|
|
|
29
30
|
logger = logging.getLogger('flixopt')
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
def load_mapping_from_file(path: pathlib.Path) -> dict[str, str | list[str]]:
|
|
34
|
+
"""Load color mapping from JSON or YAML file.
|
|
35
|
+
|
|
36
|
+
Tries loader based on file suffix first, with fallback to the other format.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
path: Path to config file (.json or .yaml/.yml)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Dictionary mapping components to colors or colorscales to component lists
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValueError: If file cannot be loaded as JSON or YAML
|
|
46
|
+
"""
|
|
47
|
+
return fx_io.load_config_file(path)
|
|
48
|
+
|
|
49
|
+
|
|
32
50
|
class _FlowSystemRestorationError(Exception):
|
|
33
51
|
"""Exception raised when a FlowSystem cannot be restored from dataset."""
|
|
34
52
|
|
|
@@ -107,6 +125,20 @@ class CalculationResults:
|
|
|
107
125
|
).mean()
|
|
108
126
|
```
|
|
109
127
|
|
|
128
|
+
Configure automatic color management for plots:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
# Dict-based configuration:
|
|
132
|
+
results.setup_colors({'Solar*': 'Oranges', 'Wind*': 'Blues', 'Battery': 'green'})
|
|
133
|
+
|
|
134
|
+
# All plots automatically use configured colors (colors=None is the default)
|
|
135
|
+
results['ElectricityBus'].plot_node_balance()
|
|
136
|
+
results['Battery'].plot_charge_state()
|
|
137
|
+
|
|
138
|
+
# Override when needed
|
|
139
|
+
results['ElectricityBus'].plot_node_balance(colors='turbo') # Ignores setup
|
|
140
|
+
```
|
|
141
|
+
|
|
110
142
|
Design Patterns:
|
|
111
143
|
**Factory Methods**: Use `from_file()` and `from_calculation()` for creation or access directly from `Calculation.results`
|
|
112
144
|
**Dictionary Access**: Use `results[element_label]` for element-specific results
|
|
@@ -137,8 +169,7 @@ class CalculationResults:
|
|
|
137
169
|
except Exception as e:
|
|
138
170
|
logger.critical(f'Could not load the linopy model "{name}" from file ("{paths.linopy_model}"): {e}')
|
|
139
171
|
|
|
140
|
-
|
|
141
|
-
summary = yaml.load(f, Loader=yaml.FullLoader)
|
|
172
|
+
summary = fx_io.load_yaml(paths.summary)
|
|
142
173
|
|
|
143
174
|
return cls(
|
|
144
175
|
solution=fx_io.load_dataset_from_netcdf(paths.solution),
|
|
@@ -230,6 +261,7 @@ class CalculationResults:
|
|
|
230
261
|
self.timesteps_extra = self.solution.indexes['time']
|
|
231
262
|
self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.timesteps_extra)
|
|
232
263
|
self.scenarios = self.solution.indexes['scenario'] if 'scenario' in self.solution.indexes else None
|
|
264
|
+
self.periods = self.solution.indexes['period'] if 'period' in self.solution.indexes else None
|
|
233
265
|
|
|
234
266
|
self._effect_share_factors = None
|
|
235
267
|
self._flow_system = None
|
|
@@ -239,6 +271,8 @@ class CalculationResults:
|
|
|
239
271
|
self._sizes = None
|
|
240
272
|
self._effects_per_component = None
|
|
241
273
|
|
|
274
|
+
self.colors: dict[str, str] = {}
|
|
275
|
+
|
|
242
276
|
def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults:
|
|
243
277
|
if key in self.components:
|
|
244
278
|
return self.components[key]
|
|
@@ -305,6 +339,131 @@ class CalculationResults:
|
|
|
305
339
|
logger.level = old_level
|
|
306
340
|
return self._flow_system
|
|
307
341
|
|
|
342
|
+
def setup_colors(
|
|
343
|
+
self,
|
|
344
|
+
config: dict[str, str | list[str]] | str | pathlib.Path | None = None,
|
|
345
|
+
default_colorscale: str | None = None,
|
|
346
|
+
) -> dict[str, str]:
|
|
347
|
+
"""
|
|
348
|
+
Setup colors for all variables across all elements. Overwrites existing ones.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
config: Configuration for color assignment. Can be:
|
|
352
|
+
- dict: Maps components to colors/colorscales:
|
|
353
|
+
* 'component1': 'red' # Single component to single color
|
|
354
|
+
* 'component1': '#FF0000' # Single component to hex color
|
|
355
|
+
- OR maps colorscales to multiple components:
|
|
356
|
+
* 'colorscale_name': ['component1', 'component2'] # Colorscale across components
|
|
357
|
+
- str: Path to a JSON/YAML config file or a colorscale name to apply to all
|
|
358
|
+
- Path: Path to a JSON/YAML config file
|
|
359
|
+
- None: Use default_colorscale for all components
|
|
360
|
+
default_colorscale: Default colorscale for unconfigured components (default: 'turbo')
|
|
361
|
+
|
|
362
|
+
Examples:
|
|
363
|
+
setup_colors({
|
|
364
|
+
# Direct component-to-color mappings
|
|
365
|
+
'Boiler1': '#FF0000',
|
|
366
|
+
'CHP': 'darkred',
|
|
367
|
+
# Colorscale for multiple components
|
|
368
|
+
'Oranges': ['Solar1', 'Solar2'],
|
|
369
|
+
'Blues': ['Wind1', 'Wind2'],
|
|
370
|
+
'Greens': ['Battery1', 'Battery2', 'Battery3'],
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Complete variable-to-color mapping dictionary
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
def get_all_variable_names(comp: str) -> list[str]:
|
|
378
|
+
"""Collect all variables from the component, including flows and flow_hours."""
|
|
379
|
+
comp_object = self.components[comp]
|
|
380
|
+
var_names = [comp] + list(comp_object._variable_names)
|
|
381
|
+
for flow in comp_object.flows:
|
|
382
|
+
var_names.extend([flow, f'{flow}|flow_hours'])
|
|
383
|
+
return var_names
|
|
384
|
+
|
|
385
|
+
# Set default colorscale if not provided
|
|
386
|
+
if default_colorscale is None:
|
|
387
|
+
default_colorscale = CONFIG.Plotting.default_qualitative_colorscale
|
|
388
|
+
|
|
389
|
+
# Handle different config input types
|
|
390
|
+
if config is None:
|
|
391
|
+
# Apply default colorscale to all components
|
|
392
|
+
config_dict = {}
|
|
393
|
+
elif isinstance(config, (str, pathlib.Path)):
|
|
394
|
+
# Try to load from file first
|
|
395
|
+
config_path = pathlib.Path(config)
|
|
396
|
+
if config_path.exists():
|
|
397
|
+
# Load config from file using helper
|
|
398
|
+
config_dict = load_mapping_from_file(config_path)
|
|
399
|
+
else:
|
|
400
|
+
# Treat as colorscale name to apply to all components
|
|
401
|
+
all_components = list(self.components.keys())
|
|
402
|
+
config_dict = {config: all_components}
|
|
403
|
+
elif isinstance(config, dict):
|
|
404
|
+
config_dict = config
|
|
405
|
+
else:
|
|
406
|
+
raise TypeError(f'config must be dict, str, Path, or None, got {type(config)}')
|
|
407
|
+
|
|
408
|
+
# Step 1: Build component-to-color mapping
|
|
409
|
+
component_colors: dict[str, str] = {}
|
|
410
|
+
|
|
411
|
+
# Track which components are configured
|
|
412
|
+
configured_components = set()
|
|
413
|
+
|
|
414
|
+
# Process each configuration entry
|
|
415
|
+
for key, value in config_dict.items():
|
|
416
|
+
# Check if value is a list (colorscale -> [components])
|
|
417
|
+
# or a string (component -> color OR colorscale -> [components])
|
|
418
|
+
|
|
419
|
+
if isinstance(value, list):
|
|
420
|
+
# key is colorscale, value is list of components
|
|
421
|
+
# Format: 'Blues': ['Wind1', 'Wind2']
|
|
422
|
+
components = value
|
|
423
|
+
colorscale_name = key
|
|
424
|
+
|
|
425
|
+
# Validate components exist
|
|
426
|
+
for component in components:
|
|
427
|
+
if component not in self.components:
|
|
428
|
+
raise ValueError(f"Component '{component}' not found")
|
|
429
|
+
|
|
430
|
+
configured_components.update(components)
|
|
431
|
+
|
|
432
|
+
# Use process_colors to get one color per component from the colorscale
|
|
433
|
+
colors_for_components = process_colors(colorscale_name, components)
|
|
434
|
+
component_colors.update(colors_for_components)
|
|
435
|
+
|
|
436
|
+
elif isinstance(value, str):
|
|
437
|
+
# Check if key is an existing component
|
|
438
|
+
if key in self.components:
|
|
439
|
+
# Format: 'CHP': 'red' (component -> color)
|
|
440
|
+
component, color = key, value
|
|
441
|
+
|
|
442
|
+
configured_components.add(component)
|
|
443
|
+
component_colors[component] = color
|
|
444
|
+
else:
|
|
445
|
+
raise ValueError(f"Component '{key}' not found")
|
|
446
|
+
else:
|
|
447
|
+
raise TypeError(f'Config value must be str or list, got {type(value)}')
|
|
448
|
+
|
|
449
|
+
# Step 2: Assign colors to remaining unconfigured components
|
|
450
|
+
remaining_components = list(set(self.components.keys()) - configured_components)
|
|
451
|
+
if remaining_components:
|
|
452
|
+
# Use default colorscale to assign one color per remaining component
|
|
453
|
+
default_colors = process_colors(default_colorscale, remaining_components)
|
|
454
|
+
component_colors.update(default_colors)
|
|
455
|
+
|
|
456
|
+
# Step 3: Build variable-to-color mapping
|
|
457
|
+
# Clear existing colors to avoid stale keys
|
|
458
|
+
self.colors = {}
|
|
459
|
+
# Each component's variables all get the same color as the component
|
|
460
|
+
for component, color in component_colors.items():
|
|
461
|
+
variable_names = get_all_variable_names(component)
|
|
462
|
+
for var_name in variable_names:
|
|
463
|
+
self.colors[var_name] = color
|
|
464
|
+
|
|
465
|
+
return self.colors
|
|
466
|
+
|
|
308
467
|
def filter_solution(
|
|
309
468
|
self,
|
|
310
469
|
variable_dims: Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly'] | None = None,
|
|
@@ -619,6 +778,30 @@ class CalculationResults:
|
|
|
619
778
|
total = xr.DataArray(np.nan)
|
|
620
779
|
return total.rename(f'{element}->{effect}({mode})')
|
|
621
780
|
|
|
781
|
+
def _create_template_for_mode(self, mode: Literal['temporal', 'periodic', 'total']) -> xr.DataArray:
|
|
782
|
+
"""Create a template DataArray with the correct dimensions for a given mode.
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
mode: The calculation mode ('temporal', 'periodic', or 'total').
|
|
786
|
+
|
|
787
|
+
Returns:
|
|
788
|
+
A DataArray filled with NaN, with dimensions appropriate for the mode.
|
|
789
|
+
"""
|
|
790
|
+
coords = {}
|
|
791
|
+
if mode == 'temporal':
|
|
792
|
+
coords['time'] = self.timesteps_extra
|
|
793
|
+
if self.periods is not None:
|
|
794
|
+
coords['period'] = self.periods
|
|
795
|
+
if self.scenarios is not None:
|
|
796
|
+
coords['scenario'] = self.scenarios
|
|
797
|
+
|
|
798
|
+
# Create template with appropriate shape
|
|
799
|
+
if coords:
|
|
800
|
+
shape = tuple(len(coords[dim]) for dim in coords)
|
|
801
|
+
return xr.DataArray(np.full(shape, np.nan, dtype=float), coords=coords, dims=list(coords.keys()))
|
|
802
|
+
else:
|
|
803
|
+
return xr.DataArray(np.nan)
|
|
804
|
+
|
|
622
805
|
def _create_effects_dataset(self, mode: Literal['temporal', 'periodic', 'total']) -> xr.Dataset:
|
|
623
806
|
"""Creates a dataset containing effect totals for all components (including their flows).
|
|
624
807
|
The dataset does contain the direct as well as the indirect effects of each component.
|
|
@@ -629,32 +812,23 @@ class CalculationResults:
|
|
|
629
812
|
Returns:
|
|
630
813
|
An xarray Dataset with components as dimension and effects as variables.
|
|
631
814
|
"""
|
|
815
|
+
# Create template with correct dimensions for this mode
|
|
816
|
+
template = self._create_template_for_mode(mode)
|
|
817
|
+
|
|
632
818
|
ds = xr.Dataset()
|
|
633
819
|
all_arrays = {}
|
|
634
|
-
template = None # Template is needed to determine the dimensions of the arrays. This handles the case of no shares for an effect
|
|
635
|
-
|
|
636
820
|
components_list = list(self.components)
|
|
637
821
|
|
|
638
|
-
#
|
|
822
|
+
# Collect arrays for all effects and components
|
|
639
823
|
for effect in self.effects:
|
|
640
824
|
effect_arrays = []
|
|
641
825
|
for component in components_list:
|
|
642
826
|
da = self._compute_effect_total(element=component, effect=effect, mode=mode, include_flows=True)
|
|
643
827
|
effect_arrays.append(da)
|
|
644
828
|
|
|
645
|
-
if template is None and (da.dims or not da.isnull().all()):
|
|
646
|
-
template = da
|
|
647
|
-
|
|
648
829
|
all_arrays[effect] = effect_arrays
|
|
649
830
|
|
|
650
|
-
#
|
|
651
|
-
if template is None:
|
|
652
|
-
raise ValueError(
|
|
653
|
-
f"No template with proper dimensions found for mode '{mode}'. "
|
|
654
|
-
f'All computed arrays are scalars, which indicates a data issue.'
|
|
655
|
-
)
|
|
656
|
-
|
|
657
|
-
# Second pass: process all effects (guaranteed to include all)
|
|
831
|
+
# Process all effects: expand scalar NaN arrays to match template dimensions
|
|
658
832
|
for effect in self.effects:
|
|
659
833
|
dataarrays = all_arrays[effect]
|
|
660
834
|
component_arrays = []
|
|
@@ -689,13 +863,13 @@ class CalculationResults:
|
|
|
689
863
|
self,
|
|
690
864
|
variable_name: str | list[str],
|
|
691
865
|
save: bool | pathlib.Path = False,
|
|
692
|
-
show: bool =
|
|
693
|
-
colors: plotting.ColorType =
|
|
866
|
+
show: bool | None = None,
|
|
867
|
+
colors: plotting.ColorType | None = None,
|
|
694
868
|
engine: plotting.PlottingEngine = 'plotly',
|
|
695
869
|
select: dict[FlowSystemDimensions, Any] | None = None,
|
|
696
870
|
facet_by: str | list[str] | None = 'scenario',
|
|
697
871
|
animate_by: str | None = 'period',
|
|
698
|
-
facet_cols: int =
|
|
872
|
+
facet_cols: int | None = None,
|
|
699
873
|
reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']]
|
|
700
874
|
| Literal['auto']
|
|
701
875
|
| None = 'auto',
|
|
@@ -705,6 +879,7 @@ class CalculationResults:
|
|
|
705
879
|
heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None,
|
|
706
880
|
heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None,
|
|
707
881
|
color_map: str | None = None,
|
|
882
|
+
**plot_kwargs: Any,
|
|
708
883
|
) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]:
|
|
709
884
|
"""
|
|
710
885
|
Plots a heatmap visualization of a variable using imshow or time-based reshaping.
|
|
@@ -738,6 +913,20 @@ class CalculationResults:
|
|
|
738
913
|
Supported timeframes: 'YS', 'MS', 'W', 'D', 'h', '15min', 'min'
|
|
739
914
|
fill: Method to fill missing values after reshape: 'ffill' (forward fill) or 'bfill' (backward fill).
|
|
740
915
|
Default is 'ffill'.
|
|
916
|
+
**plot_kwargs: Additional plotting customization options.
|
|
917
|
+
Common options:
|
|
918
|
+
|
|
919
|
+
- **dpi** (int): Export resolution for saved plots. Default: 300.
|
|
920
|
+
|
|
921
|
+
For heatmaps specifically:
|
|
922
|
+
|
|
923
|
+
- **vmin** (float): Minimum value for color scale (both engines).
|
|
924
|
+
- **vmax** (float): Maximum value for color scale (both engines).
|
|
925
|
+
|
|
926
|
+
For Matplotlib heatmaps:
|
|
927
|
+
|
|
928
|
+
- **imshow_kwargs** (dict): Additional kwargs for matplotlib's imshow (e.g., interpolation, aspect).
|
|
929
|
+
- **cbar_kwargs** (dict): Additional kwargs for colorbar customization.
|
|
741
930
|
|
|
742
931
|
Examples:
|
|
743
932
|
Direct imshow mode (default):
|
|
@@ -778,6 +967,18 @@ class CalculationResults:
|
|
|
778
967
|
... animate_by='period',
|
|
779
968
|
... reshape_time=('D', 'h'),
|
|
780
969
|
... )
|
|
970
|
+
|
|
971
|
+
High-resolution export with custom color range:
|
|
972
|
+
|
|
973
|
+
>>> results.plot_heatmap('Battery|charge_state', save=True, dpi=600, vmin=0, vmax=100)
|
|
974
|
+
|
|
975
|
+
Matplotlib heatmap with custom imshow settings:
|
|
976
|
+
|
|
977
|
+
>>> results.plot_heatmap(
|
|
978
|
+
... 'Boiler(Q_th)|flow_rate',
|
|
979
|
+
... engine='matplotlib',
|
|
980
|
+
... imshow_kwargs={'interpolation': 'bilinear', 'aspect': 'auto'},
|
|
981
|
+
... )
|
|
781
982
|
"""
|
|
782
983
|
# Delegate to module-level plot_heatmap function
|
|
783
984
|
return plot_heatmap(
|
|
@@ -798,6 +999,7 @@ class CalculationResults:
|
|
|
798
999
|
heatmap_timeframes=heatmap_timeframes,
|
|
799
1000
|
heatmap_timesteps_per_frame=heatmap_timesteps_per_frame,
|
|
800
1001
|
color_map=color_map,
|
|
1002
|
+
**plot_kwargs,
|
|
801
1003
|
)
|
|
802
1004
|
|
|
803
1005
|
def plot_network(
|
|
@@ -854,14 +1056,13 @@ class CalculationResults:
|
|
|
854
1056
|
fx_io.save_dataset_to_netcdf(self.solution, paths.solution, compression=compression)
|
|
855
1057
|
fx_io.save_dataset_to_netcdf(self.flow_system_data, paths.flow_system, compression=compression)
|
|
856
1058
|
|
|
857
|
-
|
|
858
|
-
yaml.dump(self.summary, f, allow_unicode=True, sort_keys=False, indent=4, width=1000)
|
|
1059
|
+
fx_io.save_yaml(self.summary, paths.summary)
|
|
859
1060
|
|
|
860
1061
|
if save_linopy_model:
|
|
861
1062
|
if self.model is None:
|
|
862
1063
|
logger.critical('No model in the CalculationResults. Saving the model is not possible.')
|
|
863
1064
|
else:
|
|
864
|
-
self.model.to_netcdf(paths.linopy_model, engine='
|
|
1065
|
+
self.model.to_netcdf(paths.linopy_model, engine='netcdf4')
|
|
865
1066
|
|
|
866
1067
|
if document_model:
|
|
867
1068
|
if self.model is None:
|
|
@@ -966,8 +1167,8 @@ class _NodeResults(_ElementResults):
|
|
|
966
1167
|
def plot_node_balance(
|
|
967
1168
|
self,
|
|
968
1169
|
save: bool | pathlib.Path = False,
|
|
969
|
-
show: bool =
|
|
970
|
-
colors: plotting.ColorType =
|
|
1170
|
+
show: bool | None = None,
|
|
1171
|
+
colors: plotting.ColorType | None = None,
|
|
971
1172
|
engine: plotting.PlottingEngine = 'plotly',
|
|
972
1173
|
select: dict[FlowSystemDimensions, Any] | None = None,
|
|
973
1174
|
unit_type: Literal['flow_rate', 'flow_hours'] = 'flow_rate',
|
|
@@ -975,9 +1176,10 @@ class _NodeResults(_ElementResults):
|
|
|
975
1176
|
drop_suffix: bool = True,
|
|
976
1177
|
facet_by: str | list[str] | None = 'scenario',
|
|
977
1178
|
animate_by: str | None = 'period',
|
|
978
|
-
facet_cols: int =
|
|
1179
|
+
facet_cols: int | None = None,
|
|
979
1180
|
# Deprecated parameter (kept for backwards compatibility)
|
|
980
1181
|
indexer: dict[FlowSystemDimensions, Any] | None = None,
|
|
1182
|
+
**plot_kwargs: Any,
|
|
981
1183
|
) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]:
|
|
982
1184
|
"""
|
|
983
1185
|
Plots the node balance of the Component or Bus with optional faceting and animation.
|
|
@@ -1005,6 +1207,27 @@ class _NodeResults(_ElementResults):
|
|
|
1005
1207
|
animate_by: Dimension to animate over (Plotly only). Creates animation frames that cycle through
|
|
1006
1208
|
dimension values. Only one dimension can be animated. Ignored if not found.
|
|
1007
1209
|
facet_cols: Number of columns in the facet grid layout (default: 3).
|
|
1210
|
+
**plot_kwargs: Additional plotting customization options passed to underlying plotting functions.
|
|
1211
|
+
|
|
1212
|
+
Common options:
|
|
1213
|
+
|
|
1214
|
+
- **dpi** (int): Export resolution in dots per inch. Default: 300.
|
|
1215
|
+
|
|
1216
|
+
**For Plotly engine** (`engine='plotly'`):
|
|
1217
|
+
|
|
1218
|
+
- Any Plotly Express parameter for px.bar()/px.line()/px.area()
|
|
1219
|
+
Example: `range_y=[0, 100]`, `line_shape='linear'`
|
|
1220
|
+
|
|
1221
|
+
**For Matplotlib engine** (`engine='matplotlib'`):
|
|
1222
|
+
|
|
1223
|
+
- **plot_kwargs** (dict): Customize plot via `ax.bar()` or `ax.step()`.
|
|
1224
|
+
Example: `plot_kwargs={'linewidth': 3, 'alpha': 0.7, 'edgecolor': 'black'}`
|
|
1225
|
+
|
|
1226
|
+
See :func:`flixopt.plotting.with_plotly` and :func:`flixopt.plotting.with_matplotlib`
|
|
1227
|
+
for complete parameter reference.
|
|
1228
|
+
|
|
1229
|
+
Note: For Plotly, you can further customize the returned figure using `fig.update_traces()`
|
|
1230
|
+
and `fig.update_layout()` after calling this method.
|
|
1008
1231
|
|
|
1009
1232
|
Examples:
|
|
1010
1233
|
Basic plot (current behavior):
|
|
@@ -1036,6 +1259,25 @@ class _NodeResults(_ElementResults):
|
|
|
1036
1259
|
Time range selection (summer months only):
|
|
1037
1260
|
|
|
1038
1261
|
>>> results['Boiler'].plot_node_balance(select={'time': slice('2024-06', '2024-08')}, facet_by='scenario')
|
|
1262
|
+
|
|
1263
|
+
High-resolution export for publication:
|
|
1264
|
+
|
|
1265
|
+
>>> results['Boiler'].plot_node_balance(engine='matplotlib', save='figure.png', dpi=600)
|
|
1266
|
+
|
|
1267
|
+
Plotly Express customization (e.g., set y-axis range):
|
|
1268
|
+
|
|
1269
|
+
>>> results['Boiler'].plot_node_balance(range_y=[0, 100])
|
|
1270
|
+
|
|
1271
|
+
Custom matplotlib appearance:
|
|
1272
|
+
|
|
1273
|
+
>>> results['Boiler'].plot_node_balance(engine='matplotlib', plot_kwargs={'linewidth': 3, 'alpha': 0.7})
|
|
1274
|
+
|
|
1275
|
+
Further customize Plotly figure after creation:
|
|
1276
|
+
|
|
1277
|
+
>>> fig = results['Boiler'].plot_node_balance(mode='line', show=False)
|
|
1278
|
+
>>> fig.update_traces(line={'width': 5, 'dash': 'dot'})
|
|
1279
|
+
>>> fig.update_layout(template='plotly_dark', width=1200, height=600)
|
|
1280
|
+
>>> fig.show()
|
|
1039
1281
|
"""
|
|
1040
1282
|
# Handle deprecated indexer parameter
|
|
1041
1283
|
if indexer is not None:
|
|
@@ -1057,8 +1299,11 @@ class _NodeResults(_ElementResults):
|
|
|
1057
1299
|
if engine not in {'plotly', 'matplotlib'}:
|
|
1058
1300
|
raise ValueError(f'Engine "{engine}" not supported. Use one of ["plotly", "matplotlib"]')
|
|
1059
1301
|
|
|
1302
|
+
# Extract dpi for export_figure
|
|
1303
|
+
dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi
|
|
1304
|
+
|
|
1060
1305
|
# Don't pass select/indexer to node_balance - we'll apply it afterwards
|
|
1061
|
-
ds = self.node_balance(with_last_timestep=
|
|
1306
|
+
ds = self.node_balance(with_last_timestep=False, unit_type=unit_type, drop_suffix=drop_suffix)
|
|
1062
1307
|
|
|
1063
1308
|
ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True)
|
|
1064
1309
|
|
|
@@ -1081,18 +1326,21 @@ class _NodeResults(_ElementResults):
|
|
|
1081
1326
|
ds,
|
|
1082
1327
|
facet_by=facet_by,
|
|
1083
1328
|
animate_by=animate_by,
|
|
1084
|
-
colors=colors,
|
|
1329
|
+
colors=colors if colors is not None else self._calculation_results.colors,
|
|
1085
1330
|
mode=mode,
|
|
1086
1331
|
title=title,
|
|
1087
1332
|
facet_cols=facet_cols,
|
|
1333
|
+
xlabel='Time in h',
|
|
1334
|
+
**plot_kwargs,
|
|
1088
1335
|
)
|
|
1089
1336
|
default_filetype = '.html'
|
|
1090
1337
|
else:
|
|
1091
1338
|
figure_like = plotting.with_matplotlib(
|
|
1092
|
-
ds
|
|
1093
|
-
colors=colors,
|
|
1339
|
+
ds,
|
|
1340
|
+
colors=colors if colors is not None else self._calculation_results.colors,
|
|
1094
1341
|
mode=mode,
|
|
1095
1342
|
title=title,
|
|
1343
|
+
**plot_kwargs,
|
|
1096
1344
|
)
|
|
1097
1345
|
default_filetype = '.png'
|
|
1098
1346
|
|
|
@@ -1103,19 +1351,21 @@ class _NodeResults(_ElementResults):
|
|
|
1103
1351
|
user_path=None if isinstance(save, bool) else pathlib.Path(save),
|
|
1104
1352
|
show=show,
|
|
1105
1353
|
save=True if save else False,
|
|
1354
|
+
dpi=dpi,
|
|
1106
1355
|
)
|
|
1107
1356
|
|
|
1108
1357
|
def plot_node_balance_pie(
|
|
1109
1358
|
self,
|
|
1110
1359
|
lower_percentage_group: float = 5,
|
|
1111
|
-
colors: plotting.ColorType =
|
|
1360
|
+
colors: plotting.ColorType | None = None,
|
|
1112
1361
|
text_info: str = 'percent+label+value',
|
|
1113
1362
|
save: bool | pathlib.Path = False,
|
|
1114
|
-
show: bool =
|
|
1363
|
+
show: bool | None = None,
|
|
1115
1364
|
engine: plotting.PlottingEngine = 'plotly',
|
|
1116
1365
|
select: dict[FlowSystemDimensions, Any] | None = None,
|
|
1117
1366
|
# Deprecated parameter (kept for backwards compatibility)
|
|
1118
1367
|
indexer: dict[FlowSystemDimensions, Any] | None = None,
|
|
1368
|
+
**plot_kwargs: Any,
|
|
1119
1369
|
) -> plotly.graph_objs.Figure | tuple[plt.Figure, list[plt.Axes]]:
|
|
1120
1370
|
"""Plot pie chart of flow hours distribution.
|
|
1121
1371
|
|
|
@@ -1135,6 +1385,17 @@ class _NodeResults(_ElementResults):
|
|
|
1135
1385
|
engine: Plotting engine ('plotly' or 'matplotlib').
|
|
1136
1386
|
select: Optional data selection dict. Supports single values, lists, slices, and index arrays.
|
|
1137
1387
|
Use this to select specific scenario/period before creating the pie chart.
|
|
1388
|
+
**plot_kwargs: Additional plotting customization options.
|
|
1389
|
+
|
|
1390
|
+
Common options:
|
|
1391
|
+
|
|
1392
|
+
- **dpi** (int): Export resolution in dots per inch. Default: 300.
|
|
1393
|
+
- **hover_template** (str): Hover text template (Plotly only).
|
|
1394
|
+
Example: `hover_template='%{label}: %{value} (%{percent})'`
|
|
1395
|
+
- **text_position** (str): Text position ('inside', 'outside', 'auto').
|
|
1396
|
+
- **hole** (float): Size of donut hole (0.0 to 1.0).
|
|
1397
|
+
|
|
1398
|
+
See :func:`flixopt.plotting.dual_pie_with_plotly` for complete reference.
|
|
1138
1399
|
|
|
1139
1400
|
Examples:
|
|
1140
1401
|
Basic usage (auto-selects first scenario/period if present):
|
|
@@ -1144,6 +1405,14 @@ class _NodeResults(_ElementResults):
|
|
|
1144
1405
|
Explicitly select a scenario and period:
|
|
1145
1406
|
|
|
1146
1407
|
>>> results['Bus'].plot_node_balance_pie(select={'scenario': 'high_demand', 'period': 2030})
|
|
1408
|
+
|
|
1409
|
+
Create a donut chart with custom hover text:
|
|
1410
|
+
|
|
1411
|
+
>>> results['Bus'].plot_node_balance_pie(hole=0.4, hover_template='%{label}: %{value:.2f} (%{percent})')
|
|
1412
|
+
|
|
1413
|
+
High-resolution export:
|
|
1414
|
+
|
|
1415
|
+
>>> results['Bus'].plot_node_balance_pie(save='figure.png', dpi=600)
|
|
1147
1416
|
"""
|
|
1148
1417
|
# Handle deprecated indexer parameter
|
|
1149
1418
|
if indexer is not None:
|
|
@@ -1162,6 +1431,9 @@ class _NodeResults(_ElementResults):
|
|
|
1162
1431
|
)
|
|
1163
1432
|
select = indexer
|
|
1164
1433
|
|
|
1434
|
+
# Extract dpi for export_figure
|
|
1435
|
+
dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi
|
|
1436
|
+
|
|
1165
1437
|
inputs = sanitize_dataset(
|
|
1166
1438
|
ds=self.solution[self.inputs] * self._calculation_results.hours_per_timestep,
|
|
1167
1439
|
threshold=1e-5,
|
|
@@ -1177,8 +1449,9 @@ class _NodeResults(_ElementResults):
|
|
|
1177
1449
|
drop_suffix='|',
|
|
1178
1450
|
)
|
|
1179
1451
|
|
|
1180
|
-
inputs,
|
|
1181
|
-
outputs,
|
|
1452
|
+
inputs, suffix_parts_in = _apply_selection_to_data(inputs, select=select, drop=True)
|
|
1453
|
+
outputs, suffix_parts_out = _apply_selection_to_data(outputs, select=select, drop=True)
|
|
1454
|
+
suffix_parts = suffix_parts_in + suffix_parts_out
|
|
1182
1455
|
|
|
1183
1456
|
# Sum over time dimension
|
|
1184
1457
|
inputs = inputs.sum('time')
|
|
@@ -1188,7 +1461,7 @@ class _NodeResults(_ElementResults):
|
|
|
1188
1461
|
# Pie charts need scalar data, so we automatically reduce extra dimensions
|
|
1189
1462
|
extra_dims_inputs = [dim for dim in inputs.dims if dim != 'time']
|
|
1190
1463
|
extra_dims_outputs = [dim for dim in outputs.dims if dim != 'time']
|
|
1191
|
-
extra_dims =
|
|
1464
|
+
extra_dims = sorted(set(extra_dims_inputs + extra_dims_outputs))
|
|
1192
1465
|
|
|
1193
1466
|
if extra_dims:
|
|
1194
1467
|
auto_select = {}
|
|
@@ -1206,27 +1479,28 @@ class _NodeResults(_ElementResults):
|
|
|
1206
1479
|
f'Use select={{"{dim}": value}} to choose a different value.'
|
|
1207
1480
|
)
|
|
1208
1481
|
|
|
1209
|
-
# Apply auto-selection
|
|
1210
|
-
inputs = inputs.sel(auto_select)
|
|
1211
|
-
outputs = outputs.sel(auto_select)
|
|
1482
|
+
# Apply auto-selection only for coords present in each dataset
|
|
1483
|
+
inputs = inputs.sel({k: v for k, v in auto_select.items() if k in inputs.coords})
|
|
1484
|
+
outputs = outputs.sel({k: v for k, v in auto_select.items() if k in outputs.coords})
|
|
1212
1485
|
|
|
1213
1486
|
# Update suffix with auto-selected values
|
|
1214
1487
|
auto_suffix_parts = [f'{dim}={val}' for dim, val in auto_select.items()]
|
|
1215
1488
|
suffix_parts.extend(auto_suffix_parts)
|
|
1216
1489
|
|
|
1217
|
-
suffix = '--' + '-'.join(suffix_parts) if suffix_parts else ''
|
|
1490
|
+
suffix = '--' + '-'.join(sorted(set(suffix_parts))) if suffix_parts else ''
|
|
1218
1491
|
title = f'{self.label} (total flow hours){suffix}'
|
|
1219
1492
|
|
|
1220
1493
|
if engine == 'plotly':
|
|
1221
1494
|
figure_like = plotting.dual_pie_with_plotly(
|
|
1222
|
-
data_left=inputs
|
|
1223
|
-
data_right=outputs
|
|
1224
|
-
colors=colors,
|
|
1495
|
+
data_left=inputs,
|
|
1496
|
+
data_right=outputs,
|
|
1497
|
+
colors=colors if colors is not None else self._calculation_results.colors,
|
|
1225
1498
|
title=title,
|
|
1226
1499
|
text_info=text_info,
|
|
1227
1500
|
subtitles=('Inputs', 'Outputs'),
|
|
1228
1501
|
legend_title='Flows',
|
|
1229
1502
|
lower_percentage_group=lower_percentage_group,
|
|
1503
|
+
**plot_kwargs,
|
|
1230
1504
|
)
|
|
1231
1505
|
default_filetype = '.html'
|
|
1232
1506
|
elif engine == 'matplotlib':
|
|
@@ -1234,11 +1508,12 @@ class _NodeResults(_ElementResults):
|
|
|
1234
1508
|
figure_like = plotting.dual_pie_with_matplotlib(
|
|
1235
1509
|
data_left=inputs.to_pandas(),
|
|
1236
1510
|
data_right=outputs.to_pandas(),
|
|
1237
|
-
colors=colors,
|
|
1511
|
+
colors=colors if colors is not None else self._calculation_results.colors,
|
|
1238
1512
|
title=title,
|
|
1239
1513
|
subtitles=('Inputs', 'Outputs'),
|
|
1240
1514
|
legend_title='Flows',
|
|
1241
1515
|
lower_percentage_group=lower_percentage_group,
|
|
1516
|
+
**plot_kwargs,
|
|
1242
1517
|
)
|
|
1243
1518
|
default_filetype = '.png'
|
|
1244
1519
|
else:
|
|
@@ -1251,6 +1526,7 @@ class _NodeResults(_ElementResults):
|
|
|
1251
1526
|
user_path=None if isinstance(save, bool) else pathlib.Path(save),
|
|
1252
1527
|
show=show,
|
|
1253
1528
|
save=True if save else False,
|
|
1529
|
+
dpi=dpi,
|
|
1254
1530
|
)
|
|
1255
1531
|
|
|
1256
1532
|
def node_balance(
|
|
@@ -1347,16 +1623,17 @@ class ComponentResults(_NodeResults):
|
|
|
1347
1623
|
def plot_charge_state(
|
|
1348
1624
|
self,
|
|
1349
1625
|
save: bool | pathlib.Path = False,
|
|
1350
|
-
show: bool =
|
|
1351
|
-
colors: plotting.ColorType =
|
|
1626
|
+
show: bool | None = None,
|
|
1627
|
+
colors: plotting.ColorType | None = None,
|
|
1352
1628
|
engine: plotting.PlottingEngine = 'plotly',
|
|
1353
1629
|
mode: Literal['area', 'stacked_bar', 'line'] = 'area',
|
|
1354
1630
|
select: dict[FlowSystemDimensions, Any] | None = None,
|
|
1355
1631
|
facet_by: str | list[str] | None = 'scenario',
|
|
1356
1632
|
animate_by: str | None = 'period',
|
|
1357
|
-
facet_cols: int =
|
|
1633
|
+
facet_cols: int | None = None,
|
|
1358
1634
|
# Deprecated parameter (kept for backwards compatibility)
|
|
1359
1635
|
indexer: dict[FlowSystemDimensions, Any] | None = None,
|
|
1636
|
+
**plot_kwargs: Any,
|
|
1360
1637
|
) -> plotly.graph_objs.Figure:
|
|
1361
1638
|
"""Plot storage charge state over time, combined with the node balance with optional faceting and animation.
|
|
1362
1639
|
|
|
@@ -1373,6 +1650,26 @@ class ComponentResults(_NodeResults):
|
|
|
1373
1650
|
animate_by: Dimension to animate over (Plotly only). Creates animation frames that cycle through
|
|
1374
1651
|
dimension values. Only one dimension can be animated. Ignored if not found.
|
|
1375
1652
|
facet_cols: Number of columns in the facet grid layout (default: 3).
|
|
1653
|
+
**plot_kwargs: Additional plotting customization options passed to underlying plotting functions.
|
|
1654
|
+
|
|
1655
|
+
Common options:
|
|
1656
|
+
|
|
1657
|
+
- **dpi** (int): Export resolution in dots per inch. Default: 300.
|
|
1658
|
+
|
|
1659
|
+
**For Plotly engine:**
|
|
1660
|
+
|
|
1661
|
+
- Any Plotly Express parameter for px.bar()/px.line()/px.area()
|
|
1662
|
+
Example: `range_y=[0, 100]`, `line_shape='linear'`
|
|
1663
|
+
|
|
1664
|
+
**For Matplotlib engine:**
|
|
1665
|
+
|
|
1666
|
+
- **plot_kwargs** (dict): Customize plot via `ax.bar()` or `ax.step()`.
|
|
1667
|
+
|
|
1668
|
+
See :func:`flixopt.plotting.with_plotly` and :func:`flixopt.plotting.with_matplotlib`
|
|
1669
|
+
for complete parameter reference.
|
|
1670
|
+
|
|
1671
|
+
Note: For Plotly, you can further customize the returned figure using `fig.update_traces()`
|
|
1672
|
+
and `fig.update_layout()` after calling this method.
|
|
1376
1673
|
|
|
1377
1674
|
Raises:
|
|
1378
1675
|
ValueError: If component is not a storage.
|
|
@@ -1393,6 +1690,16 @@ class ComponentResults(_NodeResults):
|
|
|
1393
1690
|
Facet by scenario AND animate by period:
|
|
1394
1691
|
|
|
1395
1692
|
>>> results['Storage'].plot_charge_state(facet_by='scenario', animate_by='period')
|
|
1693
|
+
|
|
1694
|
+
Custom layout after creation:
|
|
1695
|
+
|
|
1696
|
+
>>> fig = results['Storage'].plot_charge_state(show=False)
|
|
1697
|
+
>>> fig.update_layout(template='plotly_dark', height=800)
|
|
1698
|
+
>>> fig.show()
|
|
1699
|
+
|
|
1700
|
+
High-resolution export:
|
|
1701
|
+
|
|
1702
|
+
>>> results['Storage'].plot_charge_state(save='storage.png', dpi=600)
|
|
1396
1703
|
"""
|
|
1397
1704
|
# Handle deprecated indexer parameter
|
|
1398
1705
|
if indexer is not None:
|
|
@@ -1411,11 +1718,17 @@ class ComponentResults(_NodeResults):
|
|
|
1411
1718
|
)
|
|
1412
1719
|
select = indexer
|
|
1413
1720
|
|
|
1721
|
+
# Extract dpi for export_figure
|
|
1722
|
+
dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi
|
|
1723
|
+
|
|
1724
|
+
# Extract charge state line color (for overlay customization)
|
|
1725
|
+
overlay_color = plot_kwargs.pop('charge_state_line_color', 'black')
|
|
1726
|
+
|
|
1414
1727
|
if not self.is_storage:
|
|
1415
1728
|
raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage')
|
|
1416
1729
|
|
|
1417
1730
|
# Get node balance and charge state
|
|
1418
|
-
ds = self.node_balance(with_last_timestep=True)
|
|
1731
|
+
ds = self.node_balance(with_last_timestep=True).fillna(0)
|
|
1419
1732
|
charge_state_da = self.charge_state
|
|
1420
1733
|
|
|
1421
1734
|
# Apply select filtering
|
|
@@ -1431,25 +1744,28 @@ class ComponentResults(_NodeResults):
|
|
|
1431
1744
|
ds,
|
|
1432
1745
|
facet_by=facet_by,
|
|
1433
1746
|
animate_by=animate_by,
|
|
1434
|
-
colors=colors,
|
|
1747
|
+
colors=colors if colors is not None else self._calculation_results.colors,
|
|
1435
1748
|
mode=mode,
|
|
1436
1749
|
title=title,
|
|
1437
1750
|
facet_cols=facet_cols,
|
|
1751
|
+
xlabel='Time in h',
|
|
1752
|
+
**plot_kwargs,
|
|
1438
1753
|
)
|
|
1439
1754
|
|
|
1440
|
-
#
|
|
1441
|
-
|
|
1442
|
-
charge_state_ds = charge_state_da.to_dataset(name=self._charge_state)
|
|
1755
|
+
# Prepare charge_state as Dataset for plotting
|
|
1756
|
+
charge_state_ds = xr.Dataset({self._charge_state: charge_state_da})
|
|
1443
1757
|
|
|
1444
1758
|
# Plot charge_state with mode='line' to get Scatter traces
|
|
1445
1759
|
charge_state_fig = plotting.with_plotly(
|
|
1446
1760
|
charge_state_ds,
|
|
1447
1761
|
facet_by=facet_by,
|
|
1448
1762
|
animate_by=animate_by,
|
|
1449
|
-
colors=colors,
|
|
1763
|
+
colors=colors if colors is not None else self._calculation_results.colors,
|
|
1450
1764
|
mode='line', # Always line for charge_state
|
|
1451
1765
|
title='', # No title needed for this temp figure
|
|
1452
1766
|
facet_cols=facet_cols,
|
|
1767
|
+
xlabel='Time in h',
|
|
1768
|
+
**plot_kwargs,
|
|
1453
1769
|
)
|
|
1454
1770
|
|
|
1455
1771
|
# Add charge_state traces to the main figure
|
|
@@ -1457,6 +1773,7 @@ class ComponentResults(_NodeResults):
|
|
|
1457
1773
|
for trace in charge_state_fig.data:
|
|
1458
1774
|
trace.line.width = 2 # Make charge_state line more prominent
|
|
1459
1775
|
trace.line.shape = 'linear' # Smooth line for charge state (not stepped like flows)
|
|
1776
|
+
trace.line.color = overlay_color
|
|
1460
1777
|
figure_like.add_trace(trace)
|
|
1461
1778
|
|
|
1462
1779
|
# Also add traces from animation frames if they exist
|
|
@@ -1468,6 +1785,7 @@ class ComponentResults(_NodeResults):
|
|
|
1468
1785
|
for trace in frame.data:
|
|
1469
1786
|
trace.line.width = 2
|
|
1470
1787
|
trace.line.shape = 'linear' # Smooth line for charge state
|
|
1788
|
+
trace.line.color = overlay_color
|
|
1471
1789
|
figure_like.frames[i].data = figure_like.frames[i].data + (trace,)
|
|
1472
1790
|
|
|
1473
1791
|
default_filetype = '.html'
|
|
@@ -1481,10 +1799,11 @@ class ComponentResults(_NodeResults):
|
|
|
1481
1799
|
)
|
|
1482
1800
|
# For matplotlib, plot flows (node balance), then add charge_state as line
|
|
1483
1801
|
fig, ax = plotting.with_matplotlib(
|
|
1484
|
-
ds
|
|
1485
|
-
colors=colors,
|
|
1802
|
+
ds,
|
|
1803
|
+
colors=colors if colors is not None else self._calculation_results.colors,
|
|
1486
1804
|
mode=mode,
|
|
1487
1805
|
title=title,
|
|
1806
|
+
**plot_kwargs,
|
|
1488
1807
|
)
|
|
1489
1808
|
|
|
1490
1809
|
# Add charge_state as a line overlay
|
|
@@ -1494,9 +1813,18 @@ class ComponentResults(_NodeResults):
|
|
|
1494
1813
|
charge_state_df.values.flatten(),
|
|
1495
1814
|
label=self._charge_state,
|
|
1496
1815
|
linewidth=2,
|
|
1497
|
-
color=
|
|
1816
|
+
color=overlay_color,
|
|
1817
|
+
)
|
|
1818
|
+
# Recreate legend with the same styling as with_matplotlib
|
|
1819
|
+
handles, labels = ax.get_legend_handles_labels()
|
|
1820
|
+
ax.legend(
|
|
1821
|
+
handles,
|
|
1822
|
+
labels,
|
|
1823
|
+
loc='upper center',
|
|
1824
|
+
bbox_to_anchor=(0.5, -0.15),
|
|
1825
|
+
ncol=5,
|
|
1826
|
+
frameon=False,
|
|
1498
1827
|
)
|
|
1499
|
-
ax.legend()
|
|
1500
1828
|
fig.tight_layout()
|
|
1501
1829
|
|
|
1502
1830
|
figure_like = fig, ax
|
|
@@ -1509,6 +1837,7 @@ class ComponentResults(_NodeResults):
|
|
|
1509
1837
|
user_path=None if isinstance(save, bool) else pathlib.Path(save),
|
|
1510
1838
|
show=show,
|
|
1511
1839
|
save=True if save else False,
|
|
1840
|
+
dpi=dpi,
|
|
1512
1841
|
)
|
|
1513
1842
|
|
|
1514
1843
|
def node_balance_with_charge_state(
|
|
@@ -1718,8 +2047,7 @@ class SegmentedCalculationResults:
|
|
|
1718
2047
|
folder = pathlib.Path(folder)
|
|
1719
2048
|
path = folder / name
|
|
1720
2049
|
logger.info(f'loading calculation "{name}" from file ("{path.with_suffix(".nc4")}")')
|
|
1721
|
-
|
|
1722
|
-
meta_data = json.load(f)
|
|
2050
|
+
meta_data = fx_io.load_json(path.with_suffix('.json'))
|
|
1723
2051
|
return cls(
|
|
1724
2052
|
[CalculationResults.from_file(folder, sub_name) for sub_name in meta_data['sub_calculations']],
|
|
1725
2053
|
all_timesteps=pd.DatetimeIndex(
|
|
@@ -1747,6 +2075,7 @@ class SegmentedCalculationResults:
|
|
|
1747
2075
|
self.name = name
|
|
1748
2076
|
self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results'
|
|
1749
2077
|
self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.all_timesteps)
|
|
2078
|
+
self._colors = {}
|
|
1750
2079
|
|
|
1751
2080
|
@property
|
|
1752
2081
|
def meta_data(self) -> dict[str, int | list[str]]:
|
|
@@ -1761,6 +2090,64 @@ class SegmentedCalculationResults:
|
|
|
1761
2090
|
def segment_names(self) -> list[str]:
|
|
1762
2091
|
return [segment.name for segment in self.segment_results]
|
|
1763
2092
|
|
|
2093
|
+
@property
|
|
2094
|
+
def colors(self) -> dict[str, str]:
|
|
2095
|
+
return self._colors
|
|
2096
|
+
|
|
2097
|
+
@colors.setter
|
|
2098
|
+
def colors(self, colors: dict[str, str]):
|
|
2099
|
+
"""Applies colors to all segments"""
|
|
2100
|
+
self._colors = colors
|
|
2101
|
+
for segment in self.segment_results:
|
|
2102
|
+
segment.colors = copy.deepcopy(colors)
|
|
2103
|
+
|
|
2104
|
+
def setup_colors(
|
|
2105
|
+
self,
|
|
2106
|
+
config: dict[str, str | list[str]] | str | pathlib.Path | None = None,
|
|
2107
|
+
default_colorscale: str | None = None,
|
|
2108
|
+
) -> dict[str, str]:
|
|
2109
|
+
"""
|
|
2110
|
+
Setup colors for all variables across all segment results.
|
|
2111
|
+
|
|
2112
|
+
This method applies the same color configuration to all segments, ensuring
|
|
2113
|
+
consistent visualization across the entire segmented calculation. The color
|
|
2114
|
+
mapping is propagated to each segment's CalculationResults instance.
|
|
2115
|
+
|
|
2116
|
+
Args:
|
|
2117
|
+
config: Configuration for color assignment. Can be:
|
|
2118
|
+
- dict: Maps components to colors/colorscales:
|
|
2119
|
+
* 'component1': 'red' # Single component to single color
|
|
2120
|
+
* 'component1': '#FF0000' # Single component to hex color
|
|
2121
|
+
- OR maps colorscales to multiple components:
|
|
2122
|
+
* 'colorscale_name': ['component1', 'component2'] # Colorscale across components
|
|
2123
|
+
- str: Path to a JSON/YAML config file or a colorscale name to apply to all
|
|
2124
|
+
- Path: Path to a JSON/YAML config file
|
|
2125
|
+
- None: Use default_colorscale for all components
|
|
2126
|
+
default_colorscale: Default colorscale for unconfigured components (default: 'turbo')
|
|
2127
|
+
|
|
2128
|
+
Examples:
|
|
2129
|
+
```python
|
|
2130
|
+
# Apply colors to all segments
|
|
2131
|
+
segmented_results.setup_colors(
|
|
2132
|
+
{
|
|
2133
|
+
'CHP': 'red',
|
|
2134
|
+
'Blues': ['Storage1', 'Storage2'],
|
|
2135
|
+
'Oranges': ['Solar1', 'Solar2'],
|
|
2136
|
+
}
|
|
2137
|
+
)
|
|
2138
|
+
|
|
2139
|
+
# Use a single colorscale for all components in all segments
|
|
2140
|
+
segmented_results.setup_colors('portland')
|
|
2141
|
+
```
|
|
2142
|
+
|
|
2143
|
+
Returns:
|
|
2144
|
+
Complete variable-to-color mapping dictionary from the first segment
|
|
2145
|
+
(all segments will have the same mapping)
|
|
2146
|
+
"""
|
|
2147
|
+
self.colors = self.segment_results[0].setup_colors(config=config, default_colorscale=default_colorscale)
|
|
2148
|
+
|
|
2149
|
+
return self.colors
|
|
2150
|
+
|
|
1764
2151
|
def solution_without_overlap(self, variable_name: str) -> xr.DataArray:
|
|
1765
2152
|
"""Get variable solution removing segment overlaps.
|
|
1766
2153
|
|
|
@@ -1782,18 +2169,19 @@ class SegmentedCalculationResults:
|
|
|
1782
2169
|
reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']]
|
|
1783
2170
|
| Literal['auto']
|
|
1784
2171
|
| None = 'auto',
|
|
1785
|
-
colors:
|
|
2172
|
+
colors: plotting.ColorType | None = None,
|
|
1786
2173
|
save: bool | pathlib.Path = False,
|
|
1787
|
-
show: bool =
|
|
2174
|
+
show: bool | None = None,
|
|
1788
2175
|
engine: plotting.PlottingEngine = 'plotly',
|
|
1789
2176
|
facet_by: str | list[str] | None = None,
|
|
1790
2177
|
animate_by: str | None = None,
|
|
1791
|
-
facet_cols: int =
|
|
2178
|
+
facet_cols: int | None = None,
|
|
1792
2179
|
fill: Literal['ffill', 'bfill'] | None = 'ffill',
|
|
1793
2180
|
# Deprecated parameters (kept for backwards compatibility)
|
|
1794
2181
|
heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None,
|
|
1795
2182
|
heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None,
|
|
1796
2183
|
color_map: str | None = None,
|
|
2184
|
+
**plot_kwargs: Any,
|
|
1797
2185
|
) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]:
|
|
1798
2186
|
"""Plot heatmap of variable solution across segments.
|
|
1799
2187
|
|
|
@@ -1814,6 +2202,17 @@ class SegmentedCalculationResults:
|
|
|
1814
2202
|
heatmap_timeframes: (Deprecated) Use reshape_time instead.
|
|
1815
2203
|
heatmap_timesteps_per_frame: (Deprecated) Use reshape_time instead.
|
|
1816
2204
|
color_map: (Deprecated) Use colors instead.
|
|
2205
|
+
**plot_kwargs: Additional plotting customization options.
|
|
2206
|
+
Common options:
|
|
2207
|
+
|
|
2208
|
+
- **dpi** (int): Export resolution for saved plots. Default: 300.
|
|
2209
|
+
- **vmin** (float): Minimum value for color scale.
|
|
2210
|
+
- **vmax** (float): Maximum value for color scale.
|
|
2211
|
+
|
|
2212
|
+
For Matplotlib heatmaps:
|
|
2213
|
+
|
|
2214
|
+
- **imshow_kwargs** (dict): Additional kwargs for matplotlib's imshow.
|
|
2215
|
+
- **cbar_kwargs** (dict): Additional kwargs for colorbar customization.
|
|
1817
2216
|
|
|
1818
2217
|
Returns:
|
|
1819
2218
|
Figure object.
|
|
@@ -1841,7 +2240,7 @@ class SegmentedCalculationResults:
|
|
|
1841
2240
|
|
|
1842
2241
|
if color_map is not None:
|
|
1843
2242
|
# Check for conflict with new parameter
|
|
1844
|
-
if colors
|
|
2243
|
+
if colors is not None: # Check if user explicitly set colors
|
|
1845
2244
|
raise ValueError(
|
|
1846
2245
|
"Cannot use both deprecated parameter 'color_map' and new parameter 'colors'. Use only 'colors'."
|
|
1847
2246
|
)
|
|
@@ -1868,6 +2267,7 @@ class SegmentedCalculationResults:
|
|
|
1868
2267
|
animate_by=animate_by,
|
|
1869
2268
|
facet_cols=facet_cols,
|
|
1870
2269
|
fill=fill,
|
|
2270
|
+
**plot_kwargs,
|
|
1871
2271
|
)
|
|
1872
2272
|
|
|
1873
2273
|
def to_file(self, folder: str | pathlib.Path | None = None, name: str | None = None, compression: int = 5):
|
|
@@ -1891,8 +2291,7 @@ class SegmentedCalculationResults:
|
|
|
1891
2291
|
for segment in self.segment_results:
|
|
1892
2292
|
segment.to_file(folder=folder, name=segment.name, compression=compression)
|
|
1893
2293
|
|
|
1894
|
-
|
|
1895
|
-
json.dump(self.meta_data, f, indent=4, ensure_ascii=False)
|
|
2294
|
+
fx_io.save_json(self.meta_data, path.with_suffix('.json'))
|
|
1896
2295
|
logger.info(f'Saved calculation "{name}" to {path}')
|
|
1897
2296
|
|
|
1898
2297
|
|
|
@@ -1900,14 +2299,14 @@ def plot_heatmap(
|
|
|
1900
2299
|
data: xr.DataArray | xr.Dataset,
|
|
1901
2300
|
name: str | None = None,
|
|
1902
2301
|
folder: pathlib.Path | None = None,
|
|
1903
|
-
colors: plotting.ColorType =
|
|
2302
|
+
colors: plotting.ColorType | None = None,
|
|
1904
2303
|
save: bool | pathlib.Path = False,
|
|
1905
|
-
show: bool =
|
|
2304
|
+
show: bool | None = None,
|
|
1906
2305
|
engine: plotting.PlottingEngine = 'plotly',
|
|
1907
2306
|
select: dict[str, Any] | None = None,
|
|
1908
2307
|
facet_by: str | list[str] | None = None,
|
|
1909
2308
|
animate_by: str | None = None,
|
|
1910
|
-
facet_cols: int =
|
|
2309
|
+
facet_cols: int | None = None,
|
|
1911
2310
|
reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']]
|
|
1912
2311
|
| Literal['auto']
|
|
1913
2312
|
| None = 'auto',
|
|
@@ -1917,6 +2316,7 @@ def plot_heatmap(
|
|
|
1917
2316
|
heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None,
|
|
1918
2317
|
heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None,
|
|
1919
2318
|
color_map: str | None = None,
|
|
2319
|
+
**plot_kwargs: Any,
|
|
1920
2320
|
):
|
|
1921
2321
|
"""Plot heatmap visualization with support for multi-variable, faceting, and animation.
|
|
1922
2322
|
|
|
@@ -1987,8 +2387,7 @@ def plot_heatmap(
|
|
|
1987
2387
|
|
|
1988
2388
|
# Handle deprecated color_map parameter
|
|
1989
2389
|
if color_map is not None:
|
|
1990
|
-
|
|
1991
|
-
if colors != 'viridis': # User explicitly set colors
|
|
2390
|
+
if colors is not None: # User explicitly set colors
|
|
1992
2391
|
raise ValueError(
|
|
1993
2392
|
"Cannot use both deprecated parameter 'color_map' and new parameter 'colors'. Use only 'colors'."
|
|
1994
2393
|
)
|
|
@@ -2071,6 +2470,9 @@ def plot_heatmap(
|
|
|
2071
2470
|
timeframes, timesteps_per_frame = reshape_time
|
|
2072
2471
|
title += f' ({timeframes} vs {timesteps_per_frame})'
|
|
2073
2472
|
|
|
2473
|
+
# Extract dpi before passing to plotting functions
|
|
2474
|
+
dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi
|
|
2475
|
+
|
|
2074
2476
|
# Plot with appropriate engine
|
|
2075
2477
|
if engine == 'plotly':
|
|
2076
2478
|
figure_like = plotting.heatmap_with_plotly(
|
|
@@ -2082,6 +2484,7 @@ def plot_heatmap(
|
|
|
2082
2484
|
facet_cols=facet_cols,
|
|
2083
2485
|
reshape_time=reshape_time,
|
|
2084
2486
|
fill=fill,
|
|
2487
|
+
**plot_kwargs,
|
|
2085
2488
|
)
|
|
2086
2489
|
default_filetype = '.html'
|
|
2087
2490
|
elif engine == 'matplotlib':
|
|
@@ -2091,6 +2494,7 @@ def plot_heatmap(
|
|
|
2091
2494
|
title=title,
|
|
2092
2495
|
reshape_time=reshape_time,
|
|
2093
2496
|
fill=fill,
|
|
2497
|
+
**plot_kwargs,
|
|
2094
2498
|
)
|
|
2095
2499
|
default_filetype = '.png'
|
|
2096
2500
|
else:
|
|
@@ -2107,6 +2511,7 @@ def plot_heatmap(
|
|
|
2107
2511
|
user_path=None if isinstance(save, bool) else pathlib.Path(save),
|
|
2108
2512
|
show=show,
|
|
2109
2513
|
save=True if save else False,
|
|
2514
|
+
dpi=dpi,
|
|
2110
2515
|
)
|
|
2111
2516
|
|
|
2112
2517
|
|