flixopt 2.2.0rc2__py3-none-any.whl → 3.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flixopt/__init__.py +33 -4
- flixopt/aggregation.py +60 -80
- flixopt/calculation.py +403 -182
- flixopt/commons.py +1 -10
- flixopt/components.py +939 -448
- flixopt/config.py +553 -191
- flixopt/core.py +513 -846
- flixopt/effects.py +644 -178
- flixopt/elements.py +610 -355
- flixopt/features.py +394 -966
- flixopt/flow_system.py +736 -219
- flixopt/interface.py +1104 -302
- flixopt/io.py +103 -79
- flixopt/linear_converters.py +387 -95
- flixopt/modeling.py +757 -0
- flixopt/network_app.py +73 -39
- flixopt/plotting.py +294 -138
- flixopt/results.py +1254 -300
- flixopt/solvers.py +25 -21
- flixopt/structure.py +938 -396
- flixopt/utils.py +36 -12
- flixopt-3.0.1.dist-info/METADATA +209 -0
- flixopt-3.0.1.dist-info/RECORD +26 -0
- flixopt-3.0.1.dist-info/top_level.txt +1 -0
- docs/examples/00-Minimal Example.md +0 -5
- docs/examples/01-Basic Example.md +0 -5
- docs/examples/02-Complex Example.md +0 -10
- docs/examples/03-Calculation Modes.md +0 -5
- docs/examples/index.md +0 -5
- docs/faq/contribute.md +0 -61
- docs/faq/index.md +0 -3
- docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
- docs/images/architecture_flixOpt.png +0 -0
- docs/images/flixopt-icon.svg +0 -1
- docs/javascripts/mathjax.js +0 -18
- docs/user-guide/Mathematical Notation/Bus.md +0 -33
- docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
- docs/user-guide/Mathematical Notation/Flow.md +0 -26
- docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
- docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
- docs/user-guide/Mathematical Notation/Storage.md +0 -44
- docs/user-guide/Mathematical Notation/index.md +0 -22
- docs/user-guide/Mathematical Notation/others.md +0 -3
- docs/user-guide/index.md +0 -124
- flixopt/config.yaml +0 -10
- flixopt-2.2.0rc2.dist-info/METADATA +0 -167
- flixopt-2.2.0rc2.dist-info/RECORD +0 -54
- flixopt-2.2.0rc2.dist-info/top_level.txt +0 -5
- pics/architecture_flixOpt-pre2.0.0.png +0 -0
- pics/architecture_flixOpt.png +0 -0
- pics/flixOpt_plotting.jpg +0 -0
- pics/flixopt-icon.svg +0 -1
- pics/pics.pptx +0 -0
- scripts/extract_release_notes.py +0 -45
- scripts/gen_ref_pages.py +0 -54
- tests/ressources/Zeitreihen2020.csv +0 -35137
- {flixopt-2.2.0rc2.dist-info → flixopt-3.0.1.dist-info}/WHEEL +0 -0
- {flixopt-2.2.0rc2.dist-info → flixopt-3.0.1.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
|
-
|
|
3
|
-
|
|
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,
|
|
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 '
|
|
37
|
-
plt.colormaps
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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) ->
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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:
|
|
226
|
+
colors: list of color strings
|
|
109
227
|
num_labels: Number of labels that need colors
|
|
110
228
|
|
|
111
229
|
Returns:
|
|
112
|
-
|
|
230
|
+
list of colors matching the number of labels
|
|
113
231
|
"""
|
|
114
232
|
if len(colors) == 0:
|
|
115
|
-
logger.
|
|
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:
|
|
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:
|
|
257
|
+
labels: list of labels that need colors
|
|
140
258
|
|
|
141
259
|
Returns:
|
|
142
|
-
|
|
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:
|
|
289
|
+
labels: list[str],
|
|
172
290
|
return_mapping: bool = False,
|
|
173
|
-
) ->
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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'
|
|
380
|
+
barmode='relative',
|
|
259
381
|
bargap=0, # No space between bars
|
|
260
|
-
bargroupgap=0, # No space between
|
|
382
|
+
bargroupgap=0, # No space between grouped bars
|
|
261
383
|
)
|
|
262
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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:
|
|
354
|
-
fig:
|
|
355
|
-
ax:
|
|
356
|
-
) ->
|
|
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
|
-
|
|
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 `
|
|
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 `
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
449
|
-
) ->
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:%
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: #
|
|
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:
|
|
693
|
-
controls:
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
) ->
|
|
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. `
|
|
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.
|
|
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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,
|
|
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:
|
|
868
|
-
fig:
|
|
869
|
-
ax:
|
|
870
|
-
) ->
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
|
1001
|
-
lower_percentage_group:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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:
|
|
1138
|
-
fig:
|
|
1139
|
-
axes:
|
|
1140
|
-
) ->
|
|
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.
|
|
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.
|
|
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:
|
|
1417
|
+
figure_like: go.Figure | tuple[plt.Figure, plt.Axes],
|
|
1292
1418
|
default_path: pathlib.Path,
|
|
1293
|
-
default_filetype:
|
|
1294
|
-
user_path:
|
|
1419
|
+
default_filetype: str | None = None,
|
|
1420
|
+
user_path: pathlib.Path | None = None,
|
|
1295
1421
|
show: bool = True,
|
|
1296
1422
|
save: bool = False,
|
|
1297
|
-
) ->
|
|
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
|
|
1323
|
-
logger.
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
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)}')
|