Skip to content

Commit ae56e6c

Browse files
authored
Feature/component colors (#440)
* Add new config options for plotting * Use turbo instead of viridis * Update plotting.py to use updated color management * update color management * Add rgb to hex for matplotlib * Add rgb to hex for matplotlib * Remove colormanager class * Update type hints * Update type hints and use Config defaults * Add stable colors * V1 * V2 * Use calculation.colors if direct colors is None * Bugfix * Bugfix * Update setup_colors * Add color setup to examples * Final touches * Update CHANGELOG.md * Update CHANGELOG.md * Bugfix * Update fro SegmentedCalculationResults * Default show = False in tests * Bugfix * Bugfix * Add show default to plot_network * Make _rgb_string_to_hex more robust * Improve Error Handling * Overwrite colors explicitly in setup_colors * Improve config loader * Update CHANGELOG.md * Make colors arg always overwrite the default behaviour
1 parent feb0ced commit ae56e6c

11 files changed

Lines changed: 729 additions & 325 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,39 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp
5454
5555
### ✨ Added
5656
- Support for plotting kwargs in `results.py`, passed to plotly express and matplotlib.
57+
- **Color management system**: New `color_processing.py` module with `process_colors()` function for unified color handling across plotting backends
58+
- Supports flexible color inputs: colorscale names (e.g., 'turbo', 'plasma'), color lists, and label-to-color dictionaries
59+
- Automatic fallback handling when requested colorscales are unavailable
60+
- Seamless integration with both Plotly and Matplotlib colorscales
61+
- Automatic rgba→hex color conversion for Matplotlib compatibility
62+
- **Component color grouping**: Added `setup_colors()` method to `CalculationResults` and `SegmentedCalculationResults` to create color mappings with similar colors for all variables of a component
63+
- Allows grouping components by custom colorscales: `{'CHP': 'red', 'Greys': ['Gastarif', 'Einspeisung'], 'Storage': 'blue'}`
64+
- Colors are automatically assigned using default colorscale if not specified
65+
- For segmented calculations, colors are propagated to all segments for consistent visualization
66+
- Explicit `colors` arguments in plot methods override configured colors (when provided)
67+
- **Plotting configuration**: New `CONFIG.Plotting` section with extensive customization options:
68+
- `default_show`: Control default visibility of plots
69+
- `default_engine`: Choose between 'plotly' or 'matplotlib'
70+
- `default_dpi`: Configure resolution for saved plots (with matplotlib)
71+
- `default_facet_cols`: Set default columns for faceted plots
72+
- `default_sequential_colorscale`: Default for heatmaps and continuous data (default: 'turbo')
73+
- `default_qualitative_colorscale`: Default for categorical plots (default: 'plotly')
5774
5875
### 💥 Breaking Changes
5976
6077
### ♻️ Changed
6178
- **Template integration**: Plotly templates now fully control plot styling without hardcoded overrides
6279
- **Dataset first plotting**: Underlying plotting methods in `plotting.py` now use `xr.Dataset` as the main datatype. DataFrames are automatically converted via `_ensure_dataset()`. Both DataFrames and Datasets can be passed to plotting functions without code changes.
80+
- **Color terminology**: Standardized terminology from "colormap" to "colorscale" throughout the codebase for consistency with Plotly conventions
81+
- **Default colorscales**: Changed default sequential colorscale from 'viridis' to 'turbo' for better perceptual uniformity; qualitative colorscale now defaults to 'plotly'
82+
- **Aggregation plotting**: `Aggregation.plot()` now respects `CONFIG.Plotting.default_qualitative_colorscale` and uses `process_colors()` for consistent color handling
6383
6484
### 🗑️ Deprecated
6585
6686
### 🔥 Removed
67-
- Removed `plotting.pie_with_plotly()` method as it was not used
87+
- Removed `plotting.pie_with_plotly()` method as it was not used
88+
- Removed `ColorProcessor` class - replaced by simpler `process_colors()` function
89+
- Removed `resolve_colors()` helper function - color resolution now handled directly by `process_colors()`
6890
6991
### 🐛 Fixed
7092
- Improved error messages for `engine='matplotlib'` with multidimensional data
@@ -76,9 +98,15 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp
7698
7799
### 📝 Docs
78100
- Moved `linked_periods` into correct section of the docstring (was in deprecated params)
101+
- Updated terminology in docstrings from "colormap" to "colorscale" for consistency
102+
- Enhanced examples to demonstrate `setup_colors()` usage:
103+
- `simple_example.py`: Shows automatic color assignment and optional custom configuration
104+
- `scenario_example.py`: Demonstrates component grouping with custom colorscales
79105
80106
### 👷 Development
81107
- Fixed concurrency issue in CI
108+
- **Code architecture**: Extracted color processing logic into dedicated `color_processing.py` module for better separation of concerns
109+
- Refactored from class-based (`ColorProcessor`) to function-based color handling for simpler API and reduced complexity
82110
83111
### 🚧 Known Issues
84112

examples/01_Simple/simple_example.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@
112112
calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30))
113113

114114
# --- Analyze Results ---
115+
# Colors are automatically assigned using default colormap
116+
# Optional: Configure custom colors with
117+
calculation.results.setup_colors()
115118
calculation.results['Fernwärme'].plot_node_balance_pie()
116119
calculation.results['Fernwärme'].plot_node_balance()
117120
calculation.results['Storage'].plot_charge_state()

examples/04_Scenarios/scenario_example.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,15 @@
196196
# --- Solve the Calculation and Save Results ---
197197
calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30))
198198

199+
calculation.results.setup_colors(
200+
{
201+
'CHP': 'red',
202+
'Greys': ['Gastarif', 'Einspeisung', 'Heat Demand'],
203+
'Storage': 'blue',
204+
'Boiler': 'orange',
205+
}
206+
)
207+
199208
calculation.results.plot_heatmap('CHP(Q_th)|flow_rate')
200209

201210
# --- Analyze Results ---

flixopt/aggregation.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
except ImportError:
2121
TSAM_AVAILABLE = False
2222

23+
from .color_processing import process_colors
2324
from .components import Storage
25+
from .config import CONFIG
2426
from .structure import (
2527
FlowSystemModel,
2628
Submodel,
@@ -141,7 +143,7 @@ def describe_clusters(self) -> str:
141143
def use_extreme_periods(self):
142144
return self.time_series_for_high_peaks or self.time_series_for_low_peaks
143145

144-
def plot(self, colormap: str = 'viridis', show: bool = True, save: pathlib.Path | None = None) -> go.Figure:
146+
def plot(self, colormap: str | None = None, show: bool = True, save: pathlib.Path | None = None) -> go.Figure:
145147
from . import plotting
146148

147149
df_org = self.original_data.copy().rename(
@@ -150,10 +152,13 @@ def plot(self, colormap: str = 'viridis', show: bool = True, save: pathlib.Path
150152
df_agg = self.aggregated_data.copy().rename(
151153
columns={col: f'Aggregated - {col}' for col in self.aggregated_data.columns}
152154
)
153-
fig = plotting.with_plotly(df_org.to_xarray(), 'line', colors=colormap, xlabel='Time in h')
155+
colors = list(
156+
process_colors(colormap or CONFIG.Plotting.default_qualitative_colorscale, list(df_org.columns)).values()
157+
)
158+
fig = plotting.with_plotly(df_org.to_xarray(), 'line', colors=colors, xlabel='Time in h')
154159
for trace in fig.data:
155160
trace.update(dict(line=dict(dash='dash')))
156-
fig2 = plotting.with_plotly(df_agg.to_xarray(), 'line', colors=colormap, xlabel='Time in h')
161+
fig2 = plotting.with_plotly(df_agg.to_xarray(), 'line', colors=colors, xlabel='Time in h')
157162
for trace in fig2.data:
158163
fig.add_trace(trace)
159164

flixopt/color_processing.py

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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+
19+
def _rgb_string_to_hex(color: str) -> str:
20+
"""Convert Plotly RGB/RGBA string format to hex.
21+
22+
Args:
23+
color: Color in format 'rgb(R, G, B)', 'rgba(R, G, B, A)' or already in hex
24+
25+
Returns:
26+
Color in hex format '#RRGGBB'
27+
"""
28+
color = color.strip()
29+
30+
# If already hex, return as-is
31+
if color.startswith('#'):
32+
return color
33+
34+
# Try to parse rgb() or rgba()
35+
try:
36+
if color.startswith('rgb('):
37+
# Extract RGB values from 'rgb(R, G, B)' format
38+
rgb_str = color[4:-1] # Remove 'rgb(' and ')'
39+
elif color.startswith('rgba('):
40+
# Extract RGBA values from 'rgba(R, G, B, A)' format
41+
rgb_str = color[5:-1] # Remove 'rgba(' and ')'
42+
else:
43+
return color
44+
45+
# Split on commas and parse first three components
46+
components = rgb_str.split(',')
47+
if len(components) < 3:
48+
return color
49+
50+
# Parse and clamp the first three components
51+
r = max(0, min(255, int(round(float(components[0].strip())))))
52+
g = max(0, min(255, int(round(float(components[1].strip())))))
53+
b = max(0, min(255, int(round(float(components[2].strip())))))
54+
55+
return f'#{r:02x}{g:02x}{b:02x}'
56+
except (ValueError, IndexError):
57+
# If parsing fails, return original
58+
return color
59+
60+
61+
def process_colors(
62+
colors: None | str | list[str] | dict[str, str],
63+
labels: list[str],
64+
default_colorscale: str = 'turbo',
65+
) -> dict[str, str]:
66+
"""Process color input and return a label-to-color mapping.
67+
68+
This function takes flexible color input and always returns a dictionary
69+
mapping each label to a specific color string. The plotting engine can then
70+
use this mapping as needed.
71+
72+
Args:
73+
colors: Color specification in one of four formats:
74+
- None: Use the default colorscale
75+
- str: Name of a colorscale (e.g., 'turbo', 'plasma', 'Set1', 'portland')
76+
- list[str]: List of color strings (hex, named colors, etc.)
77+
- dict[str, str]: Direct label-to-color mapping
78+
labels: List of labels that need colors assigned
79+
default_colorscale: Fallback colorscale name if requested scale not found
80+
81+
Returns:
82+
Dictionary mapping each label to a color string
83+
84+
Examples:
85+
>>> # Using None - applies default colorscale
86+
>>> process_colors(None, ['A', 'B', 'C'])
87+
{'A': '#0d0887', 'B': '#7e03a8', 'C': '#cc4778'}
88+
89+
>>> # Using a colorscale name
90+
>>> process_colors('plasma', ['A', 'B', 'C'])
91+
{'A': '#0d0887', 'B': '#7e03a8', 'C': '#cc4778'}
92+
93+
>>> # Using a list of colors
94+
>>> process_colors(['red', 'blue', 'green'], ['A', 'B', 'C'])
95+
{'A': 'red', 'B': 'blue', 'C': 'green'}
96+
97+
>>> # Using a pre-made mapping
98+
>>> process_colors({'A': 'red', 'B': 'blue'}, ['A', 'B', 'C'])
99+
{'A': 'red', 'B': 'blue', 'C': '#0d0887'} # C gets color from default scale
100+
"""
101+
if not labels:
102+
return {}
103+
104+
# Case 1: Already a mapping dictionary
105+
if isinstance(colors, dict):
106+
return _fill_missing_colors(colors, labels, default_colorscale)
107+
108+
# Case 2: None or colorscale name (string)
109+
if colors is None or isinstance(colors, str):
110+
colorscale_name = colors if colors is not None else default_colorscale
111+
color_list = _get_colors_from_scale(colorscale_name, len(labels), default_colorscale)
112+
return dict(zip(labels, color_list, strict=False))
113+
114+
# Case 3: List of colors
115+
if isinstance(colors, list):
116+
if len(colors) == 0:
117+
logger.warning(f'Empty color list provided. Using {default_colorscale} instead.')
118+
color_list = _get_colors_from_scale(default_colorscale, len(labels), default_colorscale)
119+
return dict(zip(labels, color_list, strict=False))
120+
121+
if len(colors) < len(labels):
122+
logger.debug(
123+
f'Not enough colors provided ({len(colors)}) for all labels ({len(labels)}). Colors will cycle.'
124+
)
125+
126+
# Cycle through colors if we don't have enough
127+
return {label: colors[i % len(colors)] for i, label in enumerate(labels)}
128+
129+
raise TypeError(f'colors must be None, str, list, or dict, got {type(colors)}')
130+
131+
132+
def _fill_missing_colors(
133+
color_mapping: dict[str, str],
134+
labels: list[str],
135+
default_colorscale: str,
136+
) -> dict[str, str]:
137+
"""Fill in missing labels in a color mapping using a colorscale.
138+
139+
Args:
140+
color_mapping: Partial label-to-color mapping
141+
labels: All labels that need colors
142+
default_colorscale: Colorscale to use for missing labels
143+
144+
Returns:
145+
Complete label-to-color mapping
146+
"""
147+
missing_labels = [label for label in labels if label not in color_mapping]
148+
149+
if not missing_labels:
150+
return color_mapping.copy()
151+
152+
# Log warning about missing labels
153+
logger.debug(f'Labels missing colors: {missing_labels}. Using {default_colorscale} for these.')
154+
155+
# Get colors for missing labels
156+
missing_colors = _get_colors_from_scale(default_colorscale, len(missing_labels), default_colorscale)
157+
158+
# Combine existing and new colors
159+
result = color_mapping.copy()
160+
result.update(dict(zip(missing_labels, missing_colors, strict=False)))
161+
return result
162+
163+
164+
def _get_colors_from_scale(
165+
colorscale_name: str,
166+
num_colors: int,
167+
fallback_scale: str,
168+
) -> list[str]:
169+
"""Extract a list of colors from a named colorscale.
170+
171+
Tries to get colors from the named scale (Plotly first, then Matplotlib),
172+
falls back to the fallback scale if not found.
173+
174+
Args:
175+
colorscale_name: Name of the colorscale to try
176+
num_colors: Number of colors needed
177+
fallback_scale: Fallback colorscale name if first fails
178+
179+
Returns:
180+
List of color strings (hex format)
181+
"""
182+
# Try to get the requested colorscale
183+
colors = _try_get_colorscale(colorscale_name, num_colors)
184+
185+
if colors is not None:
186+
return colors
187+
188+
# Fallback to default
189+
logger.warning(f"Colorscale '{colorscale_name}' not found. Using '{fallback_scale}' instead.")
190+
191+
colors = _try_get_colorscale(fallback_scale, num_colors)
192+
193+
if colors is not None:
194+
return colors
195+
196+
# Ultimate fallback: just use basic colors
197+
logger.warning(f"Fallback colorscale '{fallback_scale}' also not found. Using basic colors.")
198+
basic_colors = [
199+
'#1f77b4',
200+
'#ff7f0e',
201+
'#2ca02c',
202+
'#d62728',
203+
'#9467bd',
204+
'#8c564b',
205+
'#e377c2',
206+
'#7f7f7f',
207+
'#bcbd22',
208+
'#17becf',
209+
]
210+
return [basic_colors[i % len(basic_colors)] for i in range(num_colors)]
211+
212+
213+
def _try_get_colorscale(colorscale_name: str, num_colors: int) -> list[str] | None:
214+
"""Try to get colors from Plotly or Matplotlib colorscales.
215+
216+
Tries Plotly colorscales first (both qualitative and sequential),
217+
then falls back to Matplotlib colorscales.
218+
219+
Args:
220+
colorscale_name: Name of the colorscale
221+
num_colors: Number of colors needed
222+
223+
Returns:
224+
List of color strings (hex format) if successful, None if colorscale not found
225+
"""
226+
# First try Plotly qualitative (discrete) color sequences
227+
colorscale_title = colorscale_name.title()
228+
if hasattr(px.colors.qualitative, colorscale_title):
229+
color_list = getattr(px.colors.qualitative, colorscale_title)
230+
# Convert to hex format for matplotlib compatibility
231+
return [_rgb_string_to_hex(color_list[i % len(color_list)]) for i in range(num_colors)]
232+
233+
# Then try Plotly sequential/continuous colorscales
234+
try:
235+
colorscale = px.colors.get_colorscale(colorscale_name)
236+
# Sample evenly from the colorscale
237+
if num_colors == 1:
238+
sample_points = [0.5]
239+
else:
240+
sample_points = [i / (num_colors - 1) for i in range(num_colors)]
241+
colors = px.colors.sample_colorscale(colorscale, sample_points)
242+
# Convert to hex format for matplotlib compatibility
243+
return [_rgb_string_to_hex(c) for c in colors]
244+
except (PlotlyError, ValueError):
245+
pass
246+
247+
# Finally try Matplotlib colorscales
248+
try:
249+
cmap = plt.get_cmap(colorscale_name)
250+
251+
# Sample evenly from the colorscale
252+
if num_colors == 1:
253+
colors = [cmap(0.5)]
254+
else:
255+
colors = [cmap(i / (num_colors - 1)) for i in range(num_colors)]
256+
257+
# Convert RGBA tuples to hex strings
258+
return [mcolors.rgb2hex(color[:3]) for color in colors]
259+
260+
except (ValueError, KeyError):
261+
return None

0 commit comments

Comments
 (0)