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.
Files changed (42) hide show
  1. flixopt/__init__.py +57 -49
  2. flixopt/carrier.py +159 -0
  3. flixopt/clustering/__init__.py +51 -0
  4. flixopt/clustering/base.py +1746 -0
  5. flixopt/clustering/intercluster_helpers.py +201 -0
  6. flixopt/color_processing.py +372 -0
  7. flixopt/comparison.py +819 -0
  8. flixopt/components.py +848 -270
  9. flixopt/config.py +853 -496
  10. flixopt/core.py +111 -98
  11. flixopt/effects.py +294 -284
  12. flixopt/elements.py +484 -223
  13. flixopt/features.py +220 -118
  14. flixopt/flow_system.py +2026 -389
  15. flixopt/interface.py +504 -286
  16. flixopt/io.py +1718 -55
  17. flixopt/linear_converters.py +291 -230
  18. flixopt/modeling.py +304 -181
  19. flixopt/network_app.py +2 -1
  20. flixopt/optimization.py +788 -0
  21. flixopt/optimize_accessor.py +373 -0
  22. flixopt/plot_result.py +143 -0
  23. flixopt/plotting.py +1177 -1034
  24. flixopt/results.py +1331 -372
  25. flixopt/solvers.py +12 -4
  26. flixopt/statistics_accessor.py +2412 -0
  27. flixopt/stats_accessor.py +75 -0
  28. flixopt/structure.py +954 -120
  29. flixopt/topology_accessor.py +676 -0
  30. flixopt/transform_accessor.py +2277 -0
  31. flixopt/types.py +120 -0
  32. flixopt-6.0.0rc7.dist-info/METADATA +290 -0
  33. flixopt-6.0.0rc7.dist-info/RECORD +36 -0
  34. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/WHEEL +1 -1
  35. flixopt/aggregation.py +0 -382
  36. flixopt/calculation.py +0 -672
  37. flixopt/commons.py +0 -51
  38. flixopt/utils.py +0 -86
  39. flixopt-3.0.1.dist-info/METADATA +0 -209
  40. flixopt-3.0.1.dist-info/RECORD +0 -26
  41. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/licenses/LICENSE +0 -0
  42. {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