flixopt 1.0.12__py3-none-any.whl → 2.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 (72) hide show
  1. docs/examples/00-Minimal Example.md +5 -0
  2. docs/examples/01-Basic Example.md +5 -0
  3. docs/examples/02-Complex Example.md +10 -0
  4. docs/examples/03-Calculation Modes.md +5 -0
  5. docs/examples/index.md +5 -0
  6. docs/faq/contribute.md +49 -0
  7. docs/faq/index.md +3 -0
  8. docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
  9. docs/images/architecture_flixOpt.png +0 -0
  10. docs/images/flixopt-icon.svg +1 -0
  11. docs/javascripts/mathjax.js +18 -0
  12. docs/release-notes/_template.txt +32 -0
  13. docs/release-notes/index.md +7 -0
  14. docs/release-notes/v2.0.0.md +93 -0
  15. docs/user-guide/Mathematical Notation/Bus.md +33 -0
  16. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +132 -0
  17. docs/user-guide/Mathematical Notation/Flow.md +26 -0
  18. docs/user-guide/Mathematical Notation/LinearConverter.md +21 -0
  19. docs/user-guide/Mathematical Notation/Piecewise.md +49 -0
  20. docs/user-guide/Mathematical Notation/Storage.md +44 -0
  21. docs/user-guide/Mathematical Notation/index.md +22 -0
  22. docs/user-guide/Mathematical Notation/others.md +3 -0
  23. docs/user-guide/index.md +124 -0
  24. {flixOpt → flixopt}/__init__.py +5 -2
  25. {flixOpt → flixopt}/aggregation.py +113 -140
  26. flixopt/calculation.py +455 -0
  27. {flixOpt → flixopt}/commons.py +7 -4
  28. flixopt/components.py +630 -0
  29. {flixOpt → flixopt}/config.py +9 -8
  30. {flixOpt → flixopt}/config.yaml +3 -3
  31. flixopt/core.py +914 -0
  32. flixopt/effects.py +386 -0
  33. flixopt/elements.py +529 -0
  34. flixopt/features.py +1042 -0
  35. flixopt/flow_system.py +409 -0
  36. flixopt/interface.py +265 -0
  37. flixopt/io.py +308 -0
  38. flixopt/linear_converters.py +331 -0
  39. flixopt/plotting.py +1337 -0
  40. flixopt/results.py +898 -0
  41. flixopt/solvers.py +77 -0
  42. flixopt/structure.py +630 -0
  43. flixopt/utils.py +62 -0
  44. flixopt-2.0.0.dist-info/METADATA +145 -0
  45. flixopt-2.0.0.dist-info/RECORD +56 -0
  46. {flixopt-1.0.12.dist-info → flixopt-2.0.0.dist-info}/WHEEL +1 -1
  47. flixopt-2.0.0.dist-info/top_level.txt +6 -0
  48. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  49. pics/architecture_flixOpt.png +0 -0
  50. pics/flixopt-icon.svg +1 -0
  51. pics/pics.pptx +0 -0
  52. scripts/gen_ref_pages.py +54 -0
  53. site/release-notes/_template.txt +32 -0
  54. flixOpt/calculation.py +0 -629
  55. flixOpt/components.py +0 -614
  56. flixOpt/core.py +0 -182
  57. flixOpt/effects.py +0 -410
  58. flixOpt/elements.py +0 -489
  59. flixOpt/features.py +0 -942
  60. flixOpt/flow_system.py +0 -351
  61. flixOpt/interface.py +0 -203
  62. flixOpt/linear_converters.py +0 -325
  63. flixOpt/math_modeling.py +0 -1145
  64. flixOpt/plotting.py +0 -712
  65. flixOpt/results.py +0 -563
  66. flixOpt/solvers.py +0 -21
  67. flixOpt/structure.py +0 -733
  68. flixOpt/utils.py +0 -134
  69. flixopt-1.0.12.dist-info/METADATA +0 -174
  70. flixopt-1.0.12.dist-info/RECORD +0 -29
  71. flixopt-1.0.12.dist-info/top_level.txt +0 -3
  72. {flixopt-1.0.12.dist-info → flixopt-2.0.0.dist-info/licenses}/LICENSE +0 -0
flixopt/plotting.py ADDED
@@ -0,0 +1,1337 @@
1
+ """
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
+ """
6
+
7
+ import itertools
8
+ import logging
9
+ import pathlib
10
+ from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
11
+
12
+ import matplotlib.colors as mcolors
13
+ import matplotlib.pyplot as plt
14
+ import numpy as np
15
+ import pandas as pd
16
+ import plotly.express as px
17
+ import plotly.graph_objects as go
18
+ import plotly.offline
19
+ from plotly.exceptions import PlotlyError
20
+
21
+ if TYPE_CHECKING:
22
+ import pyvis
23
+
24
+ logger = logging.getLogger('flixopt')
25
+
26
+ # Define the colors for the 'portland' colormap in matplotlib
27
+ _portland_colors = [
28
+ [12 / 255, 51 / 255, 131 / 255], # Dark blue
29
+ [10 / 255, 136 / 255, 186 / 255], # Light blue
30
+ [242 / 255, 211 / 255, 56 / 255], # Yellow
31
+ [242 / 255, 143 / 255, 56 / 255], # Orange
32
+ [217 / 255, 30 / 255, 30 / 255], # Red
33
+ ]
34
+
35
+ plt.colormaps.register(mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors))
36
+
37
+
38
+ ColorType = Union[str, List[str], Dict[str, str]]
39
+ """Identifier for the colors to use.
40
+ Use the name of a colorscale, a list of colors or a dictionary of labels to colors.
41
+ The colors must be valid color strings (HEX or names). Depending on the Engine used, other formats are possible.
42
+ See also:
43
+ - https://htmlcolorcodes.com/color-names/
44
+ - https://matplotlib.org/stable/tutorials/colors/colormaps.html
45
+ - https://plotly.com/python/builtin-colorscales/
46
+ """
47
+
48
+ PlottingEngine = Literal['plotly', 'matplotlib']
49
+ """Identifier for the plotting engine to use."""
50
+
51
+
52
+ class ColorProcessor:
53
+ """Class to handle color processing for different visualization engines."""
54
+
55
+ def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str = 'viridis'):
56
+ """
57
+ Initialize the color processor.
58
+
59
+ Args:
60
+ engine: The plotting engine to use ('plotly' or 'matplotlib')
61
+ default_colormap: Default colormap to use if none is specified
62
+ """
63
+ if engine not in ['plotly', 'matplotlib']:
64
+ raise TypeError(f'engine must be "plotly" or "matplotlib", but is {engine}')
65
+ self.engine = engine
66
+ self.default_colormap = default_colormap
67
+
68
+ def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> List[Any]:
69
+ """
70
+ Generate colors from a named colormap.
71
+
72
+ Args:
73
+ colormap_name: Name of the colormap
74
+ num_colors: Number of colors to generate
75
+
76
+ Returns:
77
+ List of colors in the format appropriate for the engine
78
+ """
79
+ if self.engine == 'plotly':
80
+ try:
81
+ colorscale = px.colors.get_colorscale(colormap_name)
82
+ except PlotlyError as e:
83
+ logger.warning(f"Colorscale '{colormap_name}' not found in Plotly. Using {self.default_colormap}: {e}")
84
+ colorscale = px.colors.get_colorscale(self.default_colormap)
85
+
86
+ # Generate evenly spaced points
87
+ color_points = [i / (num_colors - 1) for i in range(num_colors)] if num_colors > 1 else [0]
88
+ return px.colors.sample_colorscale(colorscale, color_points)
89
+
90
+ else: # matplotlib
91
+ try:
92
+ cmap = plt.get_cmap(colormap_name, num_colors)
93
+ except ValueError as e:
94
+ logger.warning(
95
+ f"Colormap '{colormap_name}' not found in Matplotlib. Using {self.default_colormap}: {e}"
96
+ )
97
+ cmap = plt.get_cmap(self.default_colormap, num_colors)
98
+
99
+ return [cmap(i) for i in range(num_colors)]
100
+
101
+ def _handle_color_list(self, colors: List[str], num_labels: int) -> List[str]:
102
+ """
103
+ Handle a list of colors, cycling if necessary.
104
+
105
+ Args:
106
+ colors: List of color strings
107
+ num_labels: Number of labels that need colors
108
+
109
+ Returns:
110
+ List of colors matching the number of labels
111
+ """
112
+ if len(colors) == 0:
113
+ logger.warning(f'Empty color list provided. Using {self.default_colormap} instead.')
114
+ return self._generate_colors_from_colormap(self.default_colormap, num_labels)
115
+
116
+ if len(colors) < num_labels:
117
+ logger.warning(
118
+ f'Not enough colors provided ({len(colors)}) for all labels ({num_labels}). Colors will cycle.'
119
+ )
120
+ # Cycle through the colors
121
+ color_iter = itertools.cycle(colors)
122
+ return [next(color_iter) for _ in range(num_labels)]
123
+ else:
124
+ # Trim if necessary
125
+ if len(colors) > num_labels:
126
+ logger.warning(
127
+ f'More colors provided ({len(colors)}) than labels ({num_labels}). Extra colors will be ignored.'
128
+ )
129
+ return colors[:num_labels]
130
+
131
+ def _handle_color_dict(self, colors: Dict[str, str], labels: List[str]) -> List[str]:
132
+ """
133
+ Handle a dictionary mapping labels to colors.
134
+
135
+ Args:
136
+ colors: Dictionary mapping labels to colors
137
+ labels: List of labels that need colors
138
+
139
+ Returns:
140
+ List of colors in the same order as labels
141
+ """
142
+ if len(colors) == 0:
143
+ logger.warning(f'Empty color dictionary provided. Using {self.default_colormap} instead.')
144
+ return self._generate_colors_from_colormap(self.default_colormap, len(labels))
145
+
146
+ # Find missing labels
147
+ missing_labels = set(labels) - set(colors.keys())
148
+ if missing_labels:
149
+ logger.warning(
150
+ f'Some labels have no color specified: {missing_labels}. Using {self.default_colormap} for these.'
151
+ )
152
+
153
+ # Generate colors for missing labels
154
+ missing_colors = self._generate_colors_from_colormap(self.default_colormap, len(missing_labels))
155
+
156
+ # Create a copy to avoid modifying the original
157
+ colors_copy = colors.copy()
158
+ for i, label in enumerate(missing_labels):
159
+ colors_copy[label] = missing_colors[i]
160
+ else:
161
+ colors_copy = colors
162
+
163
+ # Create color list in the same order as labels
164
+ return [colors_copy[label] for label in labels]
165
+
166
+ def process_colors(
167
+ self,
168
+ colors: ColorType,
169
+ labels: List[str],
170
+ return_mapping: bool = False,
171
+ ) -> Union[List[Any], Dict[str, Any]]:
172
+ """
173
+ Process colors for the specified labels.
174
+
175
+ Args:
176
+ colors: Color specification (colormap name, list of colors, or label-to-color mapping)
177
+ labels: List of data labels that need colors assigned
178
+ return_mapping: If True, returns a dictionary mapping labels to colors;
179
+ if False, returns a list of colors in the same order as labels
180
+
181
+ Returns:
182
+ Either a list of colors or a dictionary mapping labels to colors
183
+ """
184
+ if len(labels) == 0:
185
+ logger.warning('No labels provided for color assignment.')
186
+ return {} if return_mapping else []
187
+
188
+ # Process based on type of colors input
189
+ if isinstance(colors, str):
190
+ color_list = self._generate_colors_from_colormap(colors, len(labels))
191
+ elif isinstance(colors, list):
192
+ color_list = self._handle_color_list(colors, len(labels))
193
+ elif isinstance(colors, dict):
194
+ color_list = self._handle_color_dict(colors, labels)
195
+ else:
196
+ logger.warning(
197
+ f'Unsupported color specification type: {type(colors)}. Using {self.default_colormap} instead.'
198
+ )
199
+ color_list = self._generate_colors_from_colormap(self.default_colormap, len(labels))
200
+
201
+ # Return either a list or a mapping
202
+ if return_mapping:
203
+ return {label: color_list[i] for i, label in enumerate(labels)}
204
+ else:
205
+ return color_list
206
+
207
+
208
+ def with_plotly(
209
+ data: pd.DataFrame,
210
+ mode: Literal['bar', 'line', 'area'] = 'area',
211
+ colors: ColorType = 'viridis',
212
+ title: str = '',
213
+ ylabel: str = '',
214
+ xlabel: str = 'Time in h',
215
+ fig: Optional[go.Figure] = None,
216
+ ) -> go.Figure:
217
+ """
218
+ Plot a DataFrame with Plotly, using either stacked bars or stepped lines.
219
+
220
+ Args:
221
+ data: A DataFrame containing the data to plot, where the index represents time (e.g., hours),
222
+ and each column represents a separate data series.
223
+ mode: The plotting mode. Use 'bar' for stacked bar charts, 'line' for stepped lines,
224
+ or 'area' for stacked area charts.
225
+ colors: Color specification, can be:
226
+ - A string with a colorscale name (e.g., 'viridis', 'plasma')
227
+ - A list of color strings (e.g., ['#ff0000', '#00ff00'])
228
+ - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'})
229
+ title: The title of the plot.
230
+ ylabel: The label for the y-axis.
231
+ fig: A Plotly figure object to plot on. If not provided, a new figure will be created.
232
+
233
+ Returns:
234
+ A Plotly figure object containing the generated plot.
235
+ """
236
+ assert mode in ['bar', 'line', 'area'], f"'mode' must be one of {['bar', 'line', 'area']}"
237
+ if data.empty:
238
+ return go.Figure()
239
+
240
+ processed_colors = ColorProcessor(engine='plotly').process_colors(colors, list(data.columns))
241
+
242
+ fig = fig if fig is not None else go.Figure()
243
+
244
+ if mode == 'bar':
245
+ for i, column in enumerate(data.columns):
246
+ fig.add_trace(
247
+ go.Bar(
248
+ x=data.index,
249
+ y=data[column],
250
+ name=column,
251
+ marker=dict(color=processed_colors[i]),
252
+ )
253
+ )
254
+
255
+ fig.update_layout(
256
+ barmode='relative' if mode == 'bar' else None,
257
+ bargap=0, # No space between bars
258
+ bargroupgap=0, # No space between groups of bars
259
+ )
260
+ elif mode == 'line':
261
+ for i, column in enumerate(data.columns):
262
+ fig.add_trace(
263
+ go.Scatter(
264
+ x=data.index,
265
+ y=data[column],
266
+ mode='lines',
267
+ name=column,
268
+ line=dict(shape='hv', color=processed_colors[i]),
269
+ )
270
+ )
271
+ elif mode == 'area':
272
+ data = data.copy()
273
+ data[(data > -1e-5) & (data < 1e-5)] = 0 # Preventing issues with plotting
274
+ # Split columns into positive, negative, and mixed categories
275
+ positive_columns = list(data.columns[(data >= 0).where(~np.isnan(data), True).all()])
276
+ negative_columns = list(data.columns[(data <= 0).where(~np.isnan(data), True).all()])
277
+ negative_columns = [column for column in negative_columns if column not in positive_columns]
278
+ mixed_columns = list(set(data.columns) - set(positive_columns + negative_columns))
279
+
280
+ if mixed_columns:
281
+ logger.warning(
282
+ f'Data for plotting stacked lines contains columns with both positive and negative values:'
283
+ f' {mixed_columns}. These can not be stacked, and are printed as simple lines'
284
+ )
285
+
286
+ # Get color mapping for all columns
287
+ colors_stacked = {column: processed_colors[i] for i, column in enumerate(data.columns)}
288
+
289
+ for column in positive_columns + negative_columns:
290
+ fig.add_trace(
291
+ go.Scatter(
292
+ x=data.index,
293
+ y=data[column],
294
+ mode='lines',
295
+ name=column,
296
+ line=dict(shape='hv', color=colors_stacked[column]),
297
+ fill='tonexty',
298
+ stackgroup='pos' if column in positive_columns else 'neg',
299
+ )
300
+ )
301
+
302
+ for column in mixed_columns:
303
+ fig.add_trace(
304
+ go.Scatter(
305
+ x=data.index,
306
+ y=data[column],
307
+ mode='lines',
308
+ name=column,
309
+ line=dict(shape='hv', color=colors_stacked[column], dash='dash'),
310
+ )
311
+ )
312
+
313
+ # Update layout for better aesthetics
314
+ fig.update_layout(
315
+ title=title,
316
+ yaxis=dict(
317
+ title=ylabel,
318
+ showgrid=True, # Enable grid lines on the y-axis
319
+ gridcolor='lightgrey', # Customize grid line color
320
+ gridwidth=0.5, # Customize grid line width
321
+ ),
322
+ xaxis=dict(
323
+ title=xlabel,
324
+ showgrid=True, # Enable grid lines on the x-axis
325
+ gridcolor='lightgrey', # Customize grid line color
326
+ gridwidth=0.5, # Customize grid line width
327
+ ),
328
+ plot_bgcolor='rgba(0,0,0,0)', # Transparent background
329
+ paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background
330
+ font=dict(size=14), # Increase font size for better readability
331
+ legend=dict(
332
+ orientation='h', # Horizontal legend
333
+ yanchor='bottom',
334
+ y=-0.3, # Adjusts how far below the plot it appears
335
+ xanchor='center',
336
+ x=0.5,
337
+ title_text=None, # Removes legend title for a cleaner look
338
+ ),
339
+ )
340
+
341
+ return fig
342
+
343
+
344
+ def with_matplotlib(
345
+ data: pd.DataFrame,
346
+ mode: Literal['bar', 'line'] = 'bar',
347
+ colors: ColorType = 'viridis',
348
+ title: str = '',
349
+ ylabel: str = '',
350
+ xlabel: str = 'Time in h',
351
+ figsize: Tuple[int, int] = (12, 6),
352
+ fig: Optional[plt.Figure] = None,
353
+ ax: Optional[plt.Axes] = None,
354
+ ) -> Tuple[plt.Figure, plt.Axes]:
355
+ """
356
+ Plot a DataFrame with Matplotlib using stacked bars or stepped lines.
357
+
358
+ Args:
359
+ data: A DataFrame containing the data to plot. The index should represent time (e.g., hours),
360
+ and each column represents a separate data series.
361
+ mode: Plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines.
362
+ colors: Color specification, can be:
363
+ - A string with a colormap name (e.g., 'viridis', 'plasma')
364
+ - A list of color strings (e.g., ['#ff0000', '#00ff00'])
365
+ - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'})
366
+ title: The title of the plot.
367
+ ylabel: The ylabel of the plot.
368
+ xlabel: The xlabel of the plot.
369
+ figsize: Specify the size of the figure
370
+ fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created.
371
+ ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created.
372
+
373
+ Returns:
374
+ A tuple containing the Matplotlib figure and axes objects used for the plot.
375
+
376
+ Notes:
377
+ - If `mode` is 'bar', bars are stacked for both positive and negative values.
378
+ Negative values are stacked separately without extra labels in the legend.
379
+ - If `mode` is 'line', stepped lines are drawn for each data series.
380
+ - The legend is placed below the plot to accommodate multiple data series.
381
+ """
382
+ assert mode in ['bar', 'line'], f"'mode' must be one of {['bar', 'line']} for matplotlib"
383
+
384
+ if fig is None or ax is None:
385
+ fig, ax = plt.subplots(figsize=figsize)
386
+
387
+ processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, list(data.columns))
388
+
389
+ if mode == 'bar':
390
+ cumulative_positive = np.zeros(len(data))
391
+ cumulative_negative = np.zeros(len(data))
392
+ width = data.index.to_series().diff().dropna().min() # Minimum time difference
393
+
394
+ for i, column in enumerate(data.columns):
395
+ positive_values = np.clip(data[column], 0, None) # Keep only positive values
396
+ negative_values = np.clip(data[column], None, 0) # Keep only negative values
397
+ # Plot positive bars
398
+ ax.bar(
399
+ data.index,
400
+ positive_values,
401
+ bottom=cumulative_positive,
402
+ color=processed_colors[i],
403
+ label=column,
404
+ width=width,
405
+ align='center',
406
+ )
407
+ cumulative_positive += positive_values.values
408
+ # Plot negative bars
409
+ ax.bar(
410
+ data.index,
411
+ negative_values,
412
+ bottom=cumulative_negative,
413
+ color=processed_colors[i],
414
+ label='', # No label for negative bars
415
+ width=width,
416
+ align='center',
417
+ )
418
+ cumulative_negative += negative_values.values
419
+
420
+ elif mode == 'line':
421
+ for i, column in enumerate(data.columns):
422
+ ax.step(data.index, data[column], where='post', color=processed_colors[i], label=column)
423
+
424
+ # Aesthetics
425
+ ax.set_xlabel(xlabel, ha='center')
426
+ ax.set_ylabel(ylabel, va='center')
427
+ ax.set_title(title)
428
+ ax.grid(color='lightgrey', linestyle='-', linewidth=0.5)
429
+ ax.legend(
430
+ loc='upper center', # Place legend at the bottom center
431
+ bbox_to_anchor=(0.5, -0.15), # Adjust the position to fit below plot
432
+ ncol=5,
433
+ frameon=False, # Remove box around legend
434
+ )
435
+ fig.tight_layout()
436
+
437
+ return fig, ax
438
+
439
+
440
+ def heat_map_matplotlib(
441
+ data: pd.DataFrame,
442
+ color_map: str = 'viridis',
443
+ title: str = '',
444
+ xlabel: str = 'Period',
445
+ ylabel: str = 'Step',
446
+ figsize: Tuple[float, float] = (12, 6),
447
+ ) -> Tuple[plt.Figure, plt.Axes]:
448
+ """
449
+ Plots a DataFrame as a heatmap using Matplotlib. The columns of the DataFrame will be displayed on the x-axis,
450
+ the index will be displayed on the y-axis, and the values will represent the 'heat' intensity in the plot.
451
+
452
+ Args:
453
+ 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.
454
+ The values in the DataFrame will be represented as colors in the heatmap.
455
+ color_map: The colormap to use for the heatmap. Default is 'viridis'. Matplotlib supports various colormaps like 'plasma', 'inferno', 'cividis', etc.
456
+ 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.
457
+
458
+ Returns:
459
+ A tuple containing the Matplotlib `Figure` and `Axes` objects. The `Figure` contains the overall plot, while the `Axes` is the area
460
+ where the heatmap is drawn. These can be used for further customization or saving the plot to a file.
461
+
462
+ Notes:
463
+ - The y-axis is flipped so that the first row of the DataFrame is displayed at the top of the plot.
464
+ - The color scale is normalized based on the minimum and maximum values in the DataFrame.
465
+ - The x-axis labels (periods) are placed at the top of the plot.
466
+ - The colorbar is added horizontally at the bottom of the plot, with a label.
467
+ """
468
+
469
+ # Get the min and max values for color normalization
470
+ color_bar_min, color_bar_max = data.min().min(), data.max().max()
471
+
472
+ # Create the heatmap plot
473
+ fig, ax = plt.subplots(figsize=figsize)
474
+ ax.pcolormesh(data.values, cmap=color_map)
475
+ ax.invert_yaxis() # Flip the y-axis to start at the top
476
+
477
+ # Adjust ticks and labels for x and y axes
478
+ ax.set_xticks(np.arange(len(data.columns)) + 0.5)
479
+ ax.set_xticklabels(data.columns, ha='center')
480
+ ax.set_yticks(np.arange(len(data.index)) + 0.5)
481
+ ax.set_yticklabels(data.index, va='center')
482
+
483
+ # Add labels to the axes
484
+ ax.set_xlabel(xlabel, ha='center')
485
+ ax.set_ylabel(ylabel, va='center')
486
+ ax.set_title(title)
487
+
488
+ # Position x-axis labels at the top
489
+ ax.xaxis.set_label_position('top')
490
+ ax.xaxis.set_ticks_position('top')
491
+
492
+ # Add the colorbar
493
+ sm1 = plt.cm.ScalarMappable(cmap=color_map, norm=plt.Normalize(vmin=color_bar_min, vmax=color_bar_max))
494
+ sm1._A = []
495
+ fig.colorbar(sm1, ax=ax, pad=0.12, aspect=15, fraction=0.2, orientation='horizontal')
496
+
497
+ fig.tight_layout()
498
+
499
+ return fig, ax
500
+
501
+
502
+ def heat_map_plotly(
503
+ data: pd.DataFrame,
504
+ color_map: str = 'viridis',
505
+ title: str = '',
506
+ xlabel: str = 'Period',
507
+ ylabel: str = 'Step',
508
+ categorical_labels: bool = True,
509
+ ) -> go.Figure:
510
+ """
511
+ Plots a DataFrame as a heatmap using Plotly. The columns of the DataFrame will be mapped to the x-axis,
512
+ and the index will be displayed on the y-axis. The values in the DataFrame will represent the 'heat' in the plot.
513
+
514
+ Args:
515
+ 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.
516
+ The values in the DataFrame will be represented as colors in the heatmap.
517
+ color_map: The color scale to use for the heatmap. Default is 'viridis'. Plotly supports various color scales like 'Cividis', 'Inferno', etc.
518
+ 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).
519
+ Default is True. If False, the axes are treated as continuous, which may be useful for time series or numeric data.
520
+ show: Wether to show the figure after creation. (This includes saving the figure)
521
+ save: Wether to save the figure after creation (without showing)
522
+ path: Path to save the figure.
523
+
524
+ Returns:
525
+ A Plotly figure object containing the heatmap. This can be further customized and saved
526
+ or displayed using `fig.show()`.
527
+
528
+ Notes:
529
+ The color bar is automatically scaled to the minimum and maximum values in the data.
530
+ The y-axis is reversed to display the first row at the top.
531
+ """
532
+
533
+ color_bar_min, color_bar_max = data.min().min(), data.max().max() # Min and max values for color scaling
534
+ # Define the figure
535
+ fig = go.Figure(
536
+ data=go.Heatmap(
537
+ z=data.values,
538
+ x=data.columns,
539
+ y=data.index,
540
+ colorscale=color_map,
541
+ zmin=color_bar_min,
542
+ zmax=color_bar_max,
543
+ colorbar=dict(
544
+ title=dict(text='Color Bar Label', side='right'),
545
+ orientation='h',
546
+ xref='container',
547
+ yref='container',
548
+ len=0.8, # Color bar length relative to plot
549
+ x=0.5,
550
+ y=0.1,
551
+ ),
552
+ )
553
+ )
554
+
555
+ # Set axis labels and style
556
+ fig.update_layout(
557
+ title=title,
558
+ xaxis=dict(title=xlabel, side='top', type='category' if categorical_labels else None),
559
+ yaxis=dict(title=ylabel, autorange='reversed', type='category' if categorical_labels else None),
560
+ )
561
+
562
+ return fig
563
+
564
+
565
+ def reshape_to_2d(data_1d: np.ndarray, nr_of_steps_per_column: int) -> np.ndarray:
566
+ """
567
+ Reshapes a 1D numpy array into a 2D array suitable for plotting as a colormap.
568
+
569
+ The reshaped array will have the number of rows corresponding to the steps per column
570
+ (e.g., 24 hours per day) and columns representing time periods (e.g., days or months).
571
+
572
+ Args:
573
+ data_1d: A 1D numpy array with the data to reshape.
574
+ nr_of_steps_per_column: The number of steps (rows) per column in the resulting 2D array. For example,
575
+ this could be 24 (for hours) or 31 (for days in a month).
576
+
577
+ Returns:
578
+ The reshaped 2D array. Each internal array corresponds to one column, with the specified number of steps.
579
+ Each column might represents a time period (e.g., day, month, etc.).
580
+ """
581
+
582
+ # Step 1: Ensure the input is a 1D array.
583
+ if data_1d.ndim != 1:
584
+ raise ValueError('Input must be a 1D array')
585
+
586
+ # Step 2: Convert data to float type to allow NaN padding
587
+ if data_1d.dtype != np.float64:
588
+ data_1d = data_1d.astype(np.float64)
589
+
590
+ # Step 3: Calculate the number of columns required
591
+ total_steps = len(data_1d)
592
+ cols = len(data_1d) // nr_of_steps_per_column # Base number of columns
593
+
594
+ # If there's a remainder, add an extra column to hold the remaining values
595
+ if total_steps % nr_of_steps_per_column != 0:
596
+ cols += 1
597
+
598
+ # Step 4: Pad the 1D data to match the required number of rows and columns
599
+ padded_data = np.pad(
600
+ data_1d, (0, cols * nr_of_steps_per_column - total_steps), mode='constant', constant_values=np.nan
601
+ )
602
+
603
+ # Step 5: Reshape the padded data into a 2D array
604
+ data_2d = padded_data.reshape(cols, nr_of_steps_per_column)
605
+
606
+ return data_2d.T
607
+
608
+
609
+ def heat_map_data_from_df(
610
+ df: pd.DataFrame,
611
+ periods: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'],
612
+ steps_per_period: Literal['W', 'D', 'h', '15min', 'min'],
613
+ fill: Optional[Literal['ffill', 'bfill']] = None,
614
+ ) -> pd.DataFrame:
615
+ """
616
+ Reshapes a DataFrame with a DateTime index into a 2D array for heatmap plotting,
617
+ based on a specified sample rate.
618
+ If a non-valid combination of periods and steps per period is used, falls back to numerical indices
619
+
620
+ Args:
621
+ df: A DataFrame with a DateTime index containing the data to reshape.
622
+ periods: The time interval of each period (columns of the heatmap),
623
+ such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc.
624
+ steps_per_period: The time interval within each period (rows in the heatmap),
625
+ such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc.
626
+ fill: Method to fill missing values: 'ffill' for forward fill or 'bfill' for backward fill.
627
+
628
+ Returns:
629
+ A DataFrame suitable for heatmap plotting, with rows representing steps within each period
630
+ and columns representing each period.
631
+ """
632
+ assert pd.api.types.is_datetime64_any_dtype(df.index), (
633
+ 'The index of the Dataframe must be datetime to transfrom it properly for a heatmap plot'
634
+ )
635
+
636
+ # Define formats for different combinations of `periods` and `steps_per_period`
637
+ formats = {
638
+ ('YS', 'W'): ('%Y', '%W'),
639
+ ('YS', 'D'): ('%Y', '%j'), # day of year
640
+ ('YS', 'h'): ('%Y', '%j %H:00'),
641
+ ('MS', 'D'): ('%Y-%m', '%d'), # day of month
642
+ ('MS', 'h'): ('%Y-%m', '%d %H:00'),
643
+ ('W', 'D'): ('%Y-w%W', '%w_%A'), # week and day of week (with prefix for proper sorting)
644
+ ('W', 'h'): ('%Y-w%W', '%w_%A %H:00'),
645
+ ('D', 'h'): ('%Y-%m-%d', '%H:00'), # Day and hour
646
+ ('D', '15min'): ('%Y-%m-%d', '%H:%MM'), # Day and hour
647
+ ('h', '15min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour
648
+ ('h', 'min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour
649
+ }
650
+
651
+ minimum_time_diff_in_min = df.index.to_series().diff().min().total_seconds() / 60 # Smallest time_diff in minutes
652
+ time_intervals = {'min': 1, '15min': 15, 'h': 60, 'D': 24 * 60, 'W': 7 * 24 * 60}
653
+ if time_intervals[steps_per_period] > minimum_time_diff_in_min:
654
+ time_intervals[steps_per_period]
655
+ logger.warning(
656
+ f'To compute the heatmap, the data was aggregated from {minimum_time_diff_in_min:.2f} min to '
657
+ f'{time_intervals[steps_per_period]:.2f} min. Mean values are displayed.'
658
+ )
659
+
660
+ # Select the format based on the `periods` and `steps_per_period` combination
661
+ format_pair = (periods, steps_per_period)
662
+ assert format_pair in formats, f'{format_pair} is not a valid format. Choose from {list(formats.keys())}'
663
+ period_format, step_format = formats[format_pair]
664
+
665
+ df = df.sort_index() # Ensure DataFrame is sorted by time index
666
+
667
+ resampled_data = df.resample(steps_per_period).mean() # Resample and fill any gaps with NaN
668
+
669
+ if fill == 'ffill': # Apply fill method if specified
670
+ resampled_data = resampled_data.ffill()
671
+ elif fill == 'bfill':
672
+ resampled_data = resampled_data.bfill()
673
+
674
+ resampled_data['period'] = resampled_data.index.strftime(period_format)
675
+ resampled_data['step'] = resampled_data.index.strftime(step_format)
676
+ if '%w_%A' in step_format: # SHift index of strings to ensure proper sorting
677
+ resampled_data['step'] = resampled_data['step'].apply(
678
+ lambda x: x.replace('0_Sunday', '7_Sunday') if '0_Sunday' in x else x
679
+ )
680
+
681
+ # Pivot the table so periods are columns and steps are indices
682
+ df_pivoted = resampled_data.pivot(columns='period', index='step', values=df.columns[0])
683
+
684
+ return df_pivoted
685
+
686
+
687
+ def plot_network(
688
+ node_infos: dict,
689
+ edge_infos: dict,
690
+ path: Optional[Union[str, pathlib.Path]] = None,
691
+ controls: Union[
692
+ bool,
693
+ List[Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer']],
694
+ ] = True,
695
+ show: bool = False,
696
+ ) -> Optional['pyvis.network.Network']:
697
+ """
698
+ Visualizes the network structure of a FlowSystem using PyVis, using info-dictionaries.
699
+
700
+ Args:
701
+ path: Path to save the HTML visualization. `False`: Visualization is created but not saved. `str` or `Path`: Specifies file path (default: 'results/network.html').
702
+ controls: UI controls to add to the visualization. `True`: Enables all available controls. `List`: Specify controls, e.g., ['nodes', 'layout'].
703
+ Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'.
704
+ You can play with these and generate a Dictionary from it that can be applied to the network returned by this function.
705
+ network.set_options()
706
+ https://pyvis.readthedocs.io/en/latest/tutorial.html
707
+ show: Whether to open the visualization in the web browser.
708
+ The calculation must be saved to show it. If no path is given, it defaults to 'network.html'.
709
+ Returns:
710
+ The `Network` instance representing the visualization, or `None` if `pyvis` is not installed.
711
+
712
+ Notes:
713
+ - This function requires `pyvis`. If not installed, the function prints a warning and returns `None`.
714
+ - Nodes are styled based on type (e.g., circles for buses, boxes for components) and annotated with node information.
715
+ """
716
+ try:
717
+ from pyvis.network import Network
718
+ except ImportError:
719
+ logger.critical("Plotting the flow system network was not possible. Please install pyvis: 'pip install pyvis'")
720
+ return None
721
+
722
+ net = Network(directed=True, height='100%' if controls is False else '800px', font_color='white')
723
+
724
+ for node_id, node in node_infos.items():
725
+ net.add_node(
726
+ node_id,
727
+ label=node['label'],
728
+ shape={'Bus': 'circle', 'Component': 'box'}[node['class']],
729
+ color={'Bus': '#393E46', 'Component': '#00ADB5'}[node['class']],
730
+ title=node['infos'].replace(')', '\n)'),
731
+ font={'size': 14},
732
+ )
733
+
734
+ for edge in edge_infos.values():
735
+ net.add_edge(
736
+ edge['start'],
737
+ edge['end'],
738
+ label=edge['label'],
739
+ title=edge['infos'].replace(')', '\n)'),
740
+ font={'color': '#4D4D4D', 'size': 14},
741
+ color='#222831',
742
+ )
743
+
744
+ # Enhanced physics settings
745
+ net.barnes_hut(central_gravity=0.8, spring_length=50, spring_strength=0.05, gravity=-10000)
746
+
747
+ if controls:
748
+ net.show_buttons(filter_=controls) # Adds UI buttons to control physics settings
749
+ if not show and not path:
750
+ return net
751
+ elif path:
752
+ path = pathlib.Path(path) if isinstance(path, str) else path
753
+ net.write_html(path.as_posix())
754
+ elif show:
755
+ path = pathlib.Path('network.html')
756
+ net.write_html(path.as_posix())
757
+
758
+ if show:
759
+ try:
760
+ import webbrowser
761
+
762
+ worked = webbrowser.open(f'file://{path.resolve()}', 2)
763
+ if not worked:
764
+ logger.warning(
765
+ f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}'
766
+ )
767
+ except Exception as e:
768
+ logger.warning(
769
+ f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}: {e}'
770
+ )
771
+
772
+
773
+ def pie_with_plotly(
774
+ data: pd.DataFrame,
775
+ colors: ColorType = 'viridis',
776
+ title: str = '',
777
+ legend_title: str = '',
778
+ hole: float = 0.0,
779
+ fig: Optional[go.Figure] = None,
780
+ ) -> go.Figure:
781
+ """
782
+ Create a pie chart with Plotly to visualize the proportion of values in a DataFrame.
783
+
784
+ Args:
785
+ data: A DataFrame containing the data to plot. If multiple rows exist,
786
+ they will be summed unless a specific index value is passed.
787
+ colors: Color specification, can be:
788
+ - A string with a colorscale name (e.g., 'viridis', 'plasma')
789
+ - A list of color strings (e.g., ['#ff0000', '#00ff00'])
790
+ - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'})
791
+ title: The title of the plot.
792
+ legend_title: The title for the legend.
793
+ hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0).
794
+ fig: A Plotly figure object to plot on. If not provided, a new figure will be created.
795
+
796
+ Returns:
797
+ A Plotly figure object containing the generated pie chart.
798
+
799
+ Notes:
800
+ - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning.
801
+ - If the data contains very small values (less than 1% of the total), they can be grouped into an "Other" category
802
+ for better readability.
803
+ - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing.
804
+
805
+ """
806
+ if data.empty:
807
+ logger.warning('Empty DataFrame provided for pie chart. Returning empty figure.')
808
+ return go.Figure()
809
+
810
+ # Create a copy to avoid modifying the original DataFrame
811
+ data_copy = data.copy()
812
+
813
+ # Check if any negative values and warn
814
+ if (data_copy < 0).any().any():
815
+ logger.warning('Negative values detected in data. Using absolute values for pie chart.')
816
+ data_copy = data_copy.abs()
817
+
818
+ # If data has multiple rows, sum them to get total for each column
819
+ if len(data_copy) > 1:
820
+ data_sum = data_copy.sum()
821
+ else:
822
+ data_sum = data_copy.iloc[0]
823
+
824
+ # Get labels (column names) and values
825
+ labels = data_sum.index.tolist()
826
+ values = data_sum.values.tolist()
827
+
828
+ # Apply color mapping using the unified color processor
829
+ processed_colors = ColorProcessor(engine='plotly').process_colors(colors, list(data.columns))
830
+
831
+ # Create figure if not provided
832
+ fig = fig if fig is not None else go.Figure()
833
+
834
+ # Add pie trace
835
+ fig.add_trace(
836
+ go.Pie(
837
+ labels=labels,
838
+ values=values,
839
+ hole=hole,
840
+ marker=dict(colors=processed_colors),
841
+ textinfo='percent+label+value',
842
+ textposition='inside',
843
+ insidetextorientation='radial',
844
+ )
845
+ )
846
+
847
+ # Update layout for better aesthetics
848
+ fig.update_layout(
849
+ title=title,
850
+ legend_title=legend_title,
851
+ plot_bgcolor='rgba(0,0,0,0)', # Transparent background
852
+ paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background
853
+ font=dict(size=14), # Increase font size for better readability
854
+ )
855
+
856
+ return fig
857
+
858
+
859
+ def pie_with_matplotlib(
860
+ data: pd.DataFrame,
861
+ colors: ColorType = 'viridis',
862
+ title: str = '',
863
+ legend_title: str = 'Categories',
864
+ hole: float = 0.0,
865
+ figsize: Tuple[int, int] = (10, 8),
866
+ fig: Optional[plt.Figure] = None,
867
+ ax: Optional[plt.Axes] = None,
868
+ ) -> Tuple[plt.Figure, plt.Axes]:
869
+ """
870
+ Create a pie chart with Matplotlib to visualize the proportion of values in a DataFrame.
871
+
872
+ Args:
873
+ data: A DataFrame containing the data to plot. If multiple rows exist,
874
+ they will be summed unless a specific index value is passed.
875
+ colors: Color specification, can be:
876
+ - A string with a colormap name (e.g., 'viridis', 'plasma')
877
+ - A list of color strings (e.g., ['#ff0000', '#00ff00'])
878
+ - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'})
879
+ title: The title of the plot.
880
+ legend_title: The title for the legend.
881
+ hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0).
882
+ figsize: The size of the figure (width, height) in inches.
883
+ fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created.
884
+ ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created.
885
+
886
+ Returns:
887
+ A tuple containing the Matplotlib figure and axes objects used for the plot.
888
+
889
+ Notes:
890
+ - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning.
891
+ - If the data contains very small values (less than 1% of the total), they can be grouped into an "Other" category
892
+ for better readability.
893
+ - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing.
894
+
895
+ """
896
+ if data.empty:
897
+ logger.warning('Empty DataFrame provided for pie chart. Returning empty figure.')
898
+ if fig is None or ax is None:
899
+ fig, ax = plt.subplots(figsize=figsize)
900
+ return fig, ax
901
+
902
+ # Create a copy to avoid modifying the original DataFrame
903
+ data_copy = data.copy()
904
+
905
+ # Check if any negative values and warn
906
+ if (data_copy < 0).any().any():
907
+ logger.warning('Negative values detected in data. Using absolute values for pie chart.')
908
+ data_copy = data_copy.abs()
909
+
910
+ # If data has multiple rows, sum them to get total for each column
911
+ if len(data_copy) > 1:
912
+ data_sum = data_copy.sum()
913
+ else:
914
+ data_sum = data_copy.iloc[0]
915
+
916
+ # Get labels (column names) and values
917
+ labels = data_sum.index.tolist()
918
+ values = data_sum.values.tolist()
919
+
920
+ # Apply color mapping using the unified color processor
921
+ processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, labels)
922
+
923
+ # Create figure and axis if not provided
924
+ if fig is None or ax is None:
925
+ fig, ax = plt.subplots(figsize=figsize)
926
+
927
+ # Draw the pie chart
928
+ wedges, texts, autotexts = ax.pie(
929
+ values,
930
+ labels=labels,
931
+ colors=processed_colors,
932
+ autopct='%1.1f%%',
933
+ startangle=90,
934
+ shadow=False,
935
+ wedgeprops=dict(width=0.5) if hole > 0 else None, # Set width for donut
936
+ )
937
+
938
+ # Adjust the wedgeprops to make donut hole size consistent with plotly
939
+ # For matplotlib, the hole size is determined by the wedge width
940
+ # Convert hole parameter to wedge width
941
+ if hole > 0:
942
+ # Adjust hole size to match plotly's hole parameter
943
+ # In matplotlib, wedge width is relative to the radius (which is 1)
944
+ # For plotly, hole is a fraction of the radius
945
+ wedge_width = 1 - hole
946
+ for wedge in wedges:
947
+ wedge.set_width(wedge_width)
948
+
949
+ # Customize the appearance
950
+ # Make autopct text more visible
951
+ for autotext in autotexts:
952
+ autotext.set_fontsize(10)
953
+ autotext.set_color('white')
954
+
955
+ # Set aspect ratio to be equal to ensure a circular pie
956
+ ax.set_aspect('equal')
957
+
958
+ # Add title
959
+ if title:
960
+ ax.set_title(title, fontsize=16)
961
+
962
+ # Create a legend if there are many segments
963
+ if len(labels) > 6:
964
+ ax.legend(wedges, labels, title=legend_title, loc='center left', bbox_to_anchor=(1, 0, 0.5, 1))
965
+
966
+ # Apply tight layout
967
+ fig.tight_layout()
968
+
969
+ return fig, ax
970
+
971
+
972
+ def dual_pie_with_plotly(
973
+ data_left: pd.Series,
974
+ data_right: pd.Series,
975
+ colors: ColorType = 'viridis',
976
+ title: str = '',
977
+ subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'),
978
+ legend_title: str = '',
979
+ hole: float = 0.2,
980
+ lower_percentage_group: float = 5.0,
981
+ hover_template: str = '%{label}: %{value} (%{percent})',
982
+ text_info: str = 'percent+label',
983
+ text_position: str = 'inside',
984
+ ) -> go.Figure:
985
+ """
986
+ Create two pie charts side by side with Plotly, with consistent coloring across both charts.
987
+
988
+ Args:
989
+ data_left: Series for the left pie chart.
990
+ data_right: Series for the right pie chart.
991
+ colors: Color specification, can be:
992
+ - A string with a colorscale name (e.g., 'viridis', 'plasma')
993
+ - A list of color strings (e.g., ['#ff0000', '#00ff00'])
994
+ - A dictionary mapping category names to colors (e.g., {'Category1': '#ff0000'})
995
+ title: The main title of the plot.
996
+ subtitles: Tuple containing the subtitles for (left, right) charts.
997
+ legend_title: The title for the legend.
998
+ hole: Size of the hole in the center for creating donut charts (0.0 to 100).
999
+ lower_percentage_group: Whether to group small segments (below percentage (0...1)) into an "Other" category.
1000
+ hover_template: Template for hover text. Use %{label}, %{value}, %{percent}.
1001
+ text_info: What to show on pie segments: 'label', 'percent', 'value', 'label+percent',
1002
+ 'label+value', 'percent+value', 'label+percent+value', or 'none'.
1003
+ text_position: Position of text: 'inside', 'outside', 'auto', or 'none'.
1004
+
1005
+ Returns:
1006
+ A Plotly figure object containing the generated dual pie chart.
1007
+ """
1008
+ from plotly.subplots import make_subplots
1009
+
1010
+ # Check for empty data
1011
+ if data_left.empty and data_right.empty:
1012
+ logger.warning('Both datasets are empty. Returning empty figure.')
1013
+ return go.Figure()
1014
+
1015
+ # Create a subplot figure
1016
+ fig = make_subplots(
1017
+ rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], subplot_titles=subtitles, horizontal_spacing=0.05
1018
+ )
1019
+
1020
+ # Process series to handle negative values and apply minimum percentage threshold
1021
+ def preprocess_series(series: pd.Series):
1022
+ """
1023
+ Preprocess a series for pie chart display by handling negative values
1024
+ and grouping the smallest parts together if they collectively represent
1025
+ less than the specified percentage threshold.
1026
+
1027
+ Args:
1028
+ series: The series to preprocess
1029
+
1030
+ Returns:
1031
+ A preprocessed pandas Series
1032
+ """
1033
+ # Handle negative values
1034
+ if (series < 0).any():
1035
+ logger.warning('Negative values detected in data. Using absolute values for pie chart.')
1036
+ series = series.abs()
1037
+
1038
+ # Remove zeros
1039
+ series = series[series > 0]
1040
+
1041
+ # Apply minimum percentage threshold if needed
1042
+ if lower_percentage_group and not series.empty:
1043
+ total = series.sum()
1044
+ if total > 0:
1045
+ # Sort series by value (ascending)
1046
+ sorted_series = series.sort_values()
1047
+
1048
+ # Calculate cumulative percentage contribution
1049
+ cumulative_percent = (sorted_series.cumsum() / total) * 100
1050
+
1051
+ # Find entries that collectively make up less than lower_percentage_group
1052
+ to_group = cumulative_percent <= lower_percentage_group
1053
+
1054
+ if to_group.sum() > 1:
1055
+ # Create "Other" category for the smallest values that together are < threshold
1056
+ other_sum = sorted_series[to_group].sum()
1057
+
1058
+ # Keep only values that aren't in the "Other" group
1059
+ result_series = series[~series.index.isin(sorted_series[to_group].index)]
1060
+
1061
+ # Add the "Other" category if it has a value
1062
+ if other_sum > 0:
1063
+ result_series['Other'] = other_sum
1064
+
1065
+ return result_series
1066
+
1067
+ return series
1068
+
1069
+ data_left_processed = preprocess_series(data_left)
1070
+ data_right_processed = preprocess_series(data_right)
1071
+
1072
+ # Get unique set of all labels for consistent coloring
1073
+ all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index))
1074
+
1075
+ # Get consistent color mapping for both charts using our unified function
1076
+ color_map = ColorProcessor(engine='plotly').process_colors(colors, all_labels, return_mapping=True)
1077
+
1078
+ # Function to create a pie trace with consistently mapped colors
1079
+ def create_pie_trace(data_series, side):
1080
+ if data_series.empty:
1081
+ return None
1082
+
1083
+ labels = data_series.index.tolist()
1084
+ values = data_series.values.tolist()
1085
+ trace_colors = [color_map[label] for label in labels]
1086
+
1087
+ return go.Pie(
1088
+ labels=labels,
1089
+ values=values,
1090
+ name=side,
1091
+ marker_colors=trace_colors,
1092
+ hole=hole,
1093
+ textinfo=text_info,
1094
+ textposition=text_position,
1095
+ insidetextorientation='radial',
1096
+ hovertemplate=hover_template,
1097
+ sort=True, # Sort values by default (largest first)
1098
+ )
1099
+
1100
+ # Add left pie if data exists
1101
+ left_trace = create_pie_trace(data_left_processed, subtitles[0])
1102
+ if left_trace:
1103
+ left_trace.domain = dict(x=[0, 0.48])
1104
+ fig.add_trace(left_trace, row=1, col=1)
1105
+
1106
+ # Add right pie if data exists
1107
+ right_trace = create_pie_trace(data_right_processed, subtitles[1])
1108
+ if right_trace:
1109
+ right_trace.domain = dict(x=[0.52, 1])
1110
+ fig.add_trace(right_trace, row=1, col=2)
1111
+
1112
+ # Update layout
1113
+ fig.update_layout(
1114
+ title=title,
1115
+ legend_title=legend_title,
1116
+ plot_bgcolor='rgba(0,0,0,0)', # Transparent background
1117
+ paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background
1118
+ font=dict(size=14),
1119
+ margin=dict(t=80, b=50, l=30, r=30),
1120
+ legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5, font=dict(size=12)),
1121
+ )
1122
+
1123
+ return fig
1124
+
1125
+
1126
+ def dual_pie_with_matplotlib(
1127
+ data_left: pd.Series,
1128
+ data_right: pd.Series,
1129
+ colors: ColorType = 'viridis',
1130
+ title: str = '',
1131
+ subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'),
1132
+ legend_title: str = '',
1133
+ hole: float = 0.2,
1134
+ lower_percentage_group: float = 5.0,
1135
+ figsize: Tuple[int, int] = (14, 7),
1136
+ fig: Optional[plt.Figure] = None,
1137
+ axes: Optional[List[plt.Axes]] = None,
1138
+ ) -> Tuple[plt.Figure, List[plt.Axes]]:
1139
+ """
1140
+ Create two pie charts side by side with Matplotlib, with consistent coloring across both charts.
1141
+ Leverages the existing pie_with_matplotlib function.
1142
+
1143
+ Args:
1144
+ data_left: Series for the left pie chart.
1145
+ data_right: Series for the right pie chart.
1146
+ colors: Color specification, can be:
1147
+ - A string with a colormap name (e.g., 'viridis', 'plasma')
1148
+ - A list of color strings (e.g., ['#ff0000', '#00ff00'])
1149
+ - A dictionary mapping category names to colors (e.g., {'Category1': '#ff0000'})
1150
+ title: The main title of the plot.
1151
+ subtitles: Tuple containing the subtitles for (left, right) charts.
1152
+ legend_title: The title for the legend.
1153
+ hole: Size of the hole in the center for creating donut charts (0.0 to 1.0).
1154
+ lower_percentage_group: Whether to group small segments (below percentage) into an "Other" category.
1155
+ figsize: The size of the figure (width, height) in inches.
1156
+ fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created.
1157
+ axes: A list of Matplotlib axes objects to plot on. If not provided, new axes will be created.
1158
+
1159
+ Returns:
1160
+ A tuple containing the Matplotlib figure and list of axes objects used for the plot.
1161
+ """
1162
+ # Check for empty data
1163
+ if data_left.empty and data_right.empty:
1164
+ logger.warning('Both datasets are empty. Returning empty figure.')
1165
+ if fig is None:
1166
+ fig, axes = plt.subplots(1, 2, figsize=figsize)
1167
+ return fig, axes
1168
+
1169
+ # Create figure and axes if not provided
1170
+ if fig is None or axes is None:
1171
+ fig, axes = plt.subplots(1, 2, figsize=figsize)
1172
+
1173
+ # Process series to handle negative values and apply minimum percentage threshold
1174
+ def preprocess_series(series: pd.Series):
1175
+ """
1176
+ Preprocess a series for pie chart display by handling negative values
1177
+ and grouping the smallest parts together if they collectively represent
1178
+ less than the specified percentage threshold.
1179
+ """
1180
+ # Handle negative values
1181
+ if (series < 0).any():
1182
+ logger.warning('Negative values detected in data. Using absolute values for pie chart.')
1183
+ series = series.abs()
1184
+
1185
+ # Remove zeros
1186
+ series = series[series > 0]
1187
+
1188
+ # Apply minimum percentage threshold if needed
1189
+ if lower_percentage_group and not series.empty:
1190
+ total = series.sum()
1191
+ if total > 0:
1192
+ # Sort series by value (ascending)
1193
+ sorted_series = series.sort_values()
1194
+
1195
+ # Calculate cumulative percentage contribution
1196
+ cumulative_percent = (sorted_series.cumsum() / total) * 100
1197
+
1198
+ # Find entries that collectively make up less than lower_percentage_group
1199
+ to_group = cumulative_percent <= lower_percentage_group
1200
+
1201
+ if to_group.sum() > 1:
1202
+ # Create "Other" category for the smallest values that together are < threshold
1203
+ other_sum = sorted_series[to_group].sum()
1204
+
1205
+ # Keep only values that aren't in the "Other" group
1206
+ result_series = series[~series.index.isin(sorted_series[to_group].index)]
1207
+
1208
+ # Add the "Other" category if it has a value
1209
+ if other_sum > 0:
1210
+ result_series['Other'] = other_sum
1211
+
1212
+ return result_series
1213
+
1214
+ return series
1215
+
1216
+ # Preprocess data
1217
+ data_left_processed = preprocess_series(data_left)
1218
+ data_right_processed = preprocess_series(data_right)
1219
+
1220
+ # Convert Series to DataFrames for pie_with_matplotlib
1221
+ df_left = pd.DataFrame(data_left_processed).T if not data_left_processed.empty else pd.DataFrame()
1222
+ df_right = pd.DataFrame(data_right_processed).T if not data_right_processed.empty else pd.DataFrame()
1223
+
1224
+ # Get unique set of all labels for consistent coloring
1225
+ all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index))
1226
+
1227
+ # Get consistent color mapping for both charts using our unified function
1228
+ color_map = ColorProcessor(engine='matplotlib').process_colors(colors, all_labels, return_mapping=True)
1229
+
1230
+ # Configure colors for each DataFrame based on the consistent mapping
1231
+ left_colors = [color_map[col] for col in df_left.columns] if not df_left.empty else []
1232
+ right_colors = [color_map[col] for col in df_right.columns] if not df_right.empty else []
1233
+
1234
+ # Create left pie chart
1235
+ if not df_left.empty:
1236
+ pie_with_matplotlib(data=df_left, colors=left_colors, title=subtitles[0], hole=hole, fig=fig, ax=axes[0])
1237
+ else:
1238
+ axes[0].set_title(subtitles[0])
1239
+ axes[0].axis('off')
1240
+
1241
+ # Create right pie chart
1242
+ if not df_right.empty:
1243
+ pie_with_matplotlib(data=df_right, colors=right_colors, title=subtitles[1], hole=hole, fig=fig, ax=axes[1])
1244
+ else:
1245
+ axes[1].set_title(subtitles[1])
1246
+ axes[1].axis('off')
1247
+
1248
+ # Add main title
1249
+ if title:
1250
+ fig.suptitle(title, fontsize=16, y=0.98)
1251
+
1252
+ # Adjust layout
1253
+ fig.tight_layout()
1254
+
1255
+ # Create a unified legend if both charts have data
1256
+ if not df_left.empty and not df_right.empty:
1257
+ # Remove individual legends
1258
+ for ax in axes:
1259
+ if ax.get_legend():
1260
+ ax.get_legend().remove()
1261
+
1262
+ # Create handles for the unified legend
1263
+ handles = []
1264
+ labels_for_legend = []
1265
+
1266
+ for label in all_labels:
1267
+ color = color_map[label]
1268
+ patch = plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=color, markersize=10, label=label)
1269
+ handles.append(patch)
1270
+ labels_for_legend.append(label)
1271
+
1272
+ # Add unified legend
1273
+ fig.legend(
1274
+ handles=handles,
1275
+ labels=labels_for_legend,
1276
+ title=legend_title,
1277
+ loc='lower center',
1278
+ bbox_to_anchor=(0.5, 0),
1279
+ ncol=min(len(all_labels), 5), # Limit columns to 5 for readability
1280
+ )
1281
+
1282
+ # Add padding at the bottom for the legend
1283
+ fig.subplots_adjust(bottom=0.2)
1284
+
1285
+ return fig, axes
1286
+
1287
+
1288
+ def export_figure(
1289
+ figure_like: Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]],
1290
+ default_path: pathlib.Path,
1291
+ default_filetype: Optional[str] = None,
1292
+ user_path: Optional[pathlib.Path] = None,
1293
+ show: bool = True,
1294
+ save: bool = False,
1295
+ ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]:
1296
+ """
1297
+ Export a figure to a file and or show it.
1298
+
1299
+ Args:
1300
+ figure_like: The figure to export. Can be a Plotly figure or a tuple of Matplotlib figure and axes.
1301
+ default_path: The default file path if no user filename is provided.
1302
+ default_filetype: The default filetype if the path doesnt end with a filetype.
1303
+ user_path: An optional user-specified file path.
1304
+ show: Whether to display the figure (default: True).
1305
+ save: Whether to save the figure (default: False).
1306
+
1307
+ Raises:
1308
+ ValueError: If no default filetype is provided and the path doesn't specify a filetype.
1309
+ TypeError: If the figure type is not supported.
1310
+ """
1311
+ filename = user_path or default_path
1312
+ if filename.suffix == '':
1313
+ if default_filetype is None:
1314
+ raise ValueError('No default filetype provided')
1315
+ filename = filename.with_suffix(default_filetype)
1316
+
1317
+ if isinstance(figure_like, plotly.graph_objs.Figure):
1318
+ fig = figure_like
1319
+ if not filename.suffix == '.html':
1320
+ logger.debug(f'To save a plotly figure, the filename should end with ".html". Got {filename}')
1321
+ if show and not save:
1322
+ fig.show()
1323
+ elif save and show:
1324
+ plotly.offline.plot(fig, filename=str(filename))
1325
+ elif save and not show:
1326
+ fig.write_html(filename)
1327
+ return figure_like
1328
+
1329
+ elif isinstance(figure_like, tuple):
1330
+ fig, ax = figure_like
1331
+ if show:
1332
+ fig.show()
1333
+ if save:
1334
+ fig.savefig(str(filename), dpi=300)
1335
+ return fig, ax
1336
+
1337
+ raise TypeError(f'Figure type not supported: {type(figure_like)}')