flixopt 2.2.0b0__py3-none-any.whl → 3.0.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.

Files changed (63) hide show
  1. flixopt/__init__.py +35 -1
  2. flixopt/aggregation.py +60 -81
  3. flixopt/calculation.py +381 -196
  4. flixopt/components.py +1022 -359
  5. flixopt/config.py +553 -191
  6. flixopt/core.py +475 -1315
  7. flixopt/effects.py +477 -214
  8. flixopt/elements.py +591 -344
  9. flixopt/features.py +403 -957
  10. flixopt/flow_system.py +781 -293
  11. flixopt/interface.py +1159 -189
  12. flixopt/io.py +50 -55
  13. flixopt/linear_converters.py +384 -92
  14. flixopt/modeling.py +759 -0
  15. flixopt/network_app.py +789 -0
  16. flixopt/plotting.py +273 -135
  17. flixopt/results.py +639 -383
  18. flixopt/solvers.py +25 -21
  19. flixopt/structure.py +928 -442
  20. flixopt/utils.py +34 -5
  21. flixopt-3.0.0.dist-info/METADATA +209 -0
  22. flixopt-3.0.0.dist-info/RECORD +26 -0
  23. {flixopt-2.2.0b0.dist-info → flixopt-3.0.0.dist-info}/WHEEL +1 -1
  24. flixopt-3.0.0.dist-info/top_level.txt +1 -0
  25. docs/examples/00-Minimal Example.md +0 -5
  26. docs/examples/01-Basic Example.md +0 -5
  27. docs/examples/02-Complex Example.md +0 -10
  28. docs/examples/03-Calculation Modes.md +0 -5
  29. docs/examples/index.md +0 -5
  30. docs/faq/contribute.md +0 -49
  31. docs/faq/index.md +0 -3
  32. docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
  33. docs/images/architecture_flixOpt.png +0 -0
  34. docs/images/flixopt-icon.svg +0 -1
  35. docs/javascripts/mathjax.js +0 -18
  36. docs/release-notes/_template.txt +0 -32
  37. docs/release-notes/index.md +0 -7
  38. docs/release-notes/v2.0.0.md +0 -93
  39. docs/release-notes/v2.0.1.md +0 -12
  40. docs/release-notes/v2.1.0.md +0 -31
  41. docs/release-notes/v2.2.0.md +0 -55
  42. docs/user-guide/Mathematical Notation/Bus.md +0 -33
  43. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
  44. docs/user-guide/Mathematical Notation/Flow.md +0 -26
  45. docs/user-guide/Mathematical Notation/Investment.md +0 -115
  46. docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
  47. docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
  48. docs/user-guide/Mathematical Notation/Storage.md +0 -44
  49. docs/user-guide/Mathematical Notation/index.md +0 -22
  50. docs/user-guide/Mathematical Notation/others.md +0 -3
  51. docs/user-guide/index.md +0 -124
  52. flixopt/config.yaml +0 -10
  53. flixopt-2.2.0b0.dist-info/METADATA +0 -146
  54. flixopt-2.2.0b0.dist-info/RECORD +0 -59
  55. flixopt-2.2.0b0.dist-info/top_level.txt +0 -5
  56. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  57. pics/architecture_flixOpt.png +0 -0
  58. pics/flixOpt_plotting.jpg +0 -0
  59. pics/flixopt-icon.svg +0 -1
  60. pics/pics.pptx +0 -0
  61. scripts/gen_ref_pages.py +0 -54
  62. tests/ressources/Zeitreihen2020.csv +0 -35137
  63. {flixopt-2.2.0b0.dist-info → flixopt-3.0.0.dist-info}/licenses/LICENSE +0 -0
flixopt/plotting.py CHANGED
@@ -1,14 +1,37 @@
1
+ """Comprehensive visualization toolkit for flixopt optimization results and data analysis.
2
+
3
+ This module provides a unified plotting interface supporting both Plotly (interactive)
4
+ and Matplotlib (static) backends for visualizing energy system optimization results.
5
+ It offers specialized plotting functions for time series, heatmaps, network diagrams,
6
+ and statistical analyses commonly needed in energy system modeling.
7
+
8
+ Key Features:
9
+ **Dual Backend Support**: Seamless switching between Plotly and Matplotlib
10
+ **Energy System Focus**: Specialized plots for power flows, storage states, emissions
11
+ **Color Management**: Intelligent color processing and palette management
12
+ **Export Capabilities**: High-quality export for reports and publications
13
+ **Integration Ready**: Designed for use with CalculationResults and standalone analysis
14
+
15
+ Main Plot Types:
16
+ - **Time Series**: Flow rates, power profiles, storage states over time
17
+ - **Heatmaps**: High-resolution temporal data visualization with customizable aggregation
18
+ - **Network Diagrams**: System topology with flow visualization
19
+ - **Statistical Plots**: Distribution analysis, correlation studies, performance metrics
20
+ - **Comparative Analysis**: Multi-scenario and sensitivity study visualizations
21
+
22
+ The module integrates seamlessly with flixopt's result classes while remaining
23
+ accessible for standalone data visualization tasks.
1
24
  """
2
- This module contains the plotting functionality of the flixopt framework.
3
- It provides high level functions to plot data with plotly and matplotlib.
4
- It's meant to be used in results.py, but is designed to be used by the end user as well.
5
- """
25
+
26
+ from __future__ import annotations
6
27
 
7
28
  import itertools
8
29
  import logging
30
+ import os
9
31
  import pathlib
10
- from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
32
+ from typing import TYPE_CHECKING, Any, Literal
11
33
 
34
+ import matplotlib
12
35
  import matplotlib.colors as mcolors
13
36
  import matplotlib.pyplot as plt
14
37
  import numpy as np
@@ -33,18 +56,63 @@ _portland_colors = [
33
56
  ]
34
57
 
35
58
  # Check if the colormap already exists before registering it
36
- if 'portland' not in plt.colormaps:
37
- plt.colormaps.register(mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors))
38
-
39
-
40
- ColorType = Union[str, List[str], Dict[str, str]]
41
- """Identifier for the colors to use.
42
- Use the name of a colorscale, a list of colors or a dictionary of labels to colors.
43
- The colors must be valid color strings (HEX or names). Depending on the Engine used, other formats are possible.
44
- See also:
45
- - https://htmlcolorcodes.com/color-names/
46
- - https://matplotlib.org/stable/tutorials/colors/colormaps.html
47
- - https://plotly.com/python/builtin-colorscales/
59
+ if hasattr(plt, 'colormaps'): # Matplotlib >= 3.7
60
+ registry = plt.colormaps
61
+ if 'portland' not in registry:
62
+ registry.register(mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors))
63
+ else: # Matplotlib < 3.7
64
+ if 'portland' not in [c for c in plt.colormaps()]:
65
+ plt.register_cmap(name='portland', cmap=mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors))
66
+
67
+
68
+ ColorType = str | list[str] | dict[str, str]
69
+ """Flexible color specification type supporting multiple input formats for visualization.
70
+
71
+ Color specifications can take several forms to accommodate different use cases:
72
+
73
+ **Named Colormaps** (str):
74
+ - Standard colormaps: 'viridis', 'plasma', 'cividis', 'tab10', 'Set1'
75
+ - Energy-focused: 'portland' (custom flixopt colormap for energy systems)
76
+ - Backend-specific maps available in Plotly and Matplotlib
77
+
78
+ **Color Lists** (list[str]):
79
+ - Explicit color sequences: ['red', 'blue', 'green', 'orange']
80
+ - HEX codes: ['#FF0000', '#0000FF', '#00FF00', '#FFA500']
81
+ - Mixed formats: ['red', '#0000FF', 'green', 'orange']
82
+
83
+ **Label-to-Color Mapping** (dict[str, str]):
84
+ - Explicit associations: {'Wind': 'skyblue', 'Solar': 'gold', 'Gas': 'brown'}
85
+ - Ensures consistent colors across different plots and datasets
86
+ - Ideal for energy system components with semantic meaning
87
+
88
+ Examples:
89
+ ```python
90
+ # Named colormap
91
+ colors = 'viridis' # Automatic color generation
92
+
93
+ # Explicit color list
94
+ colors = ['red', 'blue', 'green', '#FFD700']
95
+
96
+ # Component-specific mapping
97
+ colors = {
98
+ 'Wind_Turbine': 'skyblue',
99
+ 'Solar_Panel': 'gold',
100
+ 'Natural_Gas': 'brown',
101
+ 'Battery': 'green',
102
+ 'Electric_Load': 'darkred'
103
+ }
104
+ ```
105
+
106
+ Color Format Support:
107
+ - **Named Colors**: 'red', 'blue', 'forestgreen', 'darkorange'
108
+ - **HEX Codes**: '#FF0000', '#0000FF', '#228B22', '#FF8C00'
109
+ - **RGB Tuples**: (255, 0, 0), (0, 0, 255) [Matplotlib only]
110
+ - **RGBA**: 'rgba(255,0,0,0.8)' [Plotly only]
111
+
112
+ References:
113
+ - HTML Color Names: https://htmlcolorcodes.com/color-names/
114
+ - Matplotlib Colormaps: https://matplotlib.org/stable/tutorials/colors/colormaps.html
115
+ - Plotly Built-in Colorscales: https://plotly.com/python/builtin-colorscales/
48
116
  """
49
117
 
50
118
  PlottingEngine = Literal['plotly', 'matplotlib']
@@ -52,22 +120,74 @@ PlottingEngine = Literal['plotly', 'matplotlib']
52
120
 
53
121
 
54
122
  class ColorProcessor:
55
- """Class to handle color processing for different visualization engines."""
123
+ """Intelligent color management system for consistent multi-backend visualization.
124
+
125
+ This class provides unified color processing across Plotly and Matplotlib backends,
126
+ ensuring consistent visual appearance regardless of the plotting engine used.
127
+ It handles color palette generation, named colormap translation, and intelligent
128
+ color cycling for complex datasets with many categories.
129
+
130
+ Key Features:
131
+ **Backend Agnostic**: Automatic color format conversion between engines
132
+ **Palette Management**: Support for named colormaps, custom palettes, and color lists
133
+ **Intelligent Cycling**: Smart color assignment for datasets with many categories
134
+ **Fallback Handling**: Graceful degradation when requested colormaps are unavailable
135
+ **Energy System Colors**: Built-in palettes optimized for energy system visualization
136
+
137
+ Color Input Types:
138
+ - **Named Colormaps**: 'viridis', 'plasma', 'portland', 'tab10', etc.
139
+ - **Color Lists**: ['red', 'blue', 'green'] or ['#FF0000', '#0000FF', '#00FF00']
140
+ - **Label Dictionaries**: {'Generator': 'red', 'Storage': 'blue', 'Load': 'green'}
141
+
142
+ Examples:
143
+ Basic color processing:
144
+
145
+ ```python
146
+ # Initialize for Plotly backend
147
+ processor = ColorProcessor(engine='plotly', default_colormap='viridis')
148
+
149
+ # Process different color specifications
150
+ colors = processor.process_colors('plasma', ['Gen1', 'Gen2', 'Storage'])
151
+ colors = processor.process_colors(['red', 'blue', 'green'], ['A', 'B', 'C'])
152
+ colors = processor.process_colors({'Wind': 'skyblue', 'Solar': 'gold'}, ['Wind', 'Solar', 'Gas'])
153
+
154
+ # Switch to Matplotlib
155
+ processor = ColorProcessor(engine='matplotlib')
156
+ mpl_colors = processor.process_colors('tab10', component_labels)
157
+ ```
158
+
159
+ Energy system visualization:
160
+
161
+ ```python
162
+ # Specialized energy system palette
163
+ energy_colors = {
164
+ 'Natural_Gas': '#8B4513', # Brown
165
+ 'Electricity': '#FFD700', # Gold
166
+ 'Heat': '#FF4500', # Red-orange
167
+ 'Cooling': '#87CEEB', # Sky blue
168
+ 'Hydrogen': '#E6E6FA', # Lavender
169
+ 'Battery': '#32CD32', # Lime green
170
+ }
171
+
172
+ processor = ColorProcessor('plotly')
173
+ flow_colors = processor.process_colors(energy_colors, flow_labels)
174
+ ```
56
175
 
57
- def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str = 'viridis'):
58
- """
59
- Initialize the color processor.
176
+ Args:
177
+ engine: Plotting backend ('plotly' or 'matplotlib'). Determines output color format.
178
+ default_colormap: Fallback colormap when requested palettes are unavailable.
179
+ Common options: 'viridis', 'plasma', 'tab10', 'portland'.
60
180
 
61
- Args:
62
- engine: The plotting engine to use ('plotly' or 'matplotlib')
63
- default_colormap: Default colormap to use if none is specified
64
- """
181
+ """
182
+
183
+ def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str = 'viridis'):
184
+ """Initialize the color processor with specified backend and defaults."""
65
185
  if engine not in ['plotly', 'matplotlib']:
66
186
  raise TypeError(f'engine must be "plotly" or "matplotlib", but is {engine}')
67
187
  self.engine = engine
68
188
  self.default_colormap = default_colormap
69
189
 
70
- def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> List[Any]:
190
+ def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> list[Any]:
71
191
  """
72
192
  Generate colors from a named colormap.
73
193
 
@@ -76,13 +196,13 @@ class ColorProcessor:
76
196
  num_colors: Number of colors to generate
77
197
 
78
198
  Returns:
79
- List of colors in the format appropriate for the engine
199
+ list of colors in the format appropriate for the engine
80
200
  """
81
201
  if self.engine == 'plotly':
82
202
  try:
83
203
  colorscale = px.colors.get_colorscale(colormap_name)
84
204
  except PlotlyError as e:
85
- logger.warning(f"Colorscale '{colormap_name}' not found in Plotly. Using {self.default_colormap}: {e}")
205
+ logger.error(f"Colorscale '{colormap_name}' not found in Plotly. Using {self.default_colormap}: {e}")
86
206
  colorscale = px.colors.get_colorscale(self.default_colormap)
87
207
 
88
208
  # Generate evenly spaced points
@@ -93,26 +213,24 @@ class ColorProcessor:
93
213
  try:
94
214
  cmap = plt.get_cmap(colormap_name, num_colors)
95
215
  except ValueError as e:
96
- logger.warning(
97
- f"Colormap '{colormap_name}' not found in Matplotlib. Using {self.default_colormap}: {e}"
98
- )
216
+ logger.error(f"Colormap '{colormap_name}' not found in Matplotlib. Using {self.default_colormap}: {e}")
99
217
  cmap = plt.get_cmap(self.default_colormap, num_colors)
100
218
 
101
219
  return [cmap(i) for i in range(num_colors)]
102
220
 
103
- def _handle_color_list(self, colors: List[str], num_labels: int) -> List[str]:
221
+ def _handle_color_list(self, colors: list[str], num_labels: int) -> list[str]:
104
222
  """
105
223
  Handle a list of colors, cycling if necessary.
106
224
 
107
225
  Args:
108
- colors: List of color strings
226
+ colors: list of color strings
109
227
  num_labels: Number of labels that need colors
110
228
 
111
229
  Returns:
112
- List of colors matching the number of labels
230
+ list of colors matching the number of labels
113
231
  """
114
232
  if len(colors) == 0:
115
- logger.warning(f'Empty color list provided. Using {self.default_colormap} instead.')
233
+ logger.error(f'Empty color list provided. Using {self.default_colormap} instead.')
116
234
  return self._generate_colors_from_colormap(self.default_colormap, num_labels)
117
235
 
118
236
  if len(colors) < num_labels:
@@ -130,23 +248,23 @@ class ColorProcessor:
130
248
  )
131
249
  return colors[:num_labels]
132
250
 
133
- def _handle_color_dict(self, colors: Dict[str, str], labels: List[str]) -> List[str]:
251
+ def _handle_color_dict(self, colors: dict[str, str], labels: list[str]) -> list[str]:
134
252
  """
135
253
  Handle a dictionary mapping labels to colors.
136
254
 
137
255
  Args:
138
256
  colors: Dictionary mapping labels to colors
139
- labels: List of labels that need colors
257
+ labels: list of labels that need colors
140
258
 
141
259
  Returns:
142
- List of colors in the same order as labels
260
+ list of colors in the same order as labels
143
261
  """
144
262
  if len(colors) == 0:
145
263
  logger.warning(f'Empty color dictionary provided. Using {self.default_colormap} instead.')
146
264
  return self._generate_colors_from_colormap(self.default_colormap, len(labels))
147
265
 
148
266
  # Find missing labels
149
- missing_labels = set(labels) - set(colors.keys())
267
+ missing_labels = sorted(set(labels) - set(colors.keys()))
150
268
  if missing_labels:
151
269
  logger.warning(
152
270
  f'Some labels have no color specified: {missing_labels}. Using {self.default_colormap} for these.'
@@ -168,15 +286,15 @@ class ColorProcessor:
168
286
  def process_colors(
169
287
  self,
170
288
  colors: ColorType,
171
- labels: List[str],
289
+ labels: list[str],
172
290
  return_mapping: bool = False,
173
- ) -> Union[List[Any], Dict[str, Any]]:
291
+ ) -> list[Any] | dict[str, Any]:
174
292
  """
175
293
  Process colors for the specified labels.
176
294
 
177
295
  Args:
178
296
  colors: Color specification (colormap name, list of colors, or label-to-color mapping)
179
- labels: List of data labels that need colors assigned
297
+ labels: list of data labels that need colors assigned
180
298
  return_mapping: If True, returns a dictionary mapping labels to colors;
181
299
  if False, returns a list of colors in the same order as labels
182
300
 
@@ -184,7 +302,7 @@ class ColorProcessor:
184
302
  Either a list of colors or a dictionary mapping labels to colors
185
303
  """
186
304
  if len(labels) == 0:
187
- logger.warning('No labels provided for color assignment.')
305
+ logger.error('No labels provided for color assignment.')
188
306
  return {} if return_mapping else []
189
307
 
190
308
  # Process based on type of colors input
@@ -195,7 +313,7 @@ class ColorProcessor:
195
313
  elif isinstance(colors, dict):
196
314
  color_list = self._handle_color_dict(colors, labels)
197
315
  else:
198
- logger.warning(
316
+ logger.error(
199
317
  f'Unsupported color specification type: {type(colors)}. Using {self.default_colormap} instead.'
200
318
  )
201
319
  color_list = self._generate_colors_from_colormap(self.default_colormap, len(labels))
@@ -214,7 +332,7 @@ def with_plotly(
214
332
  title: str = '',
215
333
  ylabel: str = '',
216
334
  xlabel: str = 'Time in h',
217
- fig: Optional[go.Figure] = None,
335
+ fig: go.Figure | None = None,
218
336
  ) -> go.Figure:
219
337
  """
220
338
  Plot a DataFrame with Plotly, using either stacked bars or stepped lines.
@@ -230,13 +348,14 @@ def with_plotly(
230
348
  - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'})
231
349
  title: The title of the plot.
232
350
  ylabel: The label for the y-axis.
351
+ xlabel: The label for the x-axis.
233
352
  fig: A Plotly figure object to plot on. If not provided, a new figure will be created.
234
353
 
235
354
  Returns:
236
355
  A Plotly figure object containing the generated plot.
237
356
  """
238
- if style not in ['stacked_bar', 'line', 'area', 'grouped_bar']:
239
- raise ValueError(f"'style' must be one of {['stacked_bar', 'line', 'area', 'grouped_bar']}")
357
+ if style not in ('stacked_bar', 'line', 'area', 'grouped_bar'):
358
+ raise ValueError(f"'style' must be one of {{'stacked_bar','line','area', 'grouped_bar'}}, got {style!r}")
240
359
  if data.empty:
241
360
  return go.Figure()
242
361
 
@@ -251,8 +370,9 @@ def with_plotly(
251
370
  x=data.index,
252
371
  y=data[column],
253
372
  name=column,
254
- marker=dict(color=processed_colors[i],
255
- line=dict(width=0, color='rgba(0,0,0,0)')), #Transparent line with 0 width
373
+ marker=dict(
374
+ color=processed_colors[i], line=dict(width=0, color='rgba(0,0,0,0)')
375
+ ), # Transparent line with 0 width
256
376
  )
257
377
  )
258
378
 
@@ -263,14 +383,7 @@ def with_plotly(
263
383
  )
264
384
  if style == 'grouped_bar':
265
385
  for i, column in enumerate(data.columns):
266
- fig.add_trace(
267
- go.Bar(
268
- x=data.index,
269
- y=data[column],
270
- name=column,
271
- marker=dict(color=processed_colors[i])
272
- )
273
- )
386
+ fig.add_trace(go.Bar(x=data.index, y=data[column], name=column, marker=dict(color=processed_colors[i])))
274
387
 
275
388
  fig.update_layout(
276
389
  barmode='group',
@@ -298,7 +411,7 @@ def with_plotly(
298
411
  mixed_columns = list(set(data.columns) - set(positive_columns + negative_columns))
299
412
 
300
413
  if mixed_columns:
301
- logger.warning(
414
+ logger.error(
302
415
  f'Data for plotting stacked lines contains columns with both positive and negative values:'
303
416
  f' {mixed_columns}. These can not be stacked, and are printed as simple lines'
304
417
  )
@@ -348,14 +461,6 @@ def with_plotly(
348
461
  plot_bgcolor='rgba(0,0,0,0)', # Transparent background
349
462
  paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background
350
463
  font=dict(size=14), # Increase font size for better readability
351
- legend=dict(
352
- orientation='h', # Horizontal legend
353
- yanchor='bottom',
354
- y=-0.3, # Adjusts how far below the plot it appears
355
- xanchor='center',
356
- x=0.5,
357
- title_text=None, # Removes legend title for a cleaner look
358
- ),
359
464
  )
360
465
 
361
466
  return fig
@@ -368,10 +473,10 @@ def with_matplotlib(
368
473
  title: str = '',
369
474
  ylabel: str = '',
370
475
  xlabel: str = 'Time in h',
371
- figsize: Tuple[int, int] = (12, 6),
372
- fig: Optional[plt.Figure] = None,
373
- ax: Optional[plt.Axes] = None,
374
- ) -> Tuple[plt.Figure, plt.Axes]:
476
+ figsize: tuple[int, int] = (12, 6),
477
+ fig: plt.Figure | None = None,
478
+ ax: plt.Axes | None = None,
479
+ ) -> tuple[plt.Figure, plt.Axes]:
375
480
  """
376
481
  Plot a DataFrame with Matplotlib using stacked bars or stepped lines.
377
482
 
@@ -397,9 +502,9 @@ def with_matplotlib(
397
502
  - If `style` is 'stacked_bar', bars are stacked for both positive and negative values.
398
503
  Negative values are stacked separately without extra labels in the legend.
399
504
  - If `style` is 'line', stepped lines are drawn for each data series.
400
- - The legend is placed below the plot to accommodate multiple data series.
401
505
  """
402
- assert style in ['stacked_bar', 'line'], f"'style' must be one of {['stacked_bar', 'line']} for matplotlib"
506
+ if style not in ('stacked_bar', 'line'):
507
+ raise ValueError(f"'style' must be one of {{'stacked_bar','line'}} for matplotlib, got {style!r}")
403
508
 
404
509
  if fig is None or ax is None:
405
510
  fig, ax = plt.subplots(figsize=figsize)
@@ -463,8 +568,8 @@ def heat_map_matplotlib(
463
568
  title: str = '',
464
569
  xlabel: str = 'Period',
465
570
  ylabel: str = 'Step',
466
- figsize: Tuple[float, float] = (12, 6),
467
- ) -> Tuple[plt.Figure, plt.Axes]:
571
+ figsize: tuple[float, float] = (12, 6),
572
+ ) -> tuple[plt.Figure, plt.Axes]:
468
573
  """
469
574
  Plots a DataFrame as a heatmap using Matplotlib. The columns of the DataFrame will be displayed on the x-axis,
470
575
  the index will be displayed on the y-axis, and the values will represent the 'heat' intensity in the plot.
@@ -473,6 +578,9 @@ def heat_map_matplotlib(
473
578
  data: A DataFrame containing the data to be visualized. The index will be used for the y-axis, and columns will be used for the x-axis.
474
579
  The values in the DataFrame will be represented as colors in the heatmap.
475
580
  color_map: The colormap to use for the heatmap. Default is 'viridis'. Matplotlib supports various colormaps like 'plasma', 'inferno', 'cividis', etc.
581
+ title: The title of the plot.
582
+ xlabel: The label for the x-axis.
583
+ ylabel: The label for the y-axis.
476
584
  figsize: The size of the figure to create. Default is (12, 6), which results in a width of 12 inches and a height of 6 inches.
477
585
 
478
586
  Returns:
@@ -491,7 +599,7 @@ def heat_map_matplotlib(
491
599
 
492
600
  # Create the heatmap plot
493
601
  fig, ax = plt.subplots(figsize=figsize)
494
- ax.pcolormesh(data.values, cmap=color_map)
602
+ ax.pcolormesh(data.values, cmap=color_map, shading='auto')
495
603
  ax.invert_yaxis() # Flip the y-axis to start at the top
496
604
 
497
605
  # Adjust ticks and labels for x and y axes
@@ -511,7 +619,7 @@ def heat_map_matplotlib(
511
619
 
512
620
  # Add the colorbar
513
621
  sm1 = plt.cm.ScalarMappable(cmap=color_map, norm=plt.Normalize(vmin=color_bar_min, vmax=color_bar_max))
514
- sm1._A = []
622
+ sm1.set_array([])
515
623
  fig.colorbar(sm1, ax=ax, pad=0.12, aspect=15, fraction=0.2, orientation='horizontal')
516
624
 
517
625
  fig.tight_layout()
@@ -535,11 +643,11 @@ def heat_map_plotly(
535
643
  data: A DataFrame with the data to be visualized. The index will be used for the y-axis, and columns will be used for the x-axis.
536
644
  The values in the DataFrame will be represented as colors in the heatmap.
537
645
  color_map: The color scale to use for the heatmap. Default is 'viridis'. Plotly supports various color scales like 'Cividis', 'Inferno', etc.
646
+ title: The title of the heatmap. Default is an empty string.
647
+ xlabel: The label for the x-axis. Default is 'Period'.
648
+ ylabel: The label for the y-axis. Default is 'Step'.
538
649
  categorical_labels: If True, the x and y axes are treated as categorical data (i.e., the index and columns will not be interpreted as continuous data).
539
650
  Default is True. If False, the axes are treated as continuous, which may be useful for time series or numeric data.
540
- show: Wether to show the figure after creation. (This includes saving the figure)
541
- save: Wether to save the figure after creation (without showing)
542
- path: Path to save the figure.
543
651
 
544
652
  Returns:
545
653
  A Plotly figure object containing the heatmap. This can be further customized and saved
@@ -630,12 +738,12 @@ def heat_map_data_from_df(
630
738
  df: pd.DataFrame,
631
739
  periods: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'],
632
740
  steps_per_period: Literal['W', 'D', 'h', '15min', 'min'],
633
- fill: Optional[Literal['ffill', 'bfill']] = None,
741
+ fill: Literal['ffill', 'bfill'] | None = None,
634
742
  ) -> pd.DataFrame:
635
743
  """
636
744
  Reshapes a DataFrame with a DateTime index into a 2D array for heatmap plotting,
637
745
  based on a specified sample rate.
638
- If a non-valid combination of periods and steps per period is used, falls back to numerical indices
746
+ Only specific combinations of `periods` and `steps_per_period` are supported; invalid combinations raise an assertion.
639
747
 
640
748
  Args:
641
749
  df: A DataFrame with a DateTime index containing the data to reshape.
@@ -650,7 +758,7 @@ def heat_map_data_from_df(
650
758
  and columns representing each period.
651
759
  """
652
760
  assert pd.api.types.is_datetime64_any_dtype(df.index), (
653
- 'The index of the Dataframe must be datetime to transfrom it properly for a heatmap plot'
761
+ 'The index of the DataFrame must be datetime to transform it properly for a heatmap plot'
654
762
  )
655
763
 
656
764
  # Define formats for different combinations of `periods` and `steps_per_period`
@@ -663,23 +771,26 @@ def heat_map_data_from_df(
663
771
  ('W', 'D'): ('%Y-w%W', '%w_%A'), # week and day of week (with prefix for proper sorting)
664
772
  ('W', 'h'): ('%Y-w%W', '%w_%A %H:00'),
665
773
  ('D', 'h'): ('%Y-%m-%d', '%H:00'), # Day and hour
666
- ('D', '15min'): ('%Y-%m-%d', '%H:%MM'), # Day and hour
774
+ ('D', '15min'): ('%Y-%m-%d', '%H:%M'), # Day and minute
667
775
  ('h', '15min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour
668
776
  ('h', 'min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour
669
777
  }
670
778
 
671
- minimum_time_diff_in_min = df.index.to_series().diff().min().total_seconds() / 60 # Smallest time_diff in minutes
779
+ if df.empty:
780
+ raise ValueError('DataFrame is empty.')
781
+ diffs = df.index.to_series().diff().dropna()
782
+ minimum_time_diff_in_min = diffs.min().total_seconds() / 60
672
783
  time_intervals = {'min': 1, '15min': 15, 'h': 60, 'D': 24 * 60, 'W': 7 * 24 * 60}
673
784
  if time_intervals[steps_per_period] > minimum_time_diff_in_min:
674
- time_intervals[steps_per_period]
675
- logger.warning(
785
+ logger.error(
676
786
  f'To compute the heatmap, the data was aggregated from {minimum_time_diff_in_min:.2f} min to '
677
787
  f'{time_intervals[steps_per_period]:.2f} min. Mean values are displayed.'
678
788
  )
679
789
 
680
790
  # Select the format based on the `periods` and `steps_per_period` combination
681
791
  format_pair = (periods, steps_per_period)
682
- assert format_pair in formats, f'{format_pair} is not a valid format. Choose from {list(formats.keys())}'
792
+ if format_pair not in formats:
793
+ raise ValueError(f'{format_pair} is not a valid format. Choose from {list(formats.keys())}')
683
794
  period_format, step_format = formats[format_pair]
684
795
 
685
796
  df = df.sort_index() # Ensure DataFrame is sorted by time index
@@ -693,7 +804,7 @@ def heat_map_data_from_df(
693
804
 
694
805
  resampled_data['period'] = resampled_data.index.strftime(period_format)
695
806
  resampled_data['step'] = resampled_data.index.strftime(step_format)
696
- if '%w_%A' in step_format: # SHift index of strings to ensure proper sorting
807
+ if '%w_%A' in step_format: # Shift index of strings to ensure proper sorting
697
808
  resampled_data['step'] = resampled_data['step'].apply(
698
809
  lambda x: x.replace('0_Sunday', '7_Sunday') if '0_Sunday' in x else x
699
810
  )
@@ -707,19 +818,19 @@ def heat_map_data_from_df(
707
818
  def plot_network(
708
819
  node_infos: dict,
709
820
  edge_infos: dict,
710
- path: Optional[Union[str, pathlib.Path]] = None,
711
- controls: Union[
712
- bool,
713
- List[Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer']],
821
+ path: str | pathlib.Path | None = None,
822
+ controls: bool
823
+ | list[
824
+ Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer']
714
825
  ] = True,
715
826
  show: bool = False,
716
- ) -> Optional['pyvis.network.Network']:
827
+ ) -> pyvis.network.Network | None:
717
828
  """
718
829
  Visualizes the network structure of a FlowSystem using PyVis, using info-dictionaries.
719
830
 
720
831
  Args:
721
832
  path: Path to save the HTML visualization. `False`: Visualization is created but not saved. `str` or `Path`: Specifies file path (default: 'results/network.html').
722
- controls: UI controls to add to the visualization. `True`: Enables all available controls. `List`: Specify controls, e.g., ['nodes', 'layout'].
833
+ controls: UI controls to add to the visualization. `True`: Enables all available controls. `list`: Specify controls, e.g., ['nodes', 'layout'].
723
834
  Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'.
724
835
  You can play with these and generate a Dictionary from it that can be applied to the network returned by this function.
725
836
  network.set_options()
@@ -781,11 +892,9 @@ def plot_network(
781
892
 
782
893
  worked = webbrowser.open(f'file://{path.resolve()}', 2)
783
894
  if not worked:
784
- logger.warning(
785
- f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}'
786
- )
895
+ logger.error(f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}')
787
896
  except Exception as e:
788
- logger.warning(
897
+ logger.error(
789
898
  f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}: {e}'
790
899
  )
791
900
 
@@ -796,7 +905,7 @@ def pie_with_plotly(
796
905
  title: str = '',
797
906
  legend_title: str = '',
798
907
  hole: float = 0.0,
799
- fig: Optional[go.Figure] = None,
908
+ fig: go.Figure | None = None,
800
909
  ) -> go.Figure:
801
910
  """
802
911
  Create a pie chart with Plotly to visualize the proportion of values in a DataFrame.
@@ -824,7 +933,7 @@ def pie_with_plotly(
824
933
 
825
934
  """
826
935
  if data.empty:
827
- logger.warning('Empty DataFrame provided for pie chart. Returning empty figure.')
936
+ logger.error('Empty DataFrame provided for pie chart. Returning empty figure.')
828
937
  return go.Figure()
829
938
 
830
939
  # Create a copy to avoid modifying the original DataFrame
@@ -832,7 +941,7 @@ def pie_with_plotly(
832
941
 
833
942
  # Check if any negative values and warn
834
943
  if (data_copy < 0).any().any():
835
- logger.warning('Negative values detected in data. Using absolute values for pie chart.')
944
+ logger.error('Negative values detected in data. Using absolute values for pie chart.')
836
945
  data_copy = data_copy.abs()
837
946
 
838
947
  # If data has multiple rows, sum them to get total for each column
@@ -846,7 +955,7 @@ def pie_with_plotly(
846
955
  values = data_sum.values.tolist()
847
956
 
848
957
  # Apply color mapping using the unified color processor
849
- processed_colors = ColorProcessor(engine='plotly').process_colors(colors, list(data.columns))
958
+ processed_colors = ColorProcessor(engine='plotly').process_colors(colors, labels)
850
959
 
851
960
  # Create figure if not provided
852
961
  fig = fig if fig is not None else go.Figure()
@@ -882,10 +991,10 @@ def pie_with_matplotlib(
882
991
  title: str = '',
883
992
  legend_title: str = 'Categories',
884
993
  hole: float = 0.0,
885
- figsize: Tuple[int, int] = (10, 8),
886
- fig: Optional[plt.Figure] = None,
887
- ax: Optional[plt.Axes] = None,
888
- ) -> Tuple[plt.Figure, plt.Axes]:
994
+ figsize: tuple[int, int] = (10, 8),
995
+ fig: plt.Figure | None = None,
996
+ ax: plt.Axes | None = None,
997
+ ) -> tuple[plt.Figure, plt.Axes]:
889
998
  """
890
999
  Create a pie chart with Matplotlib to visualize the proportion of values in a DataFrame.
891
1000
 
@@ -914,7 +1023,7 @@ def pie_with_matplotlib(
914
1023
 
915
1024
  """
916
1025
  if data.empty:
917
- logger.warning('Empty DataFrame provided for pie chart. Returning empty figure.')
1026
+ logger.error('Empty DataFrame provided for pie chart. Returning empty figure.')
918
1027
  if fig is None or ax is None:
919
1028
  fig, ax = plt.subplots(figsize=figsize)
920
1029
  return fig, ax
@@ -924,7 +1033,7 @@ def pie_with_matplotlib(
924
1033
 
925
1034
  # Check if any negative values and warn
926
1035
  if (data_copy < 0).any().any():
927
- logger.warning('Negative values detected in data. Using absolute values for pie chart.')
1036
+ logger.error('Negative values detected in data. Using absolute values for pie chart.')
928
1037
  data_copy = data_copy.abs()
929
1038
 
930
1039
  # If data has multiple rows, sum them to get total for each column
@@ -994,7 +1103,7 @@ def dual_pie_with_plotly(
994
1103
  data_right: pd.Series,
995
1104
  colors: ColorType = 'viridis',
996
1105
  title: str = '',
997
- subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'),
1106
+ subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'),
998
1107
  legend_title: str = '',
999
1108
  hole: float = 0.2,
1000
1109
  lower_percentage_group: float = 5.0,
@@ -1015,8 +1124,8 @@ def dual_pie_with_plotly(
1015
1124
  title: The main title of the plot.
1016
1125
  subtitles: Tuple containing the subtitles for (left, right) charts.
1017
1126
  legend_title: The title for the legend.
1018
- hole: Size of the hole in the center for creating donut charts (0.0 to 100).
1019
- lower_percentage_group: Whether to group small segments (below percentage (0...1)) into an "Other" category.
1127
+ hole: Size of the hole in the center for creating donut charts (0.0 to 1.0).
1128
+ lower_percentage_group: Group segments whose cumulative share is below this percentage (0–100) into "Other".
1020
1129
  hover_template: Template for hover text. Use %{label}, %{value}, %{percent}.
1021
1130
  text_info: What to show on pie segments: 'label', 'percent', 'value', 'label+percent',
1022
1131
  'label+value', 'percent+value', 'label+percent+value', or 'none'.
@@ -1029,7 +1138,7 @@ def dual_pie_with_plotly(
1029
1138
 
1030
1139
  # Check for empty data
1031
1140
  if data_left.empty and data_right.empty:
1032
- logger.warning('Both datasets are empty. Returning empty figure.')
1141
+ logger.error('Both datasets are empty. Returning empty figure.')
1033
1142
  return go.Figure()
1034
1143
 
1035
1144
  # Create a subplot figure
@@ -1052,7 +1161,7 @@ def dual_pie_with_plotly(
1052
1161
  """
1053
1162
  # Handle negative values
1054
1163
  if (series < 0).any():
1055
- logger.warning('Negative values detected in data. Using absolute values for pie chart.')
1164
+ logger.error('Negative values detected in data. Using absolute values for pie chart.')
1056
1165
  series = series.abs()
1057
1166
 
1058
1167
  # Remove zeros
@@ -1108,7 +1217,7 @@ def dual_pie_with_plotly(
1108
1217
  labels=labels,
1109
1218
  values=values,
1110
1219
  name=side,
1111
- marker_colors=trace_colors,
1220
+ marker=dict(colors=trace_colors),
1112
1221
  hole=hole,
1113
1222
  textinfo=text_info,
1114
1223
  textposition=text_position,
@@ -1137,7 +1246,6 @@ def dual_pie_with_plotly(
1137
1246
  paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background
1138
1247
  font=dict(size=14),
1139
1248
  margin=dict(t=80, b=50, l=30, r=30),
1140
- legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5, font=dict(size=12)),
1141
1249
  )
1142
1250
 
1143
1251
  return fig
@@ -1148,14 +1256,14 @@ def dual_pie_with_matplotlib(
1148
1256
  data_right: pd.Series,
1149
1257
  colors: ColorType = 'viridis',
1150
1258
  title: str = '',
1151
- subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'),
1259
+ subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'),
1152
1260
  legend_title: str = '',
1153
1261
  hole: float = 0.2,
1154
1262
  lower_percentage_group: float = 5.0,
1155
- figsize: Tuple[int, int] = (14, 7),
1156
- fig: Optional[plt.Figure] = None,
1157
- axes: Optional[List[plt.Axes]] = None,
1158
- ) -> Tuple[plt.Figure, List[plt.Axes]]:
1263
+ figsize: tuple[int, int] = (14, 7),
1264
+ fig: plt.Figure | None = None,
1265
+ axes: list[plt.Axes] | None = None,
1266
+ ) -> tuple[plt.Figure, list[plt.Axes]]:
1159
1267
  """
1160
1268
  Create two pie charts side by side with Matplotlib, with consistent coloring across both charts.
1161
1269
  Leverages the existing pie_with_matplotlib function.
@@ -1181,7 +1289,7 @@ def dual_pie_with_matplotlib(
1181
1289
  """
1182
1290
  # Check for empty data
1183
1291
  if data_left.empty and data_right.empty:
1184
- logger.warning('Both datasets are empty. Returning empty figure.')
1292
+ logger.error('Both datasets are empty. Returning empty figure.')
1185
1293
  if fig is None:
1186
1294
  fig, axes = plt.subplots(1, 2, figsize=figsize)
1187
1295
  return fig, axes
@@ -1199,7 +1307,7 @@ def dual_pie_with_matplotlib(
1199
1307
  """
1200
1308
  # Handle negative values
1201
1309
  if (series < 0).any():
1202
- logger.warning('Negative values detected in data. Using absolute values for pie chart.')
1310
+ logger.error('Negative values detected in data. Using absolute values for pie chart.')
1203
1311
  series = series.abs()
1204
1312
 
1205
1313
  # Remove zeros
@@ -1306,13 +1414,13 @@ def dual_pie_with_matplotlib(
1306
1414
 
1307
1415
 
1308
1416
  def export_figure(
1309
- figure_like: Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]],
1417
+ figure_like: go.Figure | tuple[plt.Figure, plt.Axes],
1310
1418
  default_path: pathlib.Path,
1311
- default_filetype: Optional[str] = None,
1312
- user_path: Optional[pathlib.Path] = None,
1419
+ default_filetype: str | None = None,
1420
+ user_path: pathlib.Path | None = None,
1313
1421
  show: bool = True,
1314
1422
  save: bool = False,
1315
- ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]:
1423
+ ) -> go.Figure | tuple[plt.Figure, plt.Axes]:
1316
1424
  """
1317
1425
  Export a figure to a file and or show it.
1318
1426
 
@@ -1337,22 +1445,52 @@ def export_figure(
1337
1445
 
1338
1446
  if isinstance(figure_like, plotly.graph_objs.Figure):
1339
1447
  fig = figure_like
1340
- if not filename.suffix == '.html':
1341
- logger.debug(f'To save a plotly figure, the filename should end with ".html". Got {filename}')
1342
- if show and not save:
1343
- fig.show()
1344
- elif save and show:
1345
- plotly.offline.plot(fig, filename=str(filename))
1346
- elif save and not show:
1347
- fig.write_html(filename)
1448
+ if filename.suffix != '.html':
1449
+ logger.warning(f'To save a Plotly figure, using .html. Adjusting suffix for {filename}')
1450
+ filename = filename.with_suffix('.html')
1451
+
1452
+ try:
1453
+ is_test_env = 'PYTEST_CURRENT_TEST' in os.environ
1454
+
1455
+ if is_test_env:
1456
+ # Test environment: never open browser, only save if requested
1457
+ if save:
1458
+ fig.write_html(str(filename))
1459
+ # Ignore show flag in tests
1460
+ else:
1461
+ # Production environment: respect show and save flags
1462
+ if save and show:
1463
+ # Save and auto-open in browser
1464
+ plotly.offline.plot(fig, filename=str(filename))
1465
+ elif save and not show:
1466
+ # Save without opening
1467
+ fig.write_html(str(filename))
1468
+ elif show and not save:
1469
+ # Show interactively without saving
1470
+ fig.show()
1471
+ # If neither save nor show: do nothing
1472
+ finally:
1473
+ # Cleanup to prevent socket warnings
1474
+ if hasattr(fig, '_renderer'):
1475
+ fig._renderer = None
1476
+
1348
1477
  return figure_like
1349
1478
 
1350
1479
  elif isinstance(figure_like, tuple):
1351
1480
  fig, ax = figure_like
1352
1481
  if show:
1353
- fig.show()
1482
+ # Only show if using interactive backend and not in test environment
1483
+ backend = matplotlib.get_backend().lower()
1484
+ is_interactive = backend not in {'agg', 'pdf', 'ps', 'svg', 'template'}
1485
+ is_test_env = 'PYTEST_CURRENT_TEST' in os.environ
1486
+
1487
+ if is_interactive and not is_test_env:
1488
+ plt.show()
1489
+
1354
1490
  if save:
1355
1491
  fig.savefig(str(filename), dpi=300)
1492
+ plt.close(fig) # Close figure to free memory
1493
+
1356
1494
  return fig, ax
1357
1495
 
1358
1496
  raise TypeError(f'Figure type not supported: {type(figure_like)}')