flixopt 2.2.0rc2__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 (58) hide show
  1. flixopt/__init__.py +33 -4
  2. flixopt/aggregation.py +60 -80
  3. flixopt/calculation.py +395 -178
  4. flixopt/commons.py +1 -10
  5. flixopt/components.py +939 -448
  6. flixopt/config.py +553 -191
  7. flixopt/core.py +513 -846
  8. flixopt/effects.py +644 -178
  9. flixopt/elements.py +610 -355
  10. flixopt/features.py +394 -966
  11. flixopt/flow_system.py +736 -219
  12. flixopt/interface.py +1104 -302
  13. flixopt/io.py +103 -79
  14. flixopt/linear_converters.py +387 -95
  15. flixopt/modeling.py +759 -0
  16. flixopt/network_app.py +73 -39
  17. flixopt/plotting.py +294 -138
  18. flixopt/results.py +1253 -299
  19. flixopt/solvers.py +25 -21
  20. flixopt/structure.py +938 -396
  21. flixopt/utils.py +38 -12
  22. flixopt-3.0.0.dist-info/METADATA +209 -0
  23. flixopt-3.0.0.dist-info/RECORD +26 -0
  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 -61
  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/user-guide/Mathematical Notation/Bus.md +0 -33
  37. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
  38. docs/user-guide/Mathematical Notation/Flow.md +0 -26
  39. docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
  40. docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
  41. docs/user-guide/Mathematical Notation/Storage.md +0 -44
  42. docs/user-guide/Mathematical Notation/index.md +0 -22
  43. docs/user-guide/Mathematical Notation/others.md +0 -3
  44. docs/user-guide/index.md +0 -124
  45. flixopt/config.yaml +0 -10
  46. flixopt-2.2.0rc2.dist-info/METADATA +0 -167
  47. flixopt-2.2.0rc2.dist-info/RECORD +0 -54
  48. flixopt-2.2.0rc2.dist-info/top_level.txt +0 -5
  49. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  50. pics/architecture_flixOpt.png +0 -0
  51. pics/flixOpt_plotting.jpg +0 -0
  52. pics/flixopt-icon.svg +0 -1
  53. pics/pics.pptx +0 -0
  54. scripts/extract_release_notes.py +0 -45
  55. scripts/gen_ref_pages.py +0 -54
  56. tests/ressources/Zeitreihen2020.csv +0 -35137
  57. {flixopt-2.2.0rc2.dist-info → flixopt-3.0.0.dist-info}/WHEEL +0 -0
  58. {flixopt-2.2.0rc2.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))
@@ -209,12 +327,12 @@ class ColorProcessor:
209
327
 
210
328
  def with_plotly(
211
329
  data: pd.DataFrame,
212
- mode: Literal['bar', 'line', 'area'] = 'area',
330
+ style: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar',
213
331
  colors: ColorType = 'viridis',
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.
@@ -222,7 +340,7 @@ def with_plotly(
222
340
  Args:
223
341
  data: A DataFrame containing the data to plot, where the index represents time (e.g., hours),
224
342
  and each column represents a separate data series.
225
- mode: The plotting mode. Use 'bar' for stacked bar charts, 'line' for stepped lines,
343
+ style: The plotting style. Use 'stacked_bar' for stacked bar charts, 'line' for stepped lines,
226
344
  or 'area' for stacked area charts.
227
345
  colors: Color specification, can be:
228
346
  - A string with a colorscale name (e.g., 'viridis', 'plasma')
@@ -230,12 +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
- assert mode in ['bar', 'line', 'area'], f"'mode' must be one of {['bar', 'line', 'area']}"
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}")
239
359
  if data.empty:
240
360
  return go.Figure()
241
361
 
@@ -243,23 +363,34 @@ def with_plotly(
243
363
 
244
364
  fig = fig if fig is not None else go.Figure()
245
365
 
246
- if mode == 'bar':
366
+ if style == 'stacked_bar':
247
367
  for i, column in enumerate(data.columns):
248
368
  fig.add_trace(
249
369
  go.Bar(
250
370
  x=data.index,
251
371
  y=data[column],
252
372
  name=column,
253
- marker=dict(color=processed_colors[i]),
373
+ marker=dict(
374
+ color=processed_colors[i], line=dict(width=0, color='rgba(0,0,0,0)')
375
+ ), # Transparent line with 0 width
254
376
  )
255
377
  )
256
378
 
257
379
  fig.update_layout(
258
- barmode='relative' if mode == 'bar' else None,
380
+ barmode='relative',
259
381
  bargap=0, # No space between bars
260
- bargroupgap=0, # No space between groups of bars
382
+ bargroupgap=0, # No space between grouped bars
261
383
  )
262
- elif mode == 'line':
384
+ if style == 'grouped_bar':
385
+ for i, column in enumerate(data.columns):
386
+ fig.add_trace(go.Bar(x=data.index, y=data[column], name=column, marker=dict(color=processed_colors[i])))
387
+
388
+ fig.update_layout(
389
+ barmode='group',
390
+ bargap=0.2, # No space between bars
391
+ bargroupgap=0, # space between grouped bars
392
+ )
393
+ elif style == 'line':
263
394
  for i, column in enumerate(data.columns):
264
395
  fig.add_trace(
265
396
  go.Scatter(
@@ -270,7 +401,7 @@ def with_plotly(
270
401
  line=dict(shape='hv', color=processed_colors[i]),
271
402
  )
272
403
  )
273
- elif mode == 'area':
404
+ elif style == 'area':
274
405
  data = data.copy()
275
406
  data[(data > -1e-5) & (data < 1e-5)] = 0 # Preventing issues with plotting
276
407
  # Split columns into positive, negative, and mixed categories
@@ -280,7 +411,7 @@ def with_plotly(
280
411
  mixed_columns = list(set(data.columns) - set(positive_columns + negative_columns))
281
412
 
282
413
  if mixed_columns:
283
- logger.warning(
414
+ logger.error(
284
415
  f'Data for plotting stacked lines contains columns with both positive and negative values:'
285
416
  f' {mixed_columns}. These can not be stacked, and are printed as simple lines'
286
417
  )
@@ -330,14 +461,6 @@ def with_plotly(
330
461
  plot_bgcolor='rgba(0,0,0,0)', # Transparent background
331
462
  paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background
332
463
  font=dict(size=14), # Increase font size for better readability
333
- legend=dict(
334
- orientation='h', # Horizontal legend
335
- yanchor='bottom',
336
- y=-0.3, # Adjusts how far below the plot it appears
337
- xanchor='center',
338
- x=0.5,
339
- title_text=None, # Removes legend title for a cleaner look
340
- ),
341
464
  )
342
465
 
343
466
  return fig
@@ -345,22 +468,22 @@ def with_plotly(
345
468
 
346
469
  def with_matplotlib(
347
470
  data: pd.DataFrame,
348
- mode: Literal['bar', 'line'] = 'bar',
471
+ style: Literal['stacked_bar', 'line'] = 'stacked_bar',
349
472
  colors: ColorType = 'viridis',
350
473
  title: str = '',
351
474
  ylabel: str = '',
352
475
  xlabel: str = 'Time in h',
353
- figsize: Tuple[int, int] = (12, 6),
354
- fig: Optional[plt.Figure] = None,
355
- ax: Optional[plt.Axes] = None,
356
- ) -> 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]:
357
480
  """
358
481
  Plot a DataFrame with Matplotlib using stacked bars or stepped lines.
359
482
 
360
483
  Args:
361
484
  data: A DataFrame containing the data to plot. The index should represent time (e.g., hours),
362
485
  and each column represents a separate data series.
363
- mode: Plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines.
486
+ style: Plotting style. Use 'stacked_bar' for stacked bar charts or 'line' for stepped lines.
364
487
  colors: Color specification, can be:
365
488
  - A string with a colormap name (e.g., 'viridis', 'plasma')
366
489
  - A list of color strings (e.g., ['#ff0000', '#00ff00'])
@@ -376,19 +499,19 @@ def with_matplotlib(
376
499
  A tuple containing the Matplotlib figure and axes objects used for the plot.
377
500
 
378
501
  Notes:
379
- - If `mode` is 'bar', bars are stacked for both positive and negative values.
502
+ - If `style` is 'stacked_bar', bars are stacked for both positive and negative values.
380
503
  Negative values are stacked separately without extra labels in the legend.
381
- - If `mode` is 'line', stepped lines are drawn for each data series.
382
- - The legend is placed below the plot to accommodate multiple data series.
504
+ - If `style` is 'line', stepped lines are drawn for each data series.
383
505
  """
384
- assert mode in ['bar', 'line'], f"'mode' must be one of {['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}")
385
508
 
386
509
  if fig is None or ax is None:
387
510
  fig, ax = plt.subplots(figsize=figsize)
388
511
 
389
512
  processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, list(data.columns))
390
513
 
391
- if mode == 'bar':
514
+ if style == 'stacked_bar':
392
515
  cumulative_positive = np.zeros(len(data))
393
516
  cumulative_negative = np.zeros(len(data))
394
517
  width = data.index.to_series().diff().dropna().min() # Minimum time difference
@@ -419,7 +542,7 @@ def with_matplotlib(
419
542
  )
420
543
  cumulative_negative += negative_values.values
421
544
 
422
- elif mode == 'line':
545
+ elif style == 'line':
423
546
  for i, column in enumerate(data.columns):
424
547
  ax.step(data.index, data[column], where='post', color=processed_colors[i], label=column)
425
548
 
@@ -445,8 +568,8 @@ def heat_map_matplotlib(
445
568
  title: str = '',
446
569
  xlabel: str = 'Period',
447
570
  ylabel: str = 'Step',
448
- figsize: Tuple[float, float] = (12, 6),
449
- ) -> Tuple[plt.Figure, plt.Axes]:
571
+ figsize: tuple[float, float] = (12, 6),
572
+ ) -> tuple[plt.Figure, plt.Axes]:
450
573
  """
451
574
  Plots a DataFrame as a heatmap using Matplotlib. The columns of the DataFrame will be displayed on the x-axis,
452
575
  the index will be displayed on the y-axis, and the values will represent the 'heat' intensity in the plot.
@@ -455,6 +578,9 @@ def heat_map_matplotlib(
455
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.
456
579
  The values in the DataFrame will be represented as colors in the heatmap.
457
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.
458
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.
459
585
 
460
586
  Returns:
@@ -473,7 +599,7 @@ def heat_map_matplotlib(
473
599
 
474
600
  # Create the heatmap plot
475
601
  fig, ax = plt.subplots(figsize=figsize)
476
- ax.pcolormesh(data.values, cmap=color_map)
602
+ ax.pcolormesh(data.values, cmap=color_map, shading='auto')
477
603
  ax.invert_yaxis() # Flip the y-axis to start at the top
478
604
 
479
605
  # Adjust ticks and labels for x and y axes
@@ -493,7 +619,7 @@ def heat_map_matplotlib(
493
619
 
494
620
  # Add the colorbar
495
621
  sm1 = plt.cm.ScalarMappable(cmap=color_map, norm=plt.Normalize(vmin=color_bar_min, vmax=color_bar_max))
496
- sm1._A = []
622
+ sm1.set_array([])
497
623
  fig.colorbar(sm1, ax=ax, pad=0.12, aspect=15, fraction=0.2, orientation='horizontal')
498
624
 
499
625
  fig.tight_layout()
@@ -517,11 +643,11 @@ def heat_map_plotly(
517
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.
518
644
  The values in the DataFrame will be represented as colors in the heatmap.
519
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'.
520
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).
521
650
  Default is True. If False, the axes are treated as continuous, which may be useful for time series or numeric data.
522
- show: Wether to show the figure after creation. (This includes saving the figure)
523
- save: Wether to save the figure after creation (without showing)
524
- path: Path to save the figure.
525
651
 
526
652
  Returns:
527
653
  A Plotly figure object containing the heatmap. This can be further customized and saved
@@ -612,12 +738,12 @@ def heat_map_data_from_df(
612
738
  df: pd.DataFrame,
613
739
  periods: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'],
614
740
  steps_per_period: Literal['W', 'D', 'h', '15min', 'min'],
615
- fill: Optional[Literal['ffill', 'bfill']] = None,
741
+ fill: Literal['ffill', 'bfill'] | None = None,
616
742
  ) -> pd.DataFrame:
617
743
  """
618
744
  Reshapes a DataFrame with a DateTime index into a 2D array for heatmap plotting,
619
745
  based on a specified sample rate.
620
- 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.
621
747
 
622
748
  Args:
623
749
  df: A DataFrame with a DateTime index containing the data to reshape.
@@ -632,7 +758,7 @@ def heat_map_data_from_df(
632
758
  and columns representing each period.
633
759
  """
634
760
  assert pd.api.types.is_datetime64_any_dtype(df.index), (
635
- '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'
636
762
  )
637
763
 
638
764
  # Define formats for different combinations of `periods` and `steps_per_period`
@@ -645,23 +771,26 @@ def heat_map_data_from_df(
645
771
  ('W', 'D'): ('%Y-w%W', '%w_%A'), # week and day of week (with prefix for proper sorting)
646
772
  ('W', 'h'): ('%Y-w%W', '%w_%A %H:00'),
647
773
  ('D', 'h'): ('%Y-%m-%d', '%H:00'), # Day and hour
648
- ('D', '15min'): ('%Y-%m-%d', '%H:%MM'), # Day and hour
774
+ ('D', '15min'): ('%Y-%m-%d', '%H:%M'), # Day and minute
649
775
  ('h', '15min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour
650
776
  ('h', 'min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour
651
777
  }
652
778
 
653
- 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
654
783
  time_intervals = {'min': 1, '15min': 15, 'h': 60, 'D': 24 * 60, 'W': 7 * 24 * 60}
655
784
  if time_intervals[steps_per_period] > minimum_time_diff_in_min:
656
- time_intervals[steps_per_period]
657
- logger.warning(
785
+ logger.error(
658
786
  f'To compute the heatmap, the data was aggregated from {minimum_time_diff_in_min:.2f} min to '
659
787
  f'{time_intervals[steps_per_period]:.2f} min. Mean values are displayed.'
660
788
  )
661
789
 
662
790
  # Select the format based on the `periods` and `steps_per_period` combination
663
791
  format_pair = (periods, steps_per_period)
664
- 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())}')
665
794
  period_format, step_format = formats[format_pair]
666
795
 
667
796
  df = df.sort_index() # Ensure DataFrame is sorted by time index
@@ -675,7 +804,7 @@ def heat_map_data_from_df(
675
804
 
676
805
  resampled_data['period'] = resampled_data.index.strftime(period_format)
677
806
  resampled_data['step'] = resampled_data.index.strftime(step_format)
678
- 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
679
808
  resampled_data['step'] = resampled_data['step'].apply(
680
809
  lambda x: x.replace('0_Sunday', '7_Sunday') if '0_Sunday' in x else x
681
810
  )
@@ -689,19 +818,19 @@ def heat_map_data_from_df(
689
818
  def plot_network(
690
819
  node_infos: dict,
691
820
  edge_infos: dict,
692
- path: Optional[Union[str, pathlib.Path]] = None,
693
- controls: Union[
694
- bool,
695
- 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']
696
825
  ] = True,
697
826
  show: bool = False,
698
- ) -> Optional['pyvis.network.Network']:
827
+ ) -> pyvis.network.Network | None:
699
828
  """
700
829
  Visualizes the network structure of a FlowSystem using PyVis, using info-dictionaries.
701
830
 
702
831
  Args:
703
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').
704
- 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'].
705
834
  Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'.
706
835
  You can play with these and generate a Dictionary from it that can be applied to the network returned by this function.
707
836
  network.set_options()
@@ -763,11 +892,9 @@ def plot_network(
763
892
 
764
893
  worked = webbrowser.open(f'file://{path.resolve()}', 2)
765
894
  if not worked:
766
- logger.warning(
767
- f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}'
768
- )
895
+ logger.error(f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}')
769
896
  except Exception as e:
770
- logger.warning(
897
+ logger.error(
771
898
  f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}: {e}'
772
899
  )
773
900
 
@@ -778,7 +905,7 @@ def pie_with_plotly(
778
905
  title: str = '',
779
906
  legend_title: str = '',
780
907
  hole: float = 0.0,
781
- fig: Optional[go.Figure] = None,
908
+ fig: go.Figure | None = None,
782
909
  ) -> go.Figure:
783
910
  """
784
911
  Create a pie chart with Plotly to visualize the proportion of values in a DataFrame.
@@ -806,7 +933,7 @@ def pie_with_plotly(
806
933
 
807
934
  """
808
935
  if data.empty:
809
- logger.warning('Empty DataFrame provided for pie chart. Returning empty figure.')
936
+ logger.error('Empty DataFrame provided for pie chart. Returning empty figure.')
810
937
  return go.Figure()
811
938
 
812
939
  # Create a copy to avoid modifying the original DataFrame
@@ -814,7 +941,7 @@ def pie_with_plotly(
814
941
 
815
942
  # Check if any negative values and warn
816
943
  if (data_copy < 0).any().any():
817
- 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.')
818
945
  data_copy = data_copy.abs()
819
946
 
820
947
  # If data has multiple rows, sum them to get total for each column
@@ -828,7 +955,7 @@ def pie_with_plotly(
828
955
  values = data_sum.values.tolist()
829
956
 
830
957
  # Apply color mapping using the unified color processor
831
- processed_colors = ColorProcessor(engine='plotly').process_colors(colors, list(data.columns))
958
+ processed_colors = ColorProcessor(engine='plotly').process_colors(colors, labels)
832
959
 
833
960
  # Create figure if not provided
834
961
  fig = fig if fig is not None else go.Figure()
@@ -864,10 +991,10 @@ def pie_with_matplotlib(
864
991
  title: str = '',
865
992
  legend_title: str = 'Categories',
866
993
  hole: float = 0.0,
867
- figsize: Tuple[int, int] = (10, 8),
868
- fig: Optional[plt.Figure] = None,
869
- ax: Optional[plt.Axes] = None,
870
- ) -> 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]:
871
998
  """
872
999
  Create a pie chart with Matplotlib to visualize the proportion of values in a DataFrame.
873
1000
 
@@ -896,7 +1023,7 @@ def pie_with_matplotlib(
896
1023
 
897
1024
  """
898
1025
  if data.empty:
899
- logger.warning('Empty DataFrame provided for pie chart. Returning empty figure.')
1026
+ logger.error('Empty DataFrame provided for pie chart. Returning empty figure.')
900
1027
  if fig is None or ax is None:
901
1028
  fig, ax = plt.subplots(figsize=figsize)
902
1029
  return fig, ax
@@ -906,7 +1033,7 @@ def pie_with_matplotlib(
906
1033
 
907
1034
  # Check if any negative values and warn
908
1035
  if (data_copy < 0).any().any():
909
- 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.')
910
1037
  data_copy = data_copy.abs()
911
1038
 
912
1039
  # If data has multiple rows, sum them to get total for each column
@@ -976,7 +1103,7 @@ def dual_pie_with_plotly(
976
1103
  data_right: pd.Series,
977
1104
  colors: ColorType = 'viridis',
978
1105
  title: str = '',
979
- subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'),
1106
+ subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'),
980
1107
  legend_title: str = '',
981
1108
  hole: float = 0.2,
982
1109
  lower_percentage_group: float = 5.0,
@@ -997,8 +1124,8 @@ def dual_pie_with_plotly(
997
1124
  title: The main title of the plot.
998
1125
  subtitles: Tuple containing the subtitles for (left, right) charts.
999
1126
  legend_title: The title for the legend.
1000
- hole: Size of the hole in the center for creating donut charts (0.0 to 100).
1001
- 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".
1002
1129
  hover_template: Template for hover text. Use %{label}, %{value}, %{percent}.
1003
1130
  text_info: What to show on pie segments: 'label', 'percent', 'value', 'label+percent',
1004
1131
  'label+value', 'percent+value', 'label+percent+value', or 'none'.
@@ -1011,7 +1138,7 @@ def dual_pie_with_plotly(
1011
1138
 
1012
1139
  # Check for empty data
1013
1140
  if data_left.empty and data_right.empty:
1014
- logger.warning('Both datasets are empty. Returning empty figure.')
1141
+ logger.error('Both datasets are empty. Returning empty figure.')
1015
1142
  return go.Figure()
1016
1143
 
1017
1144
  # Create a subplot figure
@@ -1034,7 +1161,7 @@ def dual_pie_with_plotly(
1034
1161
  """
1035
1162
  # Handle negative values
1036
1163
  if (series < 0).any():
1037
- 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.')
1038
1165
  series = series.abs()
1039
1166
 
1040
1167
  # Remove zeros
@@ -1090,7 +1217,7 @@ def dual_pie_with_plotly(
1090
1217
  labels=labels,
1091
1218
  values=values,
1092
1219
  name=side,
1093
- marker_colors=trace_colors,
1220
+ marker=dict(colors=trace_colors),
1094
1221
  hole=hole,
1095
1222
  textinfo=text_info,
1096
1223
  textposition=text_position,
@@ -1119,7 +1246,6 @@ def dual_pie_with_plotly(
1119
1246
  paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background
1120
1247
  font=dict(size=14),
1121
1248
  margin=dict(t=80, b=50, l=30, r=30),
1122
- legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5, font=dict(size=12)),
1123
1249
  )
1124
1250
 
1125
1251
  return fig
@@ -1130,14 +1256,14 @@ def dual_pie_with_matplotlib(
1130
1256
  data_right: pd.Series,
1131
1257
  colors: ColorType = 'viridis',
1132
1258
  title: str = '',
1133
- subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'),
1259
+ subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'),
1134
1260
  legend_title: str = '',
1135
1261
  hole: float = 0.2,
1136
1262
  lower_percentage_group: float = 5.0,
1137
- figsize: Tuple[int, int] = (14, 7),
1138
- fig: Optional[plt.Figure] = None,
1139
- axes: Optional[List[plt.Axes]] = None,
1140
- ) -> 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]]:
1141
1267
  """
1142
1268
  Create two pie charts side by side with Matplotlib, with consistent coloring across both charts.
1143
1269
  Leverages the existing pie_with_matplotlib function.
@@ -1163,7 +1289,7 @@ def dual_pie_with_matplotlib(
1163
1289
  """
1164
1290
  # Check for empty data
1165
1291
  if data_left.empty and data_right.empty:
1166
- logger.warning('Both datasets are empty. Returning empty figure.')
1292
+ logger.error('Both datasets are empty. Returning empty figure.')
1167
1293
  if fig is None:
1168
1294
  fig, axes = plt.subplots(1, 2, figsize=figsize)
1169
1295
  return fig, axes
@@ -1181,7 +1307,7 @@ def dual_pie_with_matplotlib(
1181
1307
  """
1182
1308
  # Handle negative values
1183
1309
  if (series < 0).any():
1184
- 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.')
1185
1311
  series = series.abs()
1186
1312
 
1187
1313
  # Remove zeros
@@ -1288,13 +1414,13 @@ def dual_pie_with_matplotlib(
1288
1414
 
1289
1415
 
1290
1416
  def export_figure(
1291
- figure_like: Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]],
1417
+ figure_like: go.Figure | tuple[plt.Figure, plt.Axes],
1292
1418
  default_path: pathlib.Path,
1293
- default_filetype: Optional[str] = None,
1294
- user_path: Optional[pathlib.Path] = None,
1419
+ default_filetype: str | None = None,
1420
+ user_path: pathlib.Path | None = None,
1295
1421
  show: bool = True,
1296
1422
  save: bool = False,
1297
- ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]:
1423
+ ) -> go.Figure | tuple[plt.Figure, plt.Axes]:
1298
1424
  """
1299
1425
  Export a figure to a file and or show it.
1300
1426
 
@@ -1319,22 +1445,52 @@ def export_figure(
1319
1445
 
1320
1446
  if isinstance(figure_like, plotly.graph_objs.Figure):
1321
1447
  fig = figure_like
1322
- if not filename.suffix == '.html':
1323
- logger.debug(f'To save a plotly figure, the filename should end with ".html". Got {filename}')
1324
- if show and not save:
1325
- fig.show()
1326
- elif save and show:
1327
- plotly.offline.plot(fig, filename=str(filename))
1328
- elif save and not show:
1329
- 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
+
1330
1477
  return figure_like
1331
1478
 
1332
1479
  elif isinstance(figure_like, tuple):
1333
1480
  fig, ax = figure_like
1334
1481
  if show:
1335
- 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
+
1336
1490
  if save:
1337
1491
  fig.savefig(str(filename), dpi=300)
1492
+ plt.close(fig) # Close figure to free memory
1493
+
1338
1494
  return fig, ax
1339
1495
 
1340
1496
  raise TypeError(f'Figure type not supported: {type(figure_like)}')