Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5fc4dca
Add new config options for plotting
FBumann Oct 24, 2025
3edcf40
Use turbo instead of viridis
FBumann Oct 24, 2025
9ce213c
Update plotting.py to use updated color management
FBumann Oct 24, 2025
dfa385f
update color management
FBumann Oct 24, 2025
2346759
Add rgb to hex for matplotlib
FBumann Oct 24, 2025
acdf93d
Add rgb to hex for matplotlib
FBumann Oct 24, 2025
5c24d25
Remove colormanager class
FBumann Oct 24, 2025
e7b0a1e
Update type hints
FBumann Oct 24, 2025
cabe8be
Update type hints and use Config defaults
FBumann Oct 24, 2025
94c16ba
Add stable colors
FBumann Oct 24, 2025
3465005
V1
FBumann Oct 24, 2025
f2848fc
V2
FBumann Oct 24, 2025
2bc0624
Use calculation.colors if direct colors is None
FBumann Oct 24, 2025
472cf1c
Bugfix
FBumann Oct 24, 2025
7f790e4
Bugfix
FBumann Oct 24, 2025
72b2a2c
Update setup_colors
FBumann Oct 24, 2025
3fcdbff
Add color setup to examples
FBumann Oct 24, 2025
4740763
Final touches
FBumann Oct 24, 2025
664e8ff
Update CHANGELOG.md
FBumann Oct 24, 2025
f6c721b
Update CHANGELOG.md
FBumann Oct 24, 2025
59c399f
Bugfix
FBumann Oct 24, 2025
0fd989b
Update fro SegmentedCalculationResults
FBumann Oct 24, 2025
9a7b8d7
Default show = False in tests
FBumann Oct 24, 2025
c1622ff
Bugfix
FBumann Oct 24, 2025
bff1ad6
Bugfix
FBumann Oct 24, 2025
4e64f52
Add show default to plot_network
FBumann Oct 24, 2025
8d458b7
Make _rgb_string_to_hex more robust
FBumann Oct 24, 2025
9145cce
Improve Error Handling
FBumann Oct 24, 2025
8822cd6
Overwrite colors explicitly in setup_colors
FBumann Oct 24, 2025
e94a61c
Improve config loader
FBumann Oct 24, 2025
e697ac0
Update CHANGELOG.md
FBumann Oct 24, 2025
a36ce89
Make colors arg always overwrite the default behaviour
FBumann Oct 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,39 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp

### ✨ Added
- Support for plotting kwargs in `results.py`, passed to plotly express and matplotlib.
- **Color management system**: New `color_processing.py` module with `process_colors()` function for unified color handling across plotting backends
- Supports flexible color inputs: colorscale names (e.g., 'turbo', 'plasma'), color lists, and label-to-color dictionaries
- Automatic fallback handling when requested colorscales are unavailable
- Seamless integration with both Plotly and Matplotlib colorscales
- Automatic rgba→hex color conversion for Matplotlib compatibility
- **Component color grouping**: Added `setup_colors()` method to `CalculationResults` and `SegmentedCalculationResults` to create color mappings with similar colors for all variables of a component
- Allows grouping components by custom colorscales: `{'CHP': 'red', 'Greys': ['Gastarif', 'Einspeisung'], 'Storage': 'blue'}`
- Colors are automatically assigned using default colorscale if not specified
- For segmented calculations, colors are propagated to all segments for consistent visualization
- Explicit `colors` arguments in plot methods override configured colors (when provided)
- **Plotting configuration**: New `CONFIG.Plotting` section with extensive customization options:
- `default_show`: Control default visibility of plots
- `default_engine`: Choose between 'plotly' or 'matplotlib'
- `default_dpi`: Configure resolution for saved plots (with matplotlib)
- `default_facet_cols`: Set default columns for faceted plots
- `default_sequential_colorscale`: Default for heatmaps and continuous data (default: 'turbo')
- `default_qualitative_colorscale`: Default for categorical plots (default: 'plotly')

### 💥 Breaking Changes

### ♻️ Changed
- **Template integration**: Plotly templates now fully control plot styling without hardcoded overrides
- **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.
- **Color terminology**: Standardized terminology from "colormap" to "colorscale" throughout the codebase for consistency with Plotly conventions
- **Default colorscales**: Changed default sequential colorscale from 'viridis' to 'turbo' for better perceptual uniformity; qualitative colorscale now defaults to 'plotly'
- **Aggregation plotting**: `Aggregation.plot()` now respects `CONFIG.Plotting.default_qualitative_colorscale` and uses `process_colors()` for consistent color handling

### 🗑️ Deprecated

### 🔥 Removed
- Removed `plotting.pie_with_plotly()` method as it was not used
- Removed `plotting.pie_with_plotly()` method as it was not used
- Removed `ColorProcessor` class - replaced by simpler `process_colors()` function
- Removed `resolve_colors()` helper function - color resolution now handled directly by `process_colors()`

### 🐛 Fixed
- Improved error messages for `engine='matplotlib'` with multidimensional data
Expand All @@ -76,9 +98,15 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp

### 📝 Docs
- Moved `linked_periods` into correct section of the docstring (was in deprecated params)
- Updated terminology in docstrings from "colormap" to "colorscale" for consistency
- Enhanced examples to demonstrate `setup_colors()` usage:
- `simple_example.py`: Shows automatic color assignment and optional custom configuration
- `scenario_example.py`: Demonstrates component grouping with custom colorscales

### 👷 Development
- Fixed concurrency issue in CI
- **Code architecture**: Extracted color processing logic into dedicated `color_processing.py` module for better separation of concerns
- Refactored from class-based (`ColorProcessor`) to function-based color handling for simpler API and reduced complexity

### 🚧 Known Issues

Expand Down
3 changes: 3 additions & 0 deletions examples/01_Simple/simple_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@
calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30))

# --- Analyze Results ---
# Colors are automatically assigned using default colormap
# Optional: Configure custom colors with
calculation.results.setup_colors()
calculation.results['Fernwärme'].plot_node_balance_pie()
calculation.results['Fernwärme'].plot_node_balance()
calculation.results['Storage'].plot_charge_state()
Expand Down
9 changes: 9 additions & 0 deletions examples/04_Scenarios/scenario_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,15 @@
# --- Solve the Calculation and Save Results ---
calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30))

calculation.results.setup_colors(
{
'CHP': 'red',
'Greys': ['Gastarif', 'Einspeisung', 'Heat Demand'],
'Storage': 'blue',
'Boiler': 'orange',
}
)

calculation.results.plot_heatmap('CHP(Q_th)|flow_rate')

# --- Analyze Results ---
Expand Down
11 changes: 8 additions & 3 deletions flixopt/aggregation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
except ImportError:
TSAM_AVAILABLE = False

from .color_processing import process_colors
from .components import Storage
from .config import CONFIG
from .structure import (
FlowSystemModel,
Submodel,
Expand Down Expand Up @@ -141,7 +143,7 @@ def describe_clusters(self) -> str:
def use_extreme_periods(self):
return self.time_series_for_high_peaks or self.time_series_for_low_peaks

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

df_org = self.original_data.copy().rename(
Expand All @@ -150,10 +152,13 @@ def plot(self, colormap: str = 'viridis', show: bool = True, save: pathlib.Path
df_agg = self.aggregated_data.copy().rename(
columns={col: f'Aggregated - {col}' for col in self.aggregated_data.columns}
)
fig = plotting.with_plotly(df_org.to_xarray(), 'line', colors=colormap, xlabel='Time in h')
colors = list(
process_colors(colormap or CONFIG.Plotting.default_qualitative_colorscale, list(df_org.columns)).values()
)
fig = plotting.with_plotly(df_org.to_xarray(), 'line', colors=colors, xlabel='Time in h')
for trace in fig.data:
trace.update(dict(line=dict(dash='dash')))
fig2 = plotting.with_plotly(df_agg.to_xarray(), 'line', colors=colormap, xlabel='Time in h')
fig2 = plotting.with_plotly(df_agg.to_xarray(), 'line', colors=colors, xlabel='Time in h')
for trace in fig2.data:
fig.add_trace(trace)

Expand Down
261 changes: 261 additions & 0 deletions flixopt/color_processing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
"""Simplified color handling for visualization.

This module provides clean color processing that transforms various input formats
into a label-to-color mapping dictionary, without needing to know about the plotting engine.
"""

from __future__ import annotations

import logging

import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import plotly.express as px
from plotly.exceptions import PlotlyError

logger = logging.getLogger('flixopt')


def _rgb_string_to_hex(color: str) -> str:
"""Convert Plotly RGB/RGBA string format to hex.

Args:
color: Color in format 'rgb(R, G, B)', 'rgba(R, G, B, A)' or already in hex

Returns:
Color in hex format '#RRGGBB'
"""
color = color.strip()

# If already hex, return as-is
if color.startswith('#'):
return color

# Try to parse rgb() or rgba()
try:
if color.startswith('rgb('):
# Extract RGB values from 'rgb(R, G, B)' format
rgb_str = color[4:-1] # Remove 'rgb(' and ')'
elif color.startswith('rgba('):
# Extract RGBA values from 'rgba(R, G, B, A)' format
rgb_str = color[5:-1] # Remove 'rgba(' and ')'
else:
return color

# Split on commas and parse first three components
components = rgb_str.split(',')
if len(components) < 3:
return color

# Parse and clamp the first three components
r = max(0, min(255, int(round(float(components[0].strip())))))
g = max(0, min(255, int(round(float(components[1].strip())))))
b = max(0, min(255, int(round(float(components[2].strip())))))

return f'#{r:02x}{g:02x}{b:02x}'
except (ValueError, IndexError):
# If parsing fails, return original
return color


def process_colors(
colors: None | str | list[str] | dict[str, str],
labels: list[str],
default_colorscale: str = 'turbo',
) -> dict[str, str]:
"""Process color input and return a label-to-color mapping.

This function takes flexible color input and always returns a dictionary
mapping each label to a specific color string. The plotting engine can then
use this mapping as needed.

Args:
colors: Color specification in one of four formats:
- None: Use the default colorscale
- str: Name of a colorscale (e.g., 'turbo', 'plasma', 'Set1', 'portland')
- list[str]: List of color strings (hex, named colors, etc.)
- dict[str, str]: Direct label-to-color mapping
labels: List of labels that need colors assigned
default_colorscale: Fallback colorscale name if requested scale not found

Returns:
Dictionary mapping each label to a color string

Examples:
>>> # Using None - applies default colorscale
>>> process_colors(None, ['A', 'B', 'C'])
{'A': '#0d0887', 'B': '#7e03a8', 'C': '#cc4778'}

>>> # Using a colorscale name
>>> process_colors('plasma', ['A', 'B', 'C'])
{'A': '#0d0887', 'B': '#7e03a8', 'C': '#cc4778'}

>>> # Using a list of colors
>>> process_colors(['red', 'blue', 'green'], ['A', 'B', 'C'])
{'A': 'red', 'B': 'blue', 'C': 'green'}

>>> # Using a pre-made mapping
>>> process_colors({'A': 'red', 'B': 'blue'}, ['A', 'B', 'C'])
{'A': 'red', 'B': 'blue', 'C': '#0d0887'} # C gets color from default scale
"""
if not labels:
return {}

# Case 1: Already a mapping dictionary
if isinstance(colors, dict):
return _fill_missing_colors(colors, labels, default_colorscale)

# Case 2: None or colorscale name (string)
if colors is None or isinstance(colors, str):
colorscale_name = colors if colors is not None else default_colorscale
color_list = _get_colors_from_scale(colorscale_name, len(labels), default_colorscale)
return dict(zip(labels, color_list, strict=False))

# Case 3: List of colors
if isinstance(colors, list):
if len(colors) == 0:
logger.warning(f'Empty color list provided. Using {default_colorscale} instead.')
color_list = _get_colors_from_scale(default_colorscale, len(labels), default_colorscale)
return dict(zip(labels, color_list, strict=False))

if len(colors) < len(labels):
logger.debug(
f'Not enough colors provided ({len(colors)}) for all labels ({len(labels)}). Colors will cycle.'
)

# Cycle through colors if we don't have enough
return {label: colors[i % len(colors)] for i, label in enumerate(labels)}

raise TypeError(f'colors must be None, str, list, or dict, got {type(colors)}')


def _fill_missing_colors(
color_mapping: dict[str, str],
labels: list[str],
default_colorscale: str,
) -> dict[str, str]:
"""Fill in missing labels in a color mapping using a colorscale.

Args:
color_mapping: Partial label-to-color mapping
labels: All labels that need colors
default_colorscale: Colorscale to use for missing labels

Returns:
Complete label-to-color mapping
"""
missing_labels = [label for label in labels if label not in color_mapping]

if not missing_labels:
return color_mapping.copy()

# Log warning about missing labels
logger.debug(f'Labels missing colors: {missing_labels}. Using {default_colorscale} for these.')

# Get colors for missing labels
missing_colors = _get_colors_from_scale(default_colorscale, len(missing_labels), default_colorscale)

# Combine existing and new colors
result = color_mapping.copy()
result.update(dict(zip(missing_labels, missing_colors, strict=False)))
return result


def _get_colors_from_scale(
colorscale_name: str,
num_colors: int,
fallback_scale: str,
) -> list[str]:
"""Extract a list of colors from a named colorscale.

Tries to get colors from the named scale (Plotly first, then Matplotlib),
falls back to the fallback scale if not found.

Args:
colorscale_name: Name of the colorscale to try
num_colors: Number of colors needed
fallback_scale: Fallback colorscale name if first fails

Returns:
List of color strings (hex format)
"""
# Try to get the requested colorscale
colors = _try_get_colorscale(colorscale_name, num_colors)

if colors is not None:
return colors

# Fallback to default
logger.warning(f"Colorscale '{colorscale_name}' not found. Using '{fallback_scale}' instead.")

colors = _try_get_colorscale(fallback_scale, num_colors)

if colors is not None:
return colors

# Ultimate fallback: just use basic colors
logger.warning(f"Fallback colorscale '{fallback_scale}' also not found. Using basic colors.")
basic_colors = [
'#1f77b4',
'#ff7f0e',
'#2ca02c',
'#d62728',
'#9467bd',
'#8c564b',
'#e377c2',
'#7f7f7f',
'#bcbd22',
'#17becf',
]
return [basic_colors[i % len(basic_colors)] for i in range(num_colors)]


def _try_get_colorscale(colorscale_name: str, num_colors: int) -> list[str] | None:
"""Try to get colors from Plotly or Matplotlib colorscales.

Tries Plotly colorscales first (both qualitative and sequential),
then falls back to Matplotlib colorscales.

Args:
colorscale_name: Name of the colorscale
num_colors: Number of colors needed

Returns:
List of color strings (hex format) if successful, None if colorscale not found
"""
# First try Plotly qualitative (discrete) color sequences
colorscale_title = colorscale_name.title()
if hasattr(px.colors.qualitative, colorscale_title):
color_list = getattr(px.colors.qualitative, colorscale_title)
# Convert to hex format for matplotlib compatibility
return [_rgb_string_to_hex(color_list[i % len(color_list)]) for i in range(num_colors)]

# Then try Plotly sequential/continuous colorscales
try:
colorscale = px.colors.get_colorscale(colorscale_name)
# Sample evenly from the colorscale
if num_colors == 1:
sample_points = [0.5]
else:
sample_points = [i / (num_colors - 1) for i in range(num_colors)]
colors = px.colors.sample_colorscale(colorscale, sample_points)
# Convert to hex format for matplotlib compatibility
return [_rgb_string_to_hex(c) for c in colors]
except (PlotlyError, ValueError):
pass

# Finally try Matplotlib colorscales
try:
cmap = plt.get_cmap(colorscale_name)

# Sample evenly from the colorscale
if num_colors == 1:
colors = [cmap(0.5)]
else:
colors = [cmap(i / (num_colors - 1)) for i in range(num_colors)]

# Convert RGBA tuples to hex strings
return [mcolors.rgb2hex(color[:3]) for color in colors]

except (ValueError, KeyError):
return None
Loading