flixopt 3.0.1__py3-none-any.whl → 6.0.0rc7__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 +57 -49
- flixopt/carrier.py +159 -0
- flixopt/clustering/__init__.py +51 -0
- flixopt/clustering/base.py +1746 -0
- flixopt/clustering/intercluster_helpers.py +201 -0
- flixopt/color_processing.py +372 -0
- flixopt/comparison.py +819 -0
- flixopt/components.py +848 -270
- flixopt/config.py +853 -496
- flixopt/core.py +111 -98
- flixopt/effects.py +294 -284
- flixopt/elements.py +484 -223
- flixopt/features.py +220 -118
- flixopt/flow_system.py +2026 -389
- flixopt/interface.py +504 -286
- flixopt/io.py +1718 -55
- flixopt/linear_converters.py +291 -230
- flixopt/modeling.py +304 -181
- flixopt/network_app.py +2 -1
- flixopt/optimization.py +788 -0
- flixopt/optimize_accessor.py +373 -0
- flixopt/plot_result.py +143 -0
- flixopt/plotting.py +1177 -1034
- flixopt/results.py +1331 -372
- flixopt/solvers.py +12 -4
- flixopt/statistics_accessor.py +2412 -0
- flixopt/stats_accessor.py +75 -0
- flixopt/structure.py +954 -120
- flixopt/topology_accessor.py +676 -0
- flixopt/transform_accessor.py +2277 -0
- flixopt/types.py +120 -0
- flixopt-6.0.0rc7.dist-info/METADATA +290 -0
- flixopt-6.0.0rc7.dist-info/RECORD +36 -0
- {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/WHEEL +1 -1
- flixopt/aggregation.py +0 -382
- flixopt/calculation.py +0 -672
- flixopt/commons.py +0 -51
- flixopt/utils.py +0 -86
- flixopt-3.0.1.dist-info/METADATA +0 -209
- flixopt-3.0.1.dist-info/RECORD +0 -26
- {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/licenses/LICENSE +0 -0
- {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Helper utilities for inter-cluster storage linking.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for building inter-cluster storage linking
|
|
4
|
+
constraints following the S-N model from Blanke et al. (2022).
|
|
5
|
+
|
|
6
|
+
Background
|
|
7
|
+
----------
|
|
8
|
+
When time series are clustered (aggregated into representative periods), storage
|
|
9
|
+
behavior needs special handling. The S-N linking model introduces:
|
|
10
|
+
|
|
11
|
+
- **SOC_boundary**: Absolute state-of-charge at the boundary between original periods.
|
|
12
|
+
With N original periods, there are N+1 boundary points.
|
|
13
|
+
|
|
14
|
+
- **Linking**: SOC_boundary[d+1] = SOC_boundary[d] + delta_SOC[cluster_assignments[d]]
|
|
15
|
+
Each boundary is connected to the next via the net charge change of the
|
|
16
|
+
representative cluster for that period.
|
|
17
|
+
|
|
18
|
+
These utilities help construct the coordinates and bounds for SOC_boundary variables.
|
|
19
|
+
|
|
20
|
+
References
|
|
21
|
+
----------
|
|
22
|
+
- Blanke, T., et al. (2022). "Inter-Cluster Storage Linking for Time Series
|
|
23
|
+
Aggregation in Energy System Optimization Models."
|
|
24
|
+
- Kotzur, L., et al. (2018). "Time series aggregation for energy system design:
|
|
25
|
+
Modeling seasonal storage."
|
|
26
|
+
|
|
27
|
+
See Also
|
|
28
|
+
--------
|
|
29
|
+
:class:`flixopt.components.InterclusterStorageModel`
|
|
30
|
+
The storage model that uses these utilities.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import logging
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
import numpy as np
|
|
40
|
+
import xarray as xr
|
|
41
|
+
|
|
42
|
+
from ..interface import InvestParameters
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from ..flow_system import FlowSystem
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger('flixopt')
|
|
48
|
+
|
|
49
|
+
# Default upper bound for unbounded storage capacity.
|
|
50
|
+
# Used when no explicit capacity or InvestParameters.maximum_size is provided.
|
|
51
|
+
# Set to 1e6 to avoid numerical issues with very large bounds while still
|
|
52
|
+
# being effectively unbounded for most practical applications.
|
|
53
|
+
DEFAULT_UNBOUNDED_CAPACITY = 1e6
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class CapacityBounds:
|
|
58
|
+
"""Bounds for SOC_boundary variable creation.
|
|
59
|
+
|
|
60
|
+
This dataclass holds the lower and upper bounds for the SOC_boundary variable,
|
|
61
|
+
along with a flag indicating whether investment sizing is used.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
lower: Lower bound DataArray (typically zeros).
|
|
65
|
+
upper: Upper bound DataArray (capacity or maximum investment size).
|
|
66
|
+
has_investment: True if the storage uses InvestParameters for sizing.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
lower: xr.DataArray
|
|
70
|
+
upper: xr.DataArray
|
|
71
|
+
has_investment: bool
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extract_capacity_bounds(
|
|
75
|
+
capacity_param: InvestParameters | int | float | None,
|
|
76
|
+
boundary_coords: dict,
|
|
77
|
+
boundary_dims: list[str],
|
|
78
|
+
) -> CapacityBounds:
|
|
79
|
+
"""Extract capacity bounds from storage parameters for SOC_boundary variable.
|
|
80
|
+
|
|
81
|
+
This function determines the appropriate bounds for the SOC_boundary variable
|
|
82
|
+
based on the storage's capacity parameter:
|
|
83
|
+
|
|
84
|
+
- **Fixed capacity** (numeric): Upper bound is the fixed value.
|
|
85
|
+
- **InvestParameters**: Upper bound is maximum_size (or fixed_size if set).
|
|
86
|
+
The actual bound is enforced via separate constraints linked to investment.size.
|
|
87
|
+
- **None/Unbounded**: Upper bound is set to a large value (1e6).
|
|
88
|
+
|
|
89
|
+
The lower bound is always zero (SOC cannot be negative).
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
capacity_param: Storage capacity specification. Can be:
|
|
93
|
+
- Numeric (int/float): Fixed capacity
|
|
94
|
+
- InvestParameters: Investment-based sizing with min/max
|
|
95
|
+
- None: Unbounded storage
|
|
96
|
+
boundary_coords: Coordinate dictionary for SOC_boundary variable.
|
|
97
|
+
Must contain 'cluster_boundary' key.
|
|
98
|
+
boundary_dims: Dimension names for SOC_boundary variable.
|
|
99
|
+
First dimension must be 'cluster_boundary'.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
CapacityBounds with lower/upper bounds and investment flag.
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
>>> coords, dims = build_boundary_coords(14, flow_system)
|
|
106
|
+
>>> bounds = extract_capacity_bounds(InvestParameters(maximum_size=10000), coords, dims)
|
|
107
|
+
>>> bounds.has_investment
|
|
108
|
+
True
|
|
109
|
+
>>> bounds.upper.max()
|
|
110
|
+
10000.0
|
|
111
|
+
"""
|
|
112
|
+
n_boundaries = len(boundary_coords['cluster_boundary'])
|
|
113
|
+
lb_shape = [n_boundaries] + [len(boundary_coords[d]) for d in boundary_dims[1:]]
|
|
114
|
+
|
|
115
|
+
lb = xr.DataArray(np.zeros(lb_shape), coords=boundary_coords, dims=boundary_dims)
|
|
116
|
+
|
|
117
|
+
# Determine has_investment and cap_value
|
|
118
|
+
has_investment = isinstance(capacity_param, InvestParameters)
|
|
119
|
+
using_default_bound = False
|
|
120
|
+
|
|
121
|
+
if isinstance(capacity_param, InvestParameters):
|
|
122
|
+
if capacity_param.fixed_size is not None:
|
|
123
|
+
cap_value = capacity_param.fixed_size
|
|
124
|
+
elif capacity_param.maximum_size is not None:
|
|
125
|
+
cap_value = capacity_param.maximum_size
|
|
126
|
+
else:
|
|
127
|
+
cap_value = DEFAULT_UNBOUNDED_CAPACITY
|
|
128
|
+
using_default_bound = True
|
|
129
|
+
elif isinstance(capacity_param, (int, float)):
|
|
130
|
+
cap_value = capacity_param
|
|
131
|
+
else:
|
|
132
|
+
cap_value = DEFAULT_UNBOUNDED_CAPACITY
|
|
133
|
+
using_default_bound = True
|
|
134
|
+
|
|
135
|
+
if using_default_bound:
|
|
136
|
+
logger.warning(
|
|
137
|
+
f'No explicit capacity bound provided for inter-cluster storage linking. '
|
|
138
|
+
f'Using default upper bound of {DEFAULT_UNBOUNDED_CAPACITY:.0e}. '
|
|
139
|
+
f'Consider setting capacity_in_flow_hours or InvestParameters.maximum_size explicitly.'
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Build upper bound
|
|
143
|
+
if isinstance(cap_value, xr.DataArray) and cap_value.dims:
|
|
144
|
+
ub = cap_value.expand_dims({'cluster_boundary': n_boundaries}, axis=0)
|
|
145
|
+
ub = ub.assign_coords(cluster_boundary=np.arange(n_boundaries))
|
|
146
|
+
ub = ub.transpose('cluster_boundary', ...)
|
|
147
|
+
else:
|
|
148
|
+
if hasattr(cap_value, 'item'):
|
|
149
|
+
cap_value = float(cap_value.item())
|
|
150
|
+
else:
|
|
151
|
+
cap_value = float(cap_value)
|
|
152
|
+
ub = xr.DataArray(np.full(lb_shape, cap_value), coords=boundary_coords, dims=boundary_dims)
|
|
153
|
+
|
|
154
|
+
return CapacityBounds(lower=lb, upper=ub, has_investment=has_investment)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_boundary_coords(
|
|
158
|
+
n_original_clusters: int,
|
|
159
|
+
flow_system: FlowSystem,
|
|
160
|
+
) -> tuple[dict, list[str]]:
|
|
161
|
+
"""Build coordinates and dimensions for SOC_boundary variable.
|
|
162
|
+
|
|
163
|
+
Creates the coordinate dictionary and dimension list needed to create the
|
|
164
|
+
SOC_boundary variable. The primary dimension is 'cluster_boundary' with
|
|
165
|
+
N+1 values (one for each boundary between N original periods).
|
|
166
|
+
|
|
167
|
+
Additional dimensions (period, scenario) are included if present in the
|
|
168
|
+
FlowSystem, ensuring the SOC_boundary variable has the correct shape for
|
|
169
|
+
multi-period or stochastic optimizations.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
n_original_clusters: Number of original (non-aggregated) time periods.
|
|
173
|
+
For example, if a year is clustered into 8 typical days but originally
|
|
174
|
+
had 365 days, this would be 365.
|
|
175
|
+
flow_system: The FlowSystem containing optional period/scenario dimensions.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Tuple of (coords, dims) where:
|
|
179
|
+
- coords: Dictionary mapping dimension names to coordinate arrays
|
|
180
|
+
- dims: List of dimension names in order
|
|
181
|
+
|
|
182
|
+
Example:
|
|
183
|
+
>>> coords, dims = build_boundary_coords(14, flow_system)
|
|
184
|
+
>>> dims
|
|
185
|
+
['cluster_boundary'] # or ['cluster_boundary', 'period'] if periods exist
|
|
186
|
+
>>> coords['cluster_boundary']
|
|
187
|
+
array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14])
|
|
188
|
+
"""
|
|
189
|
+
n_boundaries = n_original_clusters + 1
|
|
190
|
+
coords = {'cluster_boundary': np.arange(n_boundaries)}
|
|
191
|
+
dims = ['cluster_boundary']
|
|
192
|
+
|
|
193
|
+
if flow_system.periods is not None:
|
|
194
|
+
dims.append('period')
|
|
195
|
+
coords['period'] = np.array(list(flow_system.periods))
|
|
196
|
+
|
|
197
|
+
if flow_system.scenarios is not None:
|
|
198
|
+
dims.append('scenario')
|
|
199
|
+
coords['scenario'] = np.array(list(flow_system.scenarios))
|
|
200
|
+
|
|
201
|
+
return coords, dims
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Simplified color handling for visualization.
|
|
2
|
+
|
|
3
|
+
This module provides clean color processing that transforms various input formats
|
|
4
|
+
into a label-to-color mapping dictionary, without needing to know about the plotting engine.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
import matplotlib.colors as mcolors
|
|
12
|
+
import matplotlib.pyplot as plt
|
|
13
|
+
import plotly.express as px
|
|
14
|
+
from plotly.exceptions import PlotlyError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger('flixopt')
|
|
17
|
+
|
|
18
|
+
# Type alias for flexible color input
|
|
19
|
+
ColorType = str | list[str] | dict[str, str]
|
|
20
|
+
"""Flexible color specification type supporting multiple input formats for visualization.
|
|
21
|
+
|
|
22
|
+
Color specifications can take several forms to accommodate different use cases:
|
|
23
|
+
|
|
24
|
+
**Named colorscales** (str):
|
|
25
|
+
- Standard colorscales: 'turbo', 'plasma', 'cividis', 'tab10', 'Set1'
|
|
26
|
+
- Energy-focused: 'portland' (custom flixopt colorscale for energy systems)
|
|
27
|
+
- Backend-specific maps available in Plotly and Matplotlib
|
|
28
|
+
|
|
29
|
+
**Color Lists** (list[str]):
|
|
30
|
+
- Explicit color sequences: ['red', 'blue', 'green', 'orange']
|
|
31
|
+
- HEX codes: ['#FF0000', '#0000FF', '#00FF00', '#FFA500']
|
|
32
|
+
- Mixed formats: ['red', '#0000FF', 'green', 'orange']
|
|
33
|
+
|
|
34
|
+
**Label-to-Color Mapping** (dict[str, str]):
|
|
35
|
+
- Explicit associations: {'Wind': 'skyblue', 'Solar': 'gold', 'Gas': 'brown'}
|
|
36
|
+
- Ensures consistent colors across different plots and datasets
|
|
37
|
+
- Ideal for energy system components with semantic meaning
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
```python
|
|
41
|
+
# Named colorscale
|
|
42
|
+
colors = 'turbo' # Automatic color generation
|
|
43
|
+
|
|
44
|
+
# Explicit color list
|
|
45
|
+
colors = ['red', 'blue', 'green', '#FFD700']
|
|
46
|
+
|
|
47
|
+
# Component-specific mapping
|
|
48
|
+
colors = {
|
|
49
|
+
'Wind_Turbine': 'skyblue',
|
|
50
|
+
'Solar_Panel': 'gold',
|
|
51
|
+
'Natural_Gas': 'brown',
|
|
52
|
+
'Battery': 'green',
|
|
53
|
+
'Electric_Load': 'darkred'
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Color Format Support:
|
|
58
|
+
- **Named Colors**: 'red', 'blue', 'forestgreen', 'darkorange'
|
|
59
|
+
- **HEX Codes**: '#FF0000', '#0000FF', '#228B22', '#FF8C00'
|
|
60
|
+
- **RGB Tuples**: (255, 0, 0), (0, 0, 255) [Matplotlib only]
|
|
61
|
+
- **RGBA**: 'rgba(255,0,0,0.8)' [Plotly only]
|
|
62
|
+
|
|
63
|
+
References:
|
|
64
|
+
- HTML Color Names: https://htmlcolorcodes.com/color-names/
|
|
65
|
+
- Matplotlib colorscales: https://matplotlib.org/stable/tutorials/colors/colorscales.html
|
|
66
|
+
- Plotly Built-in Colorscales: https://plotly.com/python/builtin-colorscales/
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rgb_string_to_hex(color: str) -> str:
|
|
71
|
+
"""Convert Plotly RGB/RGBA string format to hex.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
color: Color in format 'rgb(R, G, B)', 'rgba(R, G, B, A)' or already in hex
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Color in hex format '#RRGGBB'
|
|
78
|
+
"""
|
|
79
|
+
color = color.strip()
|
|
80
|
+
|
|
81
|
+
# If already hex, return as-is
|
|
82
|
+
if color.startswith('#'):
|
|
83
|
+
return color
|
|
84
|
+
|
|
85
|
+
# Try to parse rgb() or rgba()
|
|
86
|
+
try:
|
|
87
|
+
if color.startswith('rgb('):
|
|
88
|
+
# Extract RGB values from 'rgb(R, G, B)' format
|
|
89
|
+
rgb_str = color[4:-1] # Remove 'rgb(' and ')'
|
|
90
|
+
elif color.startswith('rgba('):
|
|
91
|
+
# Extract RGBA values from 'rgba(R, G, B, A)' format
|
|
92
|
+
rgb_str = color[5:-1] # Remove 'rgba(' and ')'
|
|
93
|
+
else:
|
|
94
|
+
return color
|
|
95
|
+
|
|
96
|
+
# Split on commas and parse first three components
|
|
97
|
+
components = rgb_str.split(',')
|
|
98
|
+
if len(components) < 3:
|
|
99
|
+
return color
|
|
100
|
+
|
|
101
|
+
# Parse and clamp the first three components
|
|
102
|
+
r = max(0, min(255, int(round(float(components[0].strip())))))
|
|
103
|
+
g = max(0, min(255, int(round(float(components[1].strip())))))
|
|
104
|
+
b = max(0, min(255, int(round(float(components[2].strip())))))
|
|
105
|
+
|
|
106
|
+
return f'#{r:02x}{g:02x}{b:02x}'
|
|
107
|
+
except (ValueError, IndexError):
|
|
108
|
+
# If parsing fails, return original
|
|
109
|
+
return color
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def color_to_rgba(color: str | None, alpha: float = 1.0) -> str:
|
|
113
|
+
"""Convert any valid color to RGBA string format.
|
|
114
|
+
|
|
115
|
+
Handles hex colors (with or without #), named colors, and rgb/rgba strings.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
color: Color in any valid format (hex '#FF0000' or 'FF0000',
|
|
119
|
+
named 'red', rgb 'rgb(255,0,0)', rgba 'rgba(255,0,0,1)').
|
|
120
|
+
alpha: Alpha/opacity value between 0.0 and 1.0.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Color in RGBA format 'rgba(R, G, B, A)'.
|
|
124
|
+
|
|
125
|
+
Examples:
|
|
126
|
+
>>> color_to_rgba('#FF0000')
|
|
127
|
+
'rgba(255, 0, 0, 1.0)'
|
|
128
|
+
>>> color_to_rgba('FF0000')
|
|
129
|
+
'rgba(255, 0, 0, 1.0)'
|
|
130
|
+
>>> color_to_rgba('red', 0.5)
|
|
131
|
+
'rgba(255, 0, 0, 0.5)'
|
|
132
|
+
>>> color_to_rgba('forestgreen', 0.4)
|
|
133
|
+
'rgba(34, 139, 34, 0.4)'
|
|
134
|
+
>>> color_to_rgba(None)
|
|
135
|
+
'rgba(200, 200, 200, 1.0)'
|
|
136
|
+
"""
|
|
137
|
+
if not color:
|
|
138
|
+
return f'rgba(200, 200, 200, {alpha})'
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
# Use matplotlib's robust color conversion (handles hex, named, etc.)
|
|
142
|
+
rgba = mcolors.to_rgba(color)
|
|
143
|
+
except ValueError:
|
|
144
|
+
# Try adding # prefix for bare hex colors (e.g., 'FF0000' -> '#FF0000')
|
|
145
|
+
if len(color) == 6 and all(c in '0123456789ABCDEFabcdef' for c in color):
|
|
146
|
+
try:
|
|
147
|
+
rgba = mcolors.to_rgba(f'#{color}')
|
|
148
|
+
except ValueError:
|
|
149
|
+
return f'rgba(200, 200, 200, {alpha})'
|
|
150
|
+
else:
|
|
151
|
+
return f'rgba(200, 200, 200, {alpha})'
|
|
152
|
+
except TypeError:
|
|
153
|
+
return f'rgba(200, 200, 200, {alpha})'
|
|
154
|
+
|
|
155
|
+
r = int(round(rgba[0] * 255))
|
|
156
|
+
g = int(round(rgba[1] * 255))
|
|
157
|
+
b = int(round(rgba[2] * 255))
|
|
158
|
+
return f'rgba({r}, {g}, {b}, {alpha})'
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Alias for backwards compatibility
|
|
162
|
+
hex_to_rgba = color_to_rgba
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def process_colors(
|
|
166
|
+
colors: None | str | list[str] | dict[str, str],
|
|
167
|
+
labels: list[str],
|
|
168
|
+
default_colorscale: str | None = None,
|
|
169
|
+
) -> dict[str, str]:
|
|
170
|
+
"""Process color input and return a label-to-color mapping.
|
|
171
|
+
|
|
172
|
+
This function takes flexible color input and always returns a dictionary
|
|
173
|
+
mapping each label to a specific color string. The plotting engine can then
|
|
174
|
+
use this mapping as needed.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
colors: Color specification in one of four formats:
|
|
178
|
+
- None: Use the default colorscale
|
|
179
|
+
- str: Name of a colorscale (e.g., 'turbo', 'plasma', 'Set1', 'portland')
|
|
180
|
+
- list[str]: List of color strings (hex, named colors, etc.)
|
|
181
|
+
- dict[str, str]: Direct label-to-color mapping
|
|
182
|
+
labels: List of labels that need colors assigned
|
|
183
|
+
default_colorscale: Fallback colorscale name if requested scale not found.
|
|
184
|
+
Defaults to CONFIG.Plotting.default_qualitative_colorscale.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Dictionary mapping each label to a color string
|
|
188
|
+
|
|
189
|
+
Examples:
|
|
190
|
+
>>> # Using None - applies default colorscale
|
|
191
|
+
>>> process_colors(None, ['A', 'B', 'C'])
|
|
192
|
+
{'A': '#0d0887', 'B': '#7e03a8', 'C': '#cc4778'}
|
|
193
|
+
|
|
194
|
+
>>> # Using a colorscale name
|
|
195
|
+
>>> process_colors('plasma', ['A', 'B', 'C'])
|
|
196
|
+
{'A': '#0d0887', 'B': '#7e03a8', 'C': '#cc4778'}
|
|
197
|
+
|
|
198
|
+
>>> # Using a list of colors
|
|
199
|
+
>>> process_colors(['red', 'blue', 'green'], ['A', 'B', 'C'])
|
|
200
|
+
{'A': 'red', 'B': 'blue', 'C': 'green'}
|
|
201
|
+
|
|
202
|
+
>>> # Using a pre-made mapping
|
|
203
|
+
>>> process_colors({'A': 'red', 'B': 'blue'}, ['A', 'B', 'C'])
|
|
204
|
+
{'A': 'red', 'B': 'blue', 'C': '#0d0887'} # C gets color from default scale
|
|
205
|
+
"""
|
|
206
|
+
if not labels:
|
|
207
|
+
return {}
|
|
208
|
+
|
|
209
|
+
# Resolve default colorscale from CONFIG if not provided
|
|
210
|
+
if default_colorscale is None:
|
|
211
|
+
from .config import CONFIG
|
|
212
|
+
|
|
213
|
+
default_colorscale = CONFIG.Plotting.default_qualitative_colorscale
|
|
214
|
+
|
|
215
|
+
# Case 1: Already a mapping dictionary
|
|
216
|
+
if isinstance(colors, dict):
|
|
217
|
+
return _fill_missing_colors(colors, labels, default_colorscale)
|
|
218
|
+
|
|
219
|
+
# Case 2: None or colorscale name (string)
|
|
220
|
+
if colors is None or isinstance(colors, str):
|
|
221
|
+
colorscale_name = colors if colors is not None else default_colorscale
|
|
222
|
+
color_list = _get_colors_from_scale(colorscale_name, len(labels), default_colorscale)
|
|
223
|
+
return dict(zip(labels, color_list, strict=False))
|
|
224
|
+
|
|
225
|
+
# Case 3: List of colors
|
|
226
|
+
if isinstance(colors, list):
|
|
227
|
+
if len(colors) == 0:
|
|
228
|
+
logger.warning(f'Empty color list provided. Using {default_colorscale} instead.')
|
|
229
|
+
color_list = _get_colors_from_scale(default_colorscale, len(labels), default_colorscale)
|
|
230
|
+
return dict(zip(labels, color_list, strict=False))
|
|
231
|
+
|
|
232
|
+
if len(colors) < len(labels):
|
|
233
|
+
logger.debug(
|
|
234
|
+
f'Not enough colors provided ({len(colors)}) for all labels ({len(labels)}). Colors will cycle.'
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Cycle through colors if we don't have enough
|
|
238
|
+
return {label: colors[i % len(colors)] for i, label in enumerate(labels)}
|
|
239
|
+
|
|
240
|
+
raise TypeError(f'colors must be None, str, list, or dict, got {type(colors)}')
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _fill_missing_colors(
|
|
244
|
+
color_mapping: dict[str, str],
|
|
245
|
+
labels: list[str],
|
|
246
|
+
default_colorscale: str,
|
|
247
|
+
) -> dict[str, str]:
|
|
248
|
+
"""Fill in missing labels in a color mapping using a colorscale.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
color_mapping: Partial label-to-color mapping
|
|
252
|
+
labels: All labels that need colors
|
|
253
|
+
default_colorscale: Colorscale to use for missing labels
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Complete label-to-color mapping
|
|
257
|
+
"""
|
|
258
|
+
missing_labels = [label for label in labels if label not in color_mapping]
|
|
259
|
+
|
|
260
|
+
if not missing_labels:
|
|
261
|
+
return color_mapping.copy()
|
|
262
|
+
|
|
263
|
+
# Log warning about missing labels
|
|
264
|
+
logger.debug(f'Labels missing colors: {missing_labels}. Using {default_colorscale} for these.')
|
|
265
|
+
|
|
266
|
+
# Get colors for missing labels
|
|
267
|
+
missing_colors = _get_colors_from_scale(default_colorscale, len(missing_labels), default_colorscale)
|
|
268
|
+
|
|
269
|
+
# Combine existing and new colors
|
|
270
|
+
result = color_mapping.copy()
|
|
271
|
+
result.update(dict(zip(missing_labels, missing_colors, strict=False)))
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _get_colors_from_scale(
|
|
276
|
+
colorscale_name: str,
|
|
277
|
+
num_colors: int,
|
|
278
|
+
fallback_scale: str,
|
|
279
|
+
) -> list[str]:
|
|
280
|
+
"""Extract a list of colors from a named colorscale.
|
|
281
|
+
|
|
282
|
+
Tries to get colors from the named scale (Plotly first, then Matplotlib),
|
|
283
|
+
falls back to the fallback scale if not found.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
colorscale_name: Name of the colorscale to try
|
|
287
|
+
num_colors: Number of colors needed
|
|
288
|
+
fallback_scale: Fallback colorscale name if first fails
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
List of color strings (hex format)
|
|
292
|
+
"""
|
|
293
|
+
# Try to get the requested colorscale
|
|
294
|
+
colors = _try_get_colorscale(colorscale_name, num_colors)
|
|
295
|
+
|
|
296
|
+
if colors is not None:
|
|
297
|
+
return colors
|
|
298
|
+
|
|
299
|
+
# Fallback to default
|
|
300
|
+
logger.warning(f"Colorscale '{colorscale_name}' not found. Using '{fallback_scale}' instead.")
|
|
301
|
+
|
|
302
|
+
colors = _try_get_colorscale(fallback_scale, num_colors)
|
|
303
|
+
|
|
304
|
+
if colors is not None:
|
|
305
|
+
return colors
|
|
306
|
+
|
|
307
|
+
# Ultimate fallback: just use basic colors
|
|
308
|
+
logger.warning(f"Fallback colorscale '{fallback_scale}' also not found. Using basic colors.")
|
|
309
|
+
basic_colors = [
|
|
310
|
+
'#1f77b4',
|
|
311
|
+
'#ff7f0e',
|
|
312
|
+
'#2ca02c',
|
|
313
|
+
'#d62728',
|
|
314
|
+
'#9467bd',
|
|
315
|
+
'#8c564b',
|
|
316
|
+
'#e377c2',
|
|
317
|
+
'#7f7f7f',
|
|
318
|
+
'#bcbd22',
|
|
319
|
+
'#17becf',
|
|
320
|
+
]
|
|
321
|
+
return [basic_colors[i % len(basic_colors)] for i in range(num_colors)]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _try_get_colorscale(colorscale_name: str, num_colors: int) -> list[str] | None:
|
|
325
|
+
"""Try to get colors from Plotly or Matplotlib colorscales.
|
|
326
|
+
|
|
327
|
+
Tries Plotly colorscales first (both qualitative and sequential),
|
|
328
|
+
then falls back to Matplotlib colorscales.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
colorscale_name: Name of the colorscale
|
|
332
|
+
num_colors: Number of colors needed
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
List of color strings (hex format) if successful, None if colorscale not found
|
|
336
|
+
"""
|
|
337
|
+
# First try Plotly qualitative (discrete) color sequences
|
|
338
|
+
colorscale_title = colorscale_name.title()
|
|
339
|
+
if hasattr(px.colors.qualitative, colorscale_title):
|
|
340
|
+
color_list = getattr(px.colors.qualitative, colorscale_title)
|
|
341
|
+
# Convert to hex format for matplotlib compatibility
|
|
342
|
+
return [_rgb_string_to_hex(color_list[i % len(color_list)]) for i in range(num_colors)]
|
|
343
|
+
|
|
344
|
+
# Then try Plotly sequential/continuous colorscales
|
|
345
|
+
try:
|
|
346
|
+
colorscale = px.colors.get_colorscale(colorscale_name)
|
|
347
|
+
# Sample evenly from the colorscale
|
|
348
|
+
if num_colors == 1:
|
|
349
|
+
sample_points = [0.5]
|
|
350
|
+
else:
|
|
351
|
+
sample_points = [i / (num_colors - 1) for i in range(num_colors)]
|
|
352
|
+
colors = px.colors.sample_colorscale(colorscale, sample_points)
|
|
353
|
+
# Convert to hex format for matplotlib compatibility
|
|
354
|
+
return [_rgb_string_to_hex(c) for c in colors]
|
|
355
|
+
except (PlotlyError, ValueError):
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
# Finally try Matplotlib colorscales
|
|
359
|
+
try:
|
|
360
|
+
cmap = plt.get_cmap(colorscale_name)
|
|
361
|
+
|
|
362
|
+
# Sample evenly from the colorscale
|
|
363
|
+
if num_colors == 1:
|
|
364
|
+
colors = [cmap(0.5)]
|
|
365
|
+
else:
|
|
366
|
+
colors = [cmap(i / (num_colors - 1)) for i in range(num_colors)]
|
|
367
|
+
|
|
368
|
+
# Convert RGBA tuples to hex strings
|
|
369
|
+
return [mcolors.rgb2hex(color[:3]) for color in colors]
|
|
370
|
+
|
|
371
|
+
except (ValueError, KeyError):
|
|
372
|
+
return None
|