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/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),
@@ -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
- # First pass: collect arrays and find template
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
- # Ensure we have a template
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 = True,
693
- colors: plotting.ColorType = 'viridis',
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 = 3,
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
- with open(paths.summary, 'w', encoding='utf-8') as f:
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='h5netcdf')
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 = True,
970
- colors: plotting.ColorType = 'viridis',
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 = 3,
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=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)
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.to_dataframe(),
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 = 'viridis',
1360
+ colors: plotting.ColorType | None = None,
1112
1361
  text_info: str = 'percent+label+value',
1113
1362
  save: bool | pathlib.Path = False,
1114
- show: bool = True,
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, suffix_parts = _apply_selection_to_data(inputs, select=select, drop=True)
1181
- 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
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 = list(set(extra_dims_inputs + extra_dims_outputs))
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.to_pandas(),
1223
- data_right=outputs.to_pandas(),
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 = True,
1351
- colors: plotting.ColorType = 'viridis',
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 = 3,
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
- # Create a dataset with just charge_state and plot it as lines
1441
- # This ensures proper handling of facets and animation
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.to_dataframe(),
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='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,
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
- with open(path.with_suffix('.json'), encoding='utf-8') as f:
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: str = 'portland',
2172
+ colors: plotting.ColorType | None = None,
1786
2173
  save: bool | pathlib.Path = False,
1787
- show: bool = True,
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 = 3,
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 != 'portland': # Check if user explicitly set 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
- with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f:
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 = 'viridis',
2302
+ colors: plotting.ColorType | None = None,
1904
2303
  save: bool | pathlib.Path = False,
1905
- show: bool = True,
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 = 3,
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
- # Check for conflict with new parameter
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