flixopt 3.1.1__py3-none-any.whl → 3.2.1__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/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
- with open(paths.summary, encoding='utf-8') as f:
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),
@@ -240,6 +271,8 @@ class CalculationResults:
240
271
  self._sizes = None
241
272
  self._effects_per_component = None
242
273
 
274
+ self.colors: dict[str, str] = {}
275
+
243
276
  def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults:
244
277
  if key in self.components:
245
278
  return self.components[key]
@@ -306,6 +339,131 @@ class CalculationResults:
306
339
  logger.level = old_level
307
340
  return self._flow_system
308
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
+
309
467
  def filter_solution(
310
468
  self,
311
469
  variable_dims: Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly'] | None = None,
@@ -705,13 +863,13 @@ class CalculationResults:
705
863
  self,
706
864
  variable_name: str | list[str],
707
865
  save: bool | pathlib.Path = False,
708
- show: bool = True,
709
- colors: plotting.ColorType = 'viridis',
866
+ show: bool | None = None,
867
+ colors: plotting.ColorType | None = None,
710
868
  engine: plotting.PlottingEngine = 'plotly',
711
869
  select: dict[FlowSystemDimensions, Any] | None = None,
712
870
  facet_by: str | list[str] | None = 'scenario',
713
871
  animate_by: str | None = 'period',
714
- facet_cols: int = 3,
872
+ facet_cols: int | None = None,
715
873
  reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']]
716
874
  | Literal['auto']
717
875
  | None = 'auto',
@@ -721,6 +879,7 @@ class CalculationResults:
721
879
  heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None,
722
880
  heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None,
723
881
  color_map: str | None = None,
882
+ **plot_kwargs: Any,
724
883
  ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]:
725
884
  """
726
885
  Plots a heatmap visualization of a variable using imshow or time-based reshaping.
@@ -754,6 +913,20 @@ class CalculationResults:
754
913
  Supported timeframes: 'YS', 'MS', 'W', 'D', 'h', '15min', 'min'
755
914
  fill: Method to fill missing values after reshape: 'ffill' (forward fill) or 'bfill' (backward fill).
756
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.
757
930
 
758
931
  Examples:
759
932
  Direct imshow mode (default):
@@ -794,6 +967,18 @@ class CalculationResults:
794
967
  ... animate_by='period',
795
968
  ... reshape_time=('D', 'h'),
796
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
+ ... )
797
982
  """
798
983
  # Delegate to module-level plot_heatmap function
799
984
  return plot_heatmap(
@@ -814,6 +999,7 @@ class CalculationResults:
814
999
  heatmap_timeframes=heatmap_timeframes,
815
1000
  heatmap_timesteps_per_frame=heatmap_timesteps_per_frame,
816
1001
  color_map=color_map,
1002
+ **plot_kwargs,
817
1003
  )
818
1004
 
819
1005
  def plot_network(
@@ -870,14 +1056,13 @@ class CalculationResults:
870
1056
  fx_io.save_dataset_to_netcdf(self.solution, paths.solution, compression=compression)
871
1057
  fx_io.save_dataset_to_netcdf(self.flow_system_data, paths.flow_system, compression=compression)
872
1058
 
873
- with open(paths.summary, 'w', encoding='utf-8') as f:
874
- yaml.dump(self.summary, f, allow_unicode=True, sort_keys=False, indent=4, width=1000)
1059
+ fx_io.save_yaml(self.summary, paths.summary)
875
1060
 
876
1061
  if save_linopy_model:
877
1062
  if self.model is None:
878
1063
  logger.critical('No model in the CalculationResults. Saving the model is not possible.')
879
1064
  else:
880
- self.model.to_netcdf(paths.linopy_model, engine='h5netcdf')
1065
+ self.model.to_netcdf(paths.linopy_model, engine='netcdf4')
881
1066
 
882
1067
  if document_model:
883
1068
  if self.model is None:
@@ -982,8 +1167,8 @@ class _NodeResults(_ElementResults):
982
1167
  def plot_node_balance(
983
1168
  self,
984
1169
  save: bool | pathlib.Path = False,
985
- show: bool = True,
986
- colors: plotting.ColorType = 'viridis',
1170
+ show: bool | None = None,
1171
+ colors: plotting.ColorType | None = None,
987
1172
  engine: plotting.PlottingEngine = 'plotly',
988
1173
  select: dict[FlowSystemDimensions, Any] | None = None,
989
1174
  unit_type: Literal['flow_rate', 'flow_hours'] = 'flow_rate',
@@ -991,9 +1176,10 @@ class _NodeResults(_ElementResults):
991
1176
  drop_suffix: bool = True,
992
1177
  facet_by: str | list[str] | None = 'scenario',
993
1178
  animate_by: str | None = 'period',
994
- facet_cols: int = 3,
1179
+ facet_cols: int | None = None,
995
1180
  # Deprecated parameter (kept for backwards compatibility)
996
1181
  indexer: dict[FlowSystemDimensions, Any] | None = None,
1182
+ **plot_kwargs: Any,
997
1183
  ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]:
998
1184
  """
999
1185
  Plots the node balance of the Component or Bus with optional faceting and animation.
@@ -1021,6 +1207,27 @@ class _NodeResults(_ElementResults):
1021
1207
  animate_by: Dimension to animate over (Plotly only). Creates animation frames that cycle through
1022
1208
  dimension values. Only one dimension can be animated. Ignored if not found.
1023
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.
1024
1231
 
1025
1232
  Examples:
1026
1233
  Basic plot (current behavior):
@@ -1052,6 +1259,25 @@ class _NodeResults(_ElementResults):
1052
1259
  Time range selection (summer months only):
1053
1260
 
1054
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()
1055
1281
  """
1056
1282
  # Handle deprecated indexer parameter
1057
1283
  if indexer is not None:
@@ -1073,8 +1299,11 @@ class _NodeResults(_ElementResults):
1073
1299
  if engine not in {'plotly', 'matplotlib'}:
1074
1300
  raise ValueError(f'Engine "{engine}" not supported. Use one of ["plotly", "matplotlib"]')
1075
1301
 
1302
+ # Extract dpi for export_figure
1303
+ dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi
1304
+
1076
1305
  # Don't pass select/indexer to node_balance - we'll apply it afterwards
1077
- ds = self.node_balance(with_last_timestep=True, unit_type=unit_type, drop_suffix=drop_suffix)
1306
+ ds = self.node_balance(with_last_timestep=False, unit_type=unit_type, drop_suffix=drop_suffix)
1078
1307
 
1079
1308
  ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True)
1080
1309
 
@@ -1097,18 +1326,21 @@ class _NodeResults(_ElementResults):
1097
1326
  ds,
1098
1327
  facet_by=facet_by,
1099
1328
  animate_by=animate_by,
1100
- colors=colors,
1329
+ colors=colors if colors is not None else self._calculation_results.colors,
1101
1330
  mode=mode,
1102
1331
  title=title,
1103
1332
  facet_cols=facet_cols,
1333
+ xlabel='Time in h',
1334
+ **plot_kwargs,
1104
1335
  )
1105
1336
  default_filetype = '.html'
1106
1337
  else:
1107
1338
  figure_like = plotting.with_matplotlib(
1108
- ds.to_dataframe(),
1109
- colors=colors,
1339
+ ds,
1340
+ colors=colors if colors is not None else self._calculation_results.colors,
1110
1341
  mode=mode,
1111
1342
  title=title,
1343
+ **plot_kwargs,
1112
1344
  )
1113
1345
  default_filetype = '.png'
1114
1346
 
@@ -1119,19 +1351,21 @@ class _NodeResults(_ElementResults):
1119
1351
  user_path=None if isinstance(save, bool) else pathlib.Path(save),
1120
1352
  show=show,
1121
1353
  save=True if save else False,
1354
+ dpi=dpi,
1122
1355
  )
1123
1356
 
1124
1357
  def plot_node_balance_pie(
1125
1358
  self,
1126
1359
  lower_percentage_group: float = 5,
1127
- colors: plotting.ColorType = 'viridis',
1360
+ colors: plotting.ColorType | None = None,
1128
1361
  text_info: str = 'percent+label+value',
1129
1362
  save: bool | pathlib.Path = False,
1130
- show: bool = True,
1363
+ show: bool | None = None,
1131
1364
  engine: plotting.PlottingEngine = 'plotly',
1132
1365
  select: dict[FlowSystemDimensions, Any] | None = None,
1133
1366
  # Deprecated parameter (kept for backwards compatibility)
1134
1367
  indexer: dict[FlowSystemDimensions, Any] | None = None,
1368
+ **plot_kwargs: Any,
1135
1369
  ) -> plotly.graph_objs.Figure | tuple[plt.Figure, list[plt.Axes]]:
1136
1370
  """Plot pie chart of flow hours distribution.
1137
1371
 
@@ -1151,6 +1385,17 @@ class _NodeResults(_ElementResults):
1151
1385
  engine: Plotting engine ('plotly' or 'matplotlib').
1152
1386
  select: Optional data selection dict. Supports single values, lists, slices, and index arrays.
1153
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.
1154
1399
 
1155
1400
  Examples:
1156
1401
  Basic usage (auto-selects first scenario/period if present):
@@ -1160,6 +1405,14 @@ class _NodeResults(_ElementResults):
1160
1405
  Explicitly select a scenario and period:
1161
1406
 
1162
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)
1163
1416
  """
1164
1417
  # Handle deprecated indexer parameter
1165
1418
  if indexer is not None:
@@ -1178,6 +1431,9 @@ class _NodeResults(_ElementResults):
1178
1431
  )
1179
1432
  select = indexer
1180
1433
 
1434
+ # Extract dpi for export_figure
1435
+ dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi
1436
+
1181
1437
  inputs = sanitize_dataset(
1182
1438
  ds=self.solution[self.inputs] * self._calculation_results.hours_per_timestep,
1183
1439
  threshold=1e-5,
@@ -1193,8 +1449,9 @@ class _NodeResults(_ElementResults):
1193
1449
  drop_suffix='|',
1194
1450
  )
1195
1451
 
1196
- inputs, suffix_parts = _apply_selection_to_data(inputs, select=select, drop=True)
1197
- outputs, suffix_parts = _apply_selection_to_data(outputs, select=select, drop=True)
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
1198
1455
 
1199
1456
  # Sum over time dimension
1200
1457
  inputs = inputs.sum('time')
@@ -1204,7 +1461,7 @@ class _NodeResults(_ElementResults):
1204
1461
  # Pie charts need scalar data, so we automatically reduce extra dimensions
1205
1462
  extra_dims_inputs = [dim for dim in inputs.dims if dim != 'time']
1206
1463
  extra_dims_outputs = [dim for dim in outputs.dims if dim != 'time']
1207
- extra_dims = list(set(extra_dims_inputs + extra_dims_outputs))
1464
+ extra_dims = sorted(set(extra_dims_inputs + extra_dims_outputs))
1208
1465
 
1209
1466
  if extra_dims:
1210
1467
  auto_select = {}
@@ -1222,27 +1479,28 @@ class _NodeResults(_ElementResults):
1222
1479
  f'Use select={{"{dim}": value}} to choose a different value.'
1223
1480
  )
1224
1481
 
1225
- # Apply auto-selection
1226
- inputs = inputs.sel(auto_select)
1227
- 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})
1228
1485
 
1229
1486
  # Update suffix with auto-selected values
1230
1487
  auto_suffix_parts = [f'{dim}={val}' for dim, val in auto_select.items()]
1231
1488
  suffix_parts.extend(auto_suffix_parts)
1232
1489
 
1233
- suffix = '--' + '-'.join(suffix_parts) if suffix_parts else ''
1490
+ suffix = '--' + '-'.join(sorted(set(suffix_parts))) if suffix_parts else ''
1234
1491
  title = f'{self.label} (total flow hours){suffix}'
1235
1492
 
1236
1493
  if engine == 'plotly':
1237
1494
  figure_like = plotting.dual_pie_with_plotly(
1238
- data_left=inputs.to_pandas(),
1239
- data_right=outputs.to_pandas(),
1240
- colors=colors,
1495
+ data_left=inputs,
1496
+ data_right=outputs,
1497
+ colors=colors if colors is not None else self._calculation_results.colors,
1241
1498
  title=title,
1242
1499
  text_info=text_info,
1243
1500
  subtitles=('Inputs', 'Outputs'),
1244
1501
  legend_title='Flows',
1245
1502
  lower_percentage_group=lower_percentage_group,
1503
+ **plot_kwargs,
1246
1504
  )
1247
1505
  default_filetype = '.html'
1248
1506
  elif engine == 'matplotlib':
@@ -1250,11 +1508,12 @@ class _NodeResults(_ElementResults):
1250
1508
  figure_like = plotting.dual_pie_with_matplotlib(
1251
1509
  data_left=inputs.to_pandas(),
1252
1510
  data_right=outputs.to_pandas(),
1253
- colors=colors,
1511
+ colors=colors if colors is not None else self._calculation_results.colors,
1254
1512
  title=title,
1255
1513
  subtitles=('Inputs', 'Outputs'),
1256
1514
  legend_title='Flows',
1257
1515
  lower_percentage_group=lower_percentage_group,
1516
+ **plot_kwargs,
1258
1517
  )
1259
1518
  default_filetype = '.png'
1260
1519
  else:
@@ -1267,6 +1526,7 @@ class _NodeResults(_ElementResults):
1267
1526
  user_path=None if isinstance(save, bool) else pathlib.Path(save),
1268
1527
  show=show,
1269
1528
  save=True if save else False,
1529
+ dpi=dpi,
1270
1530
  )
1271
1531
 
1272
1532
  def node_balance(
@@ -1363,16 +1623,17 @@ class ComponentResults(_NodeResults):
1363
1623
  def plot_charge_state(
1364
1624
  self,
1365
1625
  save: bool | pathlib.Path = False,
1366
- show: bool = True,
1367
- colors: plotting.ColorType = 'viridis',
1626
+ show: bool | None = None,
1627
+ colors: plotting.ColorType | None = None,
1368
1628
  engine: plotting.PlottingEngine = 'plotly',
1369
1629
  mode: Literal['area', 'stacked_bar', 'line'] = 'area',
1370
1630
  select: dict[FlowSystemDimensions, Any] | None = None,
1371
1631
  facet_by: str | list[str] | None = 'scenario',
1372
1632
  animate_by: str | None = 'period',
1373
- facet_cols: int = 3,
1633
+ facet_cols: int | None = None,
1374
1634
  # Deprecated parameter (kept for backwards compatibility)
1375
1635
  indexer: dict[FlowSystemDimensions, Any] | None = None,
1636
+ **plot_kwargs: Any,
1376
1637
  ) -> plotly.graph_objs.Figure:
1377
1638
  """Plot storage charge state over time, combined with the node balance with optional faceting and animation.
1378
1639
 
@@ -1389,6 +1650,26 @@ class ComponentResults(_NodeResults):
1389
1650
  animate_by: Dimension to animate over (Plotly only). Creates animation frames that cycle through
1390
1651
  dimension values. Only one dimension can be animated. Ignored if not found.
1391
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.
1392
1673
 
1393
1674
  Raises:
1394
1675
  ValueError: If component is not a storage.
@@ -1409,6 +1690,16 @@ class ComponentResults(_NodeResults):
1409
1690
  Facet by scenario AND animate by period:
1410
1691
 
1411
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)
1412
1703
  """
1413
1704
  # Handle deprecated indexer parameter
1414
1705
  if indexer is not None:
@@ -1427,11 +1718,17 @@ class ComponentResults(_NodeResults):
1427
1718
  )
1428
1719
  select = indexer
1429
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
+
1430
1727
  if not self.is_storage:
1431
1728
  raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage')
1432
1729
 
1433
1730
  # Get node balance and charge state
1434
- ds = self.node_balance(with_last_timestep=True)
1731
+ ds = self.node_balance(with_last_timestep=True).fillna(0)
1435
1732
  charge_state_da = self.charge_state
1436
1733
 
1437
1734
  # Apply select filtering
@@ -1447,25 +1744,28 @@ class ComponentResults(_NodeResults):
1447
1744
  ds,
1448
1745
  facet_by=facet_by,
1449
1746
  animate_by=animate_by,
1450
- colors=colors,
1747
+ colors=colors if colors is not None else self._calculation_results.colors,
1451
1748
  mode=mode,
1452
1749
  title=title,
1453
1750
  facet_cols=facet_cols,
1751
+ xlabel='Time in h',
1752
+ **plot_kwargs,
1454
1753
  )
1455
1754
 
1456
- # Create a dataset with just charge_state and plot it as lines
1457
- # This ensures proper handling of facets and animation
1458
- 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})
1459
1757
 
1460
1758
  # Plot charge_state with mode='line' to get Scatter traces
1461
1759
  charge_state_fig = plotting.with_plotly(
1462
1760
  charge_state_ds,
1463
1761
  facet_by=facet_by,
1464
1762
  animate_by=animate_by,
1465
- colors=colors,
1763
+ colors=colors if colors is not None else self._calculation_results.colors,
1466
1764
  mode='line', # Always line for charge_state
1467
1765
  title='', # No title needed for this temp figure
1468
1766
  facet_cols=facet_cols,
1767
+ xlabel='Time in h',
1768
+ **plot_kwargs,
1469
1769
  )
1470
1770
 
1471
1771
  # Add charge_state traces to the main figure
@@ -1473,6 +1773,7 @@ class ComponentResults(_NodeResults):
1473
1773
  for trace in charge_state_fig.data:
1474
1774
  trace.line.width = 2 # Make charge_state line more prominent
1475
1775
  trace.line.shape = 'linear' # Smooth line for charge state (not stepped like flows)
1776
+ trace.line.color = overlay_color
1476
1777
  figure_like.add_trace(trace)
1477
1778
 
1478
1779
  # Also add traces from animation frames if they exist
@@ -1484,6 +1785,7 @@ class ComponentResults(_NodeResults):
1484
1785
  for trace in frame.data:
1485
1786
  trace.line.width = 2
1486
1787
  trace.line.shape = 'linear' # Smooth line for charge state
1788
+ trace.line.color = overlay_color
1487
1789
  figure_like.frames[i].data = figure_like.frames[i].data + (trace,)
1488
1790
 
1489
1791
  default_filetype = '.html'
@@ -1497,10 +1799,11 @@ class ComponentResults(_NodeResults):
1497
1799
  )
1498
1800
  # For matplotlib, plot flows (node balance), then add charge_state as line
1499
1801
  fig, ax = plotting.with_matplotlib(
1500
- ds.to_dataframe(),
1501
- colors=colors,
1802
+ ds,
1803
+ colors=colors if colors is not None else self._calculation_results.colors,
1502
1804
  mode=mode,
1503
1805
  title=title,
1806
+ **plot_kwargs,
1504
1807
  )
1505
1808
 
1506
1809
  # Add charge_state as a line overlay
@@ -1510,9 +1813,18 @@ class ComponentResults(_NodeResults):
1510
1813
  charge_state_df.values.flatten(),
1511
1814
  label=self._charge_state,
1512
1815
  linewidth=2,
1513
- color='black',
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,
1514
1827
  )
1515
- ax.legend()
1516
1828
  fig.tight_layout()
1517
1829
 
1518
1830
  figure_like = fig, ax
@@ -1525,6 +1837,7 @@ class ComponentResults(_NodeResults):
1525
1837
  user_path=None if isinstance(save, bool) else pathlib.Path(save),
1526
1838
  show=show,
1527
1839
  save=True if save else False,
1840
+ dpi=dpi,
1528
1841
  )
1529
1842
 
1530
1843
  def node_balance_with_charge_state(
@@ -1734,8 +2047,7 @@ class SegmentedCalculationResults:
1734
2047
  folder = pathlib.Path(folder)
1735
2048
  path = folder / name
1736
2049
  logger.info(f'loading calculation "{name}" from file ("{path.with_suffix(".nc4")}")')
1737
- with open(path.with_suffix('.json'), encoding='utf-8') as f:
1738
- meta_data = json.load(f)
2050
+ meta_data = fx_io.load_json(path.with_suffix('.json'))
1739
2051
  return cls(
1740
2052
  [CalculationResults.from_file(folder, sub_name) for sub_name in meta_data['sub_calculations']],
1741
2053
  all_timesteps=pd.DatetimeIndex(
@@ -1763,6 +2075,7 @@ class SegmentedCalculationResults:
1763
2075
  self.name = name
1764
2076
  self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results'
1765
2077
  self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.all_timesteps)
2078
+ self._colors = {}
1766
2079
 
1767
2080
  @property
1768
2081
  def meta_data(self) -> dict[str, int | list[str]]:
@@ -1777,6 +2090,64 @@ class SegmentedCalculationResults:
1777
2090
  def segment_names(self) -> list[str]:
1778
2091
  return [segment.name for segment in self.segment_results]
1779
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
+
1780
2151
  def solution_without_overlap(self, variable_name: str) -> xr.DataArray:
1781
2152
  """Get variable solution removing segment overlaps.
1782
2153
 
@@ -1798,18 +2169,19 @@ class SegmentedCalculationResults:
1798
2169
  reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']]
1799
2170
  | Literal['auto']
1800
2171
  | None = 'auto',
1801
- colors: str = 'portland',
2172
+ colors: plotting.ColorType | None = None,
1802
2173
  save: bool | pathlib.Path = False,
1803
- show: bool = True,
2174
+ show: bool | None = None,
1804
2175
  engine: plotting.PlottingEngine = 'plotly',
1805
2176
  facet_by: str | list[str] | None = None,
1806
2177
  animate_by: str | None = None,
1807
- facet_cols: int = 3,
2178
+ facet_cols: int | None = None,
1808
2179
  fill: Literal['ffill', 'bfill'] | None = 'ffill',
1809
2180
  # Deprecated parameters (kept for backwards compatibility)
1810
2181
  heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None,
1811
2182
  heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None,
1812
2183
  color_map: str | None = None,
2184
+ **plot_kwargs: Any,
1813
2185
  ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]:
1814
2186
  """Plot heatmap of variable solution across segments.
1815
2187
 
@@ -1830,6 +2202,17 @@ class SegmentedCalculationResults:
1830
2202
  heatmap_timeframes: (Deprecated) Use reshape_time instead.
1831
2203
  heatmap_timesteps_per_frame: (Deprecated) Use reshape_time instead.
1832
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.
1833
2216
 
1834
2217
  Returns:
1835
2218
  Figure object.
@@ -1857,7 +2240,7 @@ class SegmentedCalculationResults:
1857
2240
 
1858
2241
  if color_map is not None:
1859
2242
  # Check for conflict with new parameter
1860
- if colors != 'portland': # Check if user explicitly set colors
2243
+ if colors is not None: # Check if user explicitly set colors
1861
2244
  raise ValueError(
1862
2245
  "Cannot use both deprecated parameter 'color_map' and new parameter 'colors'. Use only 'colors'."
1863
2246
  )
@@ -1884,6 +2267,7 @@ class SegmentedCalculationResults:
1884
2267
  animate_by=animate_by,
1885
2268
  facet_cols=facet_cols,
1886
2269
  fill=fill,
2270
+ **plot_kwargs,
1887
2271
  )
1888
2272
 
1889
2273
  def to_file(self, folder: str | pathlib.Path | None = None, name: str | None = None, compression: int = 5):
@@ -1907,8 +2291,7 @@ class SegmentedCalculationResults:
1907
2291
  for segment in self.segment_results:
1908
2292
  segment.to_file(folder=folder, name=segment.name, compression=compression)
1909
2293
 
1910
- with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f:
1911
- json.dump(self.meta_data, f, indent=4, ensure_ascii=False)
2294
+ fx_io.save_json(self.meta_data, path.with_suffix('.json'))
1912
2295
  logger.info(f'Saved calculation "{name}" to {path}')
1913
2296
 
1914
2297
 
@@ -1916,14 +2299,14 @@ def plot_heatmap(
1916
2299
  data: xr.DataArray | xr.Dataset,
1917
2300
  name: str | None = None,
1918
2301
  folder: pathlib.Path | None = None,
1919
- colors: plotting.ColorType = 'viridis',
2302
+ colors: plotting.ColorType | None = None,
1920
2303
  save: bool | pathlib.Path = False,
1921
- show: bool = True,
2304
+ show: bool | None = None,
1922
2305
  engine: plotting.PlottingEngine = 'plotly',
1923
2306
  select: dict[str, Any] | None = None,
1924
2307
  facet_by: str | list[str] | None = None,
1925
2308
  animate_by: str | None = None,
1926
- facet_cols: int = 3,
2309
+ facet_cols: int | None = None,
1927
2310
  reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']]
1928
2311
  | Literal['auto']
1929
2312
  | None = 'auto',
@@ -1933,6 +2316,7 @@ def plot_heatmap(
1933
2316
  heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None,
1934
2317
  heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None,
1935
2318
  color_map: str | None = None,
2319
+ **plot_kwargs: Any,
1936
2320
  ):
1937
2321
  """Plot heatmap visualization with support for multi-variable, faceting, and animation.
1938
2322
 
@@ -2003,8 +2387,7 @@ def plot_heatmap(
2003
2387
 
2004
2388
  # Handle deprecated color_map parameter
2005
2389
  if color_map is not None:
2006
- # Check for conflict with new parameter
2007
- if colors != 'viridis': # User explicitly set colors
2390
+ if colors is not None: # User explicitly set colors
2008
2391
  raise ValueError(
2009
2392
  "Cannot use both deprecated parameter 'color_map' and new parameter 'colors'. Use only 'colors'."
2010
2393
  )
@@ -2087,6 +2470,9 @@ def plot_heatmap(
2087
2470
  timeframes, timesteps_per_frame = reshape_time
2088
2471
  title += f' ({timeframes} vs {timesteps_per_frame})'
2089
2472
 
2473
+ # Extract dpi before passing to plotting functions
2474
+ dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi
2475
+
2090
2476
  # Plot with appropriate engine
2091
2477
  if engine == 'plotly':
2092
2478
  figure_like = plotting.heatmap_with_plotly(
@@ -2098,6 +2484,7 @@ def plot_heatmap(
2098
2484
  facet_cols=facet_cols,
2099
2485
  reshape_time=reshape_time,
2100
2486
  fill=fill,
2487
+ **plot_kwargs,
2101
2488
  )
2102
2489
  default_filetype = '.html'
2103
2490
  elif engine == 'matplotlib':
@@ -2107,6 +2494,7 @@ def plot_heatmap(
2107
2494
  title=title,
2108
2495
  reshape_time=reshape_time,
2109
2496
  fill=fill,
2497
+ **plot_kwargs,
2110
2498
  )
2111
2499
  default_filetype = '.png'
2112
2500
  else:
@@ -2123,6 +2511,7 @@ def plot_heatmap(
2123
2511
  user_path=None if isinstance(save, bool) else pathlib.Path(save),
2124
2512
  show=show,
2125
2513
  save=True if save else False,
2514
+ dpi=dpi,
2126
2515
  )
2127
2516
 
2128
2517