From 98a269dc9671703fd299d3e087934e056be0cc6c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 7 Dec 2025 14:43:04 +0100 Subject: [PATCH 1/7] Inline plotting methods to deprecate plotting.py --- flixopt/clustering.py | 29 ++++---- flixopt/statistics_accessor.py | 126 ++++++++++++++++++++++++++++++++- flixopt/topology_accessor.py | 84 ++++++++++++++++++++-- 3 files changed, 219 insertions(+), 20 deletions(-) diff --git a/flixopt/clustering.py b/flixopt/clustering.py index 1c6f7511b..3d049132d 100644 --- a/flixopt/clustering.py +++ b/flixopt/clustering.py @@ -7,7 +7,6 @@ import copy import logging -import pathlib import timeit from typing import TYPE_CHECKING @@ -29,6 +28,8 @@ ) if TYPE_CHECKING: + import pathlib + import linopy import pandas as pd import plotly.graph_objects as go @@ -145,7 +146,7 @@ def use_extreme_periods(self): return self.time_series_for_high_peaks or self.time_series_for_low_peaks def plot(self, colormap: str | None = None, show: bool = True, save: pathlib.Path | None = None) -> go.Figure: - from . import plotting + import plotly.express as px df_org = self.original_data.copy().rename( columns={col: f'Original - {col}' for col in self.original_data.columns} @@ -156,10 +157,16 @@ def plot(self, colormap: str | None = None, show: bool = True, save: pathlib.Pat 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') + + # Create line plot for original data (dashed) + df_org_long = df_org.reset_index().melt(id_vars='index', var_name='variable', value_name='value') + fig = px.line(df_org_long, x='index', y='value', color='variable', color_discrete_sequence=colors) for trace in fig.data: - trace.update(dict(line=dict(dash='dash'))) - fig2 = plotting.with_plotly(df_agg.to_xarray(), 'line', colors=colors, xlabel='Time in h') + trace.update(line=dict(dash='dash')) + + # Add aggregated data (solid lines) + df_agg_long = df_agg.reset_index().melt(id_vars='index', var_name='variable', value_name='value') + fig2 = px.line(df_agg_long, x='index', y='value', color='variable', color_discrete_sequence=colors) for trace in fig2.data: fig.add_trace(trace) @@ -169,14 +176,10 @@ def plot(self, colormap: str | None = None, show: bool = True, save: pathlib.Pat yaxis_title='Value', ) - plotting.export_figure( - figure_like=fig, - default_path=pathlib.Path('aggregated data.html'), - default_filetype='.html', - user_path=save, - show=show, - save=save is not None, - ) + if save is not None: + fig.write_html(str(save)) + if show: + fig.show() return fig diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index 9afcfd284..9a9cff17a 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -26,10 +26,10 @@ import numpy as np import pandas as pd +import plotly.express as px import plotly.graph_objects as go import xarray as xr -from . import plotting from .config import CONFIG if TYPE_CHECKING: @@ -46,6 +46,126 @@ FilterType = str | list[str] """For include/exclude filtering: 'Boiler' or ['Boiler', 'CHP']""" +ColorType = str | list[str] | None +"""Colorscale type for plots.""" + + +def _reshape_time_for_heatmap( + data: xr.DataArray, + reshape: tuple[str, str], + fill: Literal['ffill', 'bfill'] | None = 'ffill', +) -> xr.DataArray: + """Reshape time dimension into 2D (timeframe × timestep) for heatmap display. + + Args: + data: DataArray with 'time' dimension. + reshape: Tuple of (outer_freq, inner_freq), e.g. ('D', 'h') for days × hours. + fill: Method to fill missing values after resampling. + + Returns: + DataArray with 'time' replaced by 'timestep' and 'timeframe' dimensions. + """ + if 'time' not in data.dims: + return data + + timeframes, timesteps_per_frame = reshape + + # Define formats for different combinations + formats = { + ('YS', 'W'): ('%Y', '%W'), + ('YS', 'D'): ('%Y', '%j'), + ('YS', 'h'): ('%Y', '%j %H:00'), + ('MS', 'D'): ('%Y-%m', '%d'), + ('MS', 'h'): ('%Y-%m', '%d %H:00'), + ('W', 'D'): ('%Y-w%W', '%w_%A'), + ('W', 'h'): ('%Y-w%W', '%w_%A %H:00'), + ('D', 'h'): ('%Y-%m-%d', '%H:00'), + ('D', '15min'): ('%Y-%m-%d', '%H:%M'), + ('h', '15min'): ('%Y-%m-%d %H:00', '%M'), + ('h', 'min'): ('%Y-%m-%d %H:00', '%M'), + } + + format_pair = (timeframes, timesteps_per_frame) + if format_pair not in formats: + raise ValueError(f'{format_pair} is not a valid format. Choose from {list(formats.keys())}') + period_format, step_format = formats[format_pair] + + # Resample along time dimension + resampled = data.resample(time=timesteps_per_frame).mean() + + # Apply fill if specified + if fill == 'ffill': + resampled = resampled.ffill(dim='time') + elif fill == 'bfill': + resampled = resampled.bfill(dim='time') + + # Create period and step labels + time_values = pd.to_datetime(resampled.coords['time'].values) + period_labels = time_values.strftime(period_format) + step_labels = time_values.strftime(step_format) + + # Handle special case for weekly day format + if '%w_%A' in step_format: + step_labels = pd.Series(step_labels).replace('0_Sunday', '7_Sunday').values + + # Add period and step as coordinates + resampled = resampled.assign_coords({'timeframe': ('time', period_labels), 'timestep': ('time', step_labels)}) + + # Convert to multi-index and unstack + resampled = resampled.set_index(time=['timeframe', 'timestep']) + result = resampled.unstack('time') + + # Reorder: timestep, timeframe, then other dimensions + other_dims = [d for d in result.dims if d not in ['timestep', 'timeframe']] + return result.transpose('timestep', 'timeframe', *other_dims) + + +def _heatmap_figure( + data: xr.DataArray, + colors: ColorType = None, + title: str = '', + facet_col: str | None = None, + animation_frame: str | None = None, + facet_col_wrap: int | None = None, + **imshow_kwargs: Any, +) -> go.Figure: + """Create heatmap figure using px.imshow. + + Args: + data: DataArray with 2-4 dimensions. First two are heatmap axes. + colors: Colorscale name. + title: Plot title. + facet_col: Dimension for subplot columns. + animation_frame: Dimension for animation slider. + facet_col_wrap: Max columns before wrapping. + **imshow_kwargs: Additional args for px.imshow. + + Returns: + Plotly Figure. + """ + if data.size == 0: + return go.Figure() + + colors = colors or CONFIG.Plotting.default_sequential_colorscale + facet_col_wrap = facet_col_wrap or CONFIG.Plotting.default_facet_cols + + imshow_args: dict[str, Any] = { + 'img': data, + 'color_continuous_scale': colors, + 'title': title, + **imshow_kwargs, + } + + if facet_col and facet_col in data.dims: + imshow_args['facet_col'] = facet_col + if facet_col_wrap < data.sizes[facet_col]: + imshow_args['facet_col_wrap'] = facet_col_wrap + + if animation_frame and animation_frame in data.dims: + imshow_args['animation_frame'] = animation_frame + + return px.imshow(**imshow_args) + @dataclass class PlotResult: @@ -771,7 +891,7 @@ def heatmap( # Reshape time only if we wouldn't lose data (all extra dims fit in facet + animation) if reshape and 'time' in da.dims and not would_drop: - da = plotting.reshape_data_for_heatmap(da, reshape) + da = _reshape_time_for_heatmap(da, reshape) heatmap_dims = ['timestep', 'timeframe'] elif has_multiple_vars: # Can't reshape but have multiple vars: use variable + time as heatmap axes @@ -797,7 +917,7 @@ def heatmap( if has_multiple_vars: da = da.rename('') - fig = plotting.heatmap_with_plotly_v2( + fig = _heatmap_figure( da, colors=colorscale, facet_col=actual_facet, diff --git a/flixopt/topology_accessor.py b/flixopt/topology_accessor.py index 0df05afa2..b4e18eb08 100644 --- a/flixopt/topology_accessor.py +++ b/flixopt/topology_accessor.py @@ -8,13 +8,12 @@ from __future__ import annotations import logging +import pathlib import warnings from itertools import chain from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: - import pathlib - import pyvis from .flow_system import FlowSystem @@ -22,6 +21,84 @@ logger = logging.getLogger('flixopt') +def _plot_network( + node_infos: dict, + edge_infos: dict, + path: str | pathlib.Path | None = None, + controls: bool + | list[ + Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] + ] = True, + show: bool = False, +) -> pyvis.network.Network | None: + """Visualize network structure using PyVis. + + Args: + node_infos: Dictionary of node information. + edge_infos: Dictionary of edge information. + path: Path to save HTML visualization. + controls: UI controls to add. True for all, or list of specific controls. + show: Whether to open in browser. + + Returns: + Network instance, or None if pyvis not installed. + """ + try: + from pyvis.network import Network + except ImportError: + logger.critical("Plotting the flow system network was not possible. Please install pyvis: 'pip install pyvis'") + return None + + net = Network(directed=True, height='100%' if controls is False else '800px', font_color='white') + + for node_id, node in node_infos.items(): + net.add_node( + node_id, + label=node['label'], + shape={'Bus': 'circle', 'Component': 'box'}[node['class']], + color={'Bus': '#393E46', 'Component': '#00ADB5'}[node['class']], + title=node['infos'].replace(')', '\n)'), + font={'size': 14}, + ) + + for edge in edge_infos.values(): + net.add_edge( + edge['start'], + edge['end'], + label=edge['label'], + title=edge['infos'].replace(')', '\n)'), + font={'color': '#4D4D4D', 'size': 14}, + color='#222831', + ) + + net.barnes_hut(central_gravity=0.8, spring_length=50, spring_strength=0.05, gravity=-10000) + + if controls: + net.show_buttons(filter_=controls) + if not show and not path: + return net + elif path: + path = pathlib.Path(path) if isinstance(path, str) else path + net.write_html(path.as_posix()) + elif show: + path = pathlib.Path('network.html') + net.write_html(path.as_posix()) + + if show: + try: + import webbrowser + + worked = webbrowser.open(f'file://{path.resolve()}', 2) + if not worked: + logger.error(f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}') + except Exception as e: + logger.error( + f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}: {e}' + ) + + return net + + class TopologyAccessor: """ Accessor for network topology inspection and visualization on FlowSystem. @@ -136,11 +213,10 @@ def plot( Nodes are styled based on type (circles for buses, boxes for components) and annotated with node information. """ - from . import plotting from .config import CONFIG node_infos, edge_infos = self.infos() - return plotting.plot_network( + return _plot_network( node_infos, edge_infos, path, controls, show if show is not None else CONFIG.Plotting.default_show ) From c41b21258b084f7aa2ab23c4c3e19e28f7e8a24d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 7 Dec 2025 15:14:51 +0100 Subject: [PATCH 2/7] Fix test --- tests/test_solution_and_plotting.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_solution_and_plotting.py b/tests/test_solution_and_plotting.py index e302c4267..e5c96da33 100644 --- a/tests/test_solution_and_plotting.py +++ b/tests/test_solution_and_plotting.py @@ -349,10 +349,10 @@ def test_reshape_none_preserves_data(self, long_time_data): def test_heatmap_with_plotly_v2(self, long_time_data): """Test heatmap plotting with Plotly.""" - # Convert to Dataset for plotting - data = long_time_data.to_dataset(name='power') + # Reshape data first (heatmap_with_plotly_v2 requires pre-reshaped data) + reshaped = plotting.reshape_data_for_heatmap(long_time_data, reshape_time=('D', 'h')) - fig = plotting.heatmap_with_plotly_v2(data['power'], reshape_time=('D', 'h')) + fig = plotting.heatmap_with_plotly_v2(reshaped) assert fig is not None def test_heatmap_with_matplotlib(self, long_time_data): From d08dc520698ea9f43e09892f9a0cd539a3e056b6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 7 Dec 2025 15:27:23 +0100 Subject: [PATCH 3/7] Simplify Color Management --- flixopt/statistics_accessor.py | 62 ++++++++++++++-------------------- 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index 9a9cff17a..e7d0ec5fe 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -30,6 +30,7 @@ import plotly.graph_objects as go import xarray as xr +from .color_processing import process_colors from .config import CONFIG if TYPE_CHECKING: @@ -46,8 +47,8 @@ FilterType = str | list[str] """For include/exclude filtering: 'Boiler' or ['Boiler', 'CHP']""" -ColorType = str | list[str] | None -"""Colorscale type for plots.""" +ColorType = str | list[str] | dict[str, str] | None +"""Flexible color input: colorscale name, color list, label-to-color dict, or None for default.""" def _reshape_time_for_heatmap( @@ -270,21 +271,19 @@ def _dataset_to_long_df(ds: xr.Dataset, value_name: str = 'value', var_name: str def _create_stacked_bar( ds: xr.Dataset, - colors: dict[str, str] | None, + colors: ColorType, title: str, facet_col: str | None, facet_row: str | None, **plotly_kwargs: Any, ) -> go.Figure: """Create a stacked bar chart from xarray Dataset.""" - import plotly.express as px - df = _dataset_to_long_df(ds) if df.empty: return go.Figure() x_col = 'time' if 'time' in df.columns else df.columns[0] variables = df['variable'].unique().tolist() - color_map = {var: colors.get(var) for var in variables if colors and var in colors} or None + color_map = process_colors(colors, variables) fig = px.bar( df, x=x_col, @@ -303,21 +302,19 @@ def _create_stacked_bar( def _create_line( ds: xr.Dataset, - colors: dict[str, str] | None, + colors: ColorType, title: str, facet_col: str | None, facet_row: str | None, **plotly_kwargs: Any, ) -> go.Figure: """Create a line chart from xarray Dataset.""" - import plotly.express as px - df = _dataset_to_long_df(ds) if df.empty: return go.Figure() x_col = 'time' if 'time' in df.columns else df.columns[0] variables = df['variable'].unique().tolist() - color_map = {var: colors.get(var) for var in variables if colors and var in colors} or None + color_map = process_colors(colors, variables) return px.line( df, x=x_col, @@ -751,7 +748,7 @@ def balance( include: FilterType | None = None, exclude: FilterType | None = None, unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate', - colors: dict[str, str] | None = None, + colors: ColorType = None, facet_col: str | None = 'period', facet_row: str | None = 'scenario', show: bool | None = None, @@ -765,7 +762,7 @@ def balance( include: Only include flows containing these substrings. exclude: Exclude flows containing these substrings. unit: 'flow_rate' (power) or 'flow_hours' (energy). - colors: Color overrides for flows. + colors: Color specification (colorscale name, color list, or label-to-color dict). facet_col: Dimension for column facets. facet_row: Dimension for row facets. show: Whether to display the plot. @@ -828,7 +825,7 @@ def heatmap( *, select: SelectType | None = None, reshape: tuple[str, str] | None = ('D', 'h'), - colorscale: str = 'viridis', + colors: ColorType = None, facet_col: str | None = 'period', animation_frame: str | None = 'scenario', show: bool | None = None, @@ -845,7 +842,7 @@ def heatmap( select: xarray-style selection, e.g. {'scenario': 'Base Case'}. reshape: Time reshape frequencies as (outer, inner), e.g. ('D', 'h') for days × hours. Set to None to disable reshaping. - colorscale: Plotly colorscale name. + colors: Colorscale name (e.g., 'viridis', 'plasma') for heatmap coloring. facet_col: Dimension for subplot columns (default: 'period'). With multiple variables, 'variable' is used instead. animation_frame: Dimension for animation slider (default: 'scenario'). @@ -919,7 +916,7 @@ def heatmap( fig = _heatmap_figure( da, - colors=colorscale, + colors=colors, facet_col=actual_facet, animation_frame=actual_animation, **plotly_kwargs, @@ -941,7 +938,7 @@ def flows( component: str | list[str] | None = None, select: SelectType | None = None, unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate', - colors: dict[str, str] | None = None, + colors: ColorType = None, facet_col: str | None = 'period', facet_row: str | None = 'scenario', show: bool | None = None, @@ -955,7 +952,7 @@ def flows( component: Filter by parent component(s). select: xarray-style selection. unit: 'flow_rate' or 'flow_hours'. - colors: Color overrides. + colors: Color specification (colorscale name, color list, or label-to-color dict). facet_col: Dimension for column facets. facet_row: Dimension for row facets. show: Whether to display. @@ -1024,7 +1021,7 @@ def sankey( timestep: int | str | None = None, aggregate: Literal['sum', 'mean'] = 'sum', select: SelectType | None = None, - colors: dict[str, str] | None = None, + colors: ColorType = None, show: bool | None = None, **plotly_kwargs: Any, ) -> PlotResult: @@ -1034,7 +1031,7 @@ def sankey( timestep: Specific timestep to show, or None for aggregation. aggregate: How to aggregate if timestep is None. select: xarray-style selection. - colors: Color overrides for flows/nodes. + colors: Color specification for nodes (colorscale name, color list, or label-to-color dict). show: Whether to display. Returns: @@ -1099,11 +1096,8 @@ def sankey( node_list = list(nodes) node_indices = {n: i for i, n in enumerate(node_list)} - node_colors = [colors.get(node) if colors else None for node in node_list] - if any(node_colors): - node_colors = [c if c else 'lightgray' for c in node_colors] - else: - node_colors = None + color_map = process_colors(colors, node_list) + node_colors = [color_map[node] for node in node_list] fig = go.Figure( data=[ @@ -1139,7 +1133,7 @@ def sizes( *, max_size: float | None = 1e6, select: SelectType | None = None, - colors: dict[str, str] | None = None, + colors: ColorType = None, facet_col: str | None = 'period', facet_row: str | None = 'scenario', show: bool | None = None, @@ -1150,7 +1144,7 @@ def sizes( Args: max_size: Maximum size to include (filters defaults). select: xarray-style selection. - colors: Color overrides. + colors: Color specification (colorscale name, color list, or label-to-color dict). facet_col: Dimension for column facets. facet_row: Dimension for row facets. show: Whether to display. @@ -1158,8 +1152,6 @@ def sizes( Returns: PlotResult with size data. """ - import plotly.express as px - self._stats._require_solution() ds = self._stats.sizes @@ -1176,7 +1168,7 @@ def sizes( fig = go.Figure() else: variables = df['variable'].unique().tolist() - color_map = {var: colors.get(var) for var in variables if colors and var in colors} or None + color_map = process_colors(colors, variables) fig = px.bar( df, x='variable', @@ -1203,7 +1195,7 @@ def duration_curve( *, select: SelectType | None = None, normalize: bool = False, - colors: dict[str, str] | None = None, + colors: ColorType = None, facet_col: str | None = 'period', facet_row: str | None = 'scenario', show: bool | None = None, @@ -1216,7 +1208,7 @@ def duration_curve( Uses flow_rates from statistics. select: xarray-style selection. normalize: If True, normalize x-axis to 0-100%. - colors: Color overrides. + colors: Color specification (colorscale name, color list, or label-to-color dict). facet_col: Dimension for column facets. facet_row: Dimension for row facets. show: Whether to display. @@ -1282,7 +1274,7 @@ def effects( effect: str | None = None, by: Literal['component', 'contributor', 'time'] = 'component', select: SelectType | None = None, - colors: dict[str, str] | None = None, + colors: ColorType = None, facet_col: str | None = 'period', facet_row: str | None = 'scenario', show: bool | None = None, @@ -1296,7 +1288,7 @@ def effects( If None, plots all effects. by: Group by 'component', 'contributor' (individual flows), or 'time'. select: xarray-style selection. - colors: Override colors. + colors: Color specification (colorscale name, color list, or label-to-color dict). facet_col: Dimension for column facets (ignored if not in data). facet_row: Dimension for row facets (ignored if not in data). show: Whether to display. @@ -1310,8 +1302,6 @@ def effects( >>> flow_system.statistics.plot.effects(by='contributor') # By individual flows >>> flow_system.statistics.plot.effects(aspect='temporal', by='time') # Over time """ - import plotly.express as px - self._stats._require_solution() # Get the appropriate effects dataset based on aspect @@ -1387,7 +1377,7 @@ def effects( # Build color map if color_col and color_col in df.columns: color_items = df[color_col].unique().tolist() - color_map = {item: colors.get(item) for item in color_items if colors and item in colors} or None + color_map = process_colors(colors, color_items) else: color_map = None From 435ecd94dcef4fd7ce494a0ce250028dd877b124 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 7 Dec 2025 15:31:12 +0100 Subject: [PATCH 4/7] ColorType is now defined in color_processing.py and imported into statistics_accessor.py. --- flixopt/color_processing.py | 52 ++++++++++++++++++++++++++++++++++ flixopt/plotting.py | 52 +--------------------------------- flixopt/statistics_accessor.py | 5 +--- 3 files changed, 54 insertions(+), 55 deletions(-) diff --git a/flixopt/color_processing.py b/flixopt/color_processing.py index 2959acc82..f09a3927d 100644 --- a/flixopt/color_processing.py +++ b/flixopt/color_processing.py @@ -15,6 +15,58 @@ logger = logging.getLogger('flixopt') +# Type alias for flexible color input +ColorType = str | list[str] | dict[str, str] | None +ColorType = str | list[str] | dict[str, str] +"""Flexible color specification type supporting multiple input formats for visualization. + +Color specifications can take several forms to accommodate different use cases: + +**Named colorscales** (str): + - Standard colorscales: 'turbo', 'plasma', 'cividis', 'tab10', 'Set1' + - Energy-focused: 'portland' (custom flixopt colorscale for energy systems) + - Backend-specific maps available in Plotly and Matplotlib + +**Color Lists** (list[str]): + - Explicit color sequences: ['red', 'blue', 'green', 'orange'] + - HEX codes: ['#FF0000', '#0000FF', '#00FF00', '#FFA500'] + - Mixed formats: ['red', '#0000FF', 'green', 'orange'] + +**Label-to-Color Mapping** (dict[str, str]): + - Explicit associations: {'Wind': 'skyblue', 'Solar': 'gold', 'Gas': 'brown'} + - Ensures consistent colors across different plots and datasets + - Ideal for energy system components with semantic meaning + +Examples: + ```python + # Named colorscale + colors = 'turbo' # Automatic color generation + + # Explicit color list + colors = ['red', 'blue', 'green', '#FFD700'] + + # Component-specific mapping + colors = { + 'Wind_Turbine': 'skyblue', + 'Solar_Panel': 'gold', + 'Natural_Gas': 'brown', + 'Battery': 'green', + 'Electric_Load': 'darkred' + } + ``` + +Color Format Support: + - **Named Colors**: 'red', 'blue', 'forestgreen', 'darkorange' + - **HEX Codes**: '#FF0000', '#0000FF', '#228B22', '#FF8C00' + - **RGB Tuples**: (255, 0, 0), (0, 0, 255) [Matplotlib only] + - **RGBA**: 'rgba(255,0,0,0.8)' [Plotly only] + +References: + - HTML Color Names: https://htmlcolorcodes.com/color-names/ + - Matplotlib colorscales: https://matplotlib.org/stable/tutorials/colors/colorscales.html + - Plotly Built-in Colorscales: https://plotly.com/python/builtin-colorscales/ +""" + def _rgb_string_to_hex(color: str) -> str: """Convert Plotly RGB/RGBA string format to hex. diff --git a/flixopt/plotting.py b/flixopt/plotting.py index db78ca19b..db5a3eb5c 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -39,7 +39,7 @@ import plotly.offline import xarray as xr -from .color_processing import process_colors +from .color_processing import ColorType, process_colors from .config import CONFIG if TYPE_CHECKING: @@ -66,56 +66,6 @@ plt.register_cmap(name='portland', cmap=mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors)) -ColorType = str | list[str] | dict[str, str] -"""Flexible color specification type supporting multiple input formats for visualization. - -Color specifications can take several forms to accommodate different use cases: - -**Named colorscales** (str): - - Standard colorscales: 'turbo', 'plasma', 'cividis', 'tab10', 'Set1' - - Energy-focused: 'portland' (custom flixopt colorscale for energy systems) - - Backend-specific maps available in Plotly and Matplotlib - -**Color Lists** (list[str]): - - Explicit color sequences: ['red', 'blue', 'green', 'orange'] - - HEX codes: ['#FF0000', '#0000FF', '#00FF00', '#FFA500'] - - Mixed formats: ['red', '#0000FF', 'green', 'orange'] - -**Label-to-Color Mapping** (dict[str, str]): - - Explicit associations: {'Wind': 'skyblue', 'Solar': 'gold', 'Gas': 'brown'} - - Ensures consistent colors across different plots and datasets - - Ideal for energy system components with semantic meaning - -Examples: - ```python - # Named colorscale - colors = 'turbo' # Automatic color generation - - # Explicit color list - colors = ['red', 'blue', 'green', '#FFD700'] - - # Component-specific mapping - colors = { - 'Wind_Turbine': 'skyblue', - 'Solar_Panel': 'gold', - 'Natural_Gas': 'brown', - 'Battery': 'green', - 'Electric_Load': 'darkred' - } - ``` - -Color Format Support: - - **Named Colors**: 'red', 'blue', 'forestgreen', 'darkorange' - - **HEX Codes**: '#FF0000', '#0000FF', '#228B22', '#FF8C00' - - **RGB Tuples**: (255, 0, 0), (0, 0, 255) [Matplotlib only] - - **RGBA**: 'rgba(255,0,0,0.8)' [Plotly only] - -References: - - HTML Color Names: https://htmlcolorcodes.com/color-names/ - - Matplotlib colorscales: https://matplotlib.org/stable/tutorials/colors/colorscales.html - - Plotly Built-in Colorscales: https://plotly.com/python/builtin-colorscales/ -""" - PlottingEngine = Literal['plotly', 'matplotlib'] """Identifier for the plotting engine to use.""" diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index e7d0ec5fe..53ad5fa51 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -30,7 +30,7 @@ import plotly.graph_objects as go import xarray as xr -from .color_processing import process_colors +from .color_processing import ColorType, process_colors from .config import CONFIG if TYPE_CHECKING: @@ -47,9 +47,6 @@ FilterType = str | list[str] """For include/exclude filtering: 'Boiler' or ['Boiler', 'CHP']""" -ColorType = str | list[str] | dict[str, str] | None -"""Flexible color input: colorscale name, color list, label-to-color dict, or None for default.""" - def _reshape_time_for_heatmap( data: xr.DataArray, From 2c8eb4e33dcd3c51e4e94a501790a25284b2b9fa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 7 Dec 2025 15:50:09 +0100 Subject: [PATCH 5/7] Fix ColorType typing --- flixopt/color_processing.py | 1 - flixopt/statistics_accessor.py | 14 +++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/flixopt/color_processing.py b/flixopt/color_processing.py index f09a3927d..f6e9a3b9f 100644 --- a/flixopt/color_processing.py +++ b/flixopt/color_processing.py @@ -16,7 +16,6 @@ logger = logging.getLogger('flixopt') # Type alias for flexible color input -ColorType = str | list[str] | dict[str, str] | None ColorType = str | list[str] | dict[str, str] """Flexible color specification type supporting multiple input formats for visualization. diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index 53ad5fa51..eab6a7567 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -745,7 +745,7 @@ def balance( include: FilterType | None = None, exclude: FilterType | None = None, unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate', - colors: ColorType = None, + colors: ColorType | None = None, facet_col: str | None = 'period', facet_row: str | None = 'scenario', show: bool | None = None, @@ -822,7 +822,7 @@ def heatmap( *, select: SelectType | None = None, reshape: tuple[str, str] | None = ('D', 'h'), - colors: ColorType = None, + colors: ColorType | None = None, facet_col: str | None = 'period', animation_frame: str | None = 'scenario', show: bool | None = None, @@ -935,7 +935,7 @@ def flows( component: str | list[str] | None = None, select: SelectType | None = None, unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate', - colors: ColorType = None, + colors: ColorType | None = None, facet_col: str | None = 'period', facet_row: str | None = 'scenario', show: bool | None = None, @@ -1018,7 +1018,7 @@ def sankey( timestep: int | str | None = None, aggregate: Literal['sum', 'mean'] = 'sum', select: SelectType | None = None, - colors: ColorType = None, + colors: ColorType | None = None, show: bool | None = None, **plotly_kwargs: Any, ) -> PlotResult: @@ -1130,7 +1130,7 @@ def sizes( *, max_size: float | None = 1e6, select: SelectType | None = None, - colors: ColorType = None, + colors: ColorType | None = None, facet_col: str | None = 'period', facet_row: str | None = 'scenario', show: bool | None = None, @@ -1192,7 +1192,7 @@ def duration_curve( *, select: SelectType | None = None, normalize: bool = False, - colors: ColorType = None, + colors: ColorType | None = None, facet_col: str | None = 'period', facet_row: str | None = 'scenario', show: bool | None = None, @@ -1271,7 +1271,7 @@ def effects( effect: str | None = None, by: Literal['component', 'contributor', 'time'] = 'component', select: SelectType | None = None, - colors: ColorType = None, + colors: ColorType | None = None, facet_col: str | None = 'period', facet_row: str | None = 'scenario', show: bool | None = None, From bff873fea25d2805f8760458b86900d2687dd32c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 7 Dec 2025 17:22:42 +0100 Subject: [PATCH 6/7] statistics_accessor.py - Heatmap colors type safety (lines 121-148, 820-853) - Changed _heatmap_figure() parameter type from colors: ColorType = None to colors: str | list[str] | None = None - Changed heatmap() method parameter type similarly - Updated docstrings to clarify that dicts are not supported for heatmaps since px.imshow's color_continuous_scale only accepts colorscale names or lists 2. statistics_accessor.py - Use configured qualitative colorscale (lines 284, 315) - Updated _create_stacked_bar() to use CONFIG.Plotting.default_qualitative_colorscale as the default colorscale - Updated _create_line() similarly - This ensures user-configured CONFIG.Plotting.default_qualitative_colorscale affects all bar/line plots consistently 3. topology_accessor.py - Path type alignment (lines 219-222) - Added normalization of path=False to None before calling _plot_network() - This resolves the type mismatch where TopologyAccessor.plot() accepts bool | str | Path but _plot_network() only accepts str | Path | None --- flixopt/statistics_accessor.py | 14 ++++++++------ flixopt/topology_accessor.py | 8 +++++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index eab6a7567..9f6bb01be 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -120,7 +120,7 @@ def _reshape_time_for_heatmap( def _heatmap_figure( data: xr.DataArray, - colors: ColorType = None, + colors: str | list[str] | None = None, title: str = '', facet_col: str | None = None, animation_frame: str | None = None, @@ -131,7 +131,8 @@ def _heatmap_figure( Args: data: DataArray with 2-4 dimensions. First two are heatmap axes. - colors: Colorscale name. + colors: Colorscale name (str) or list of colors. Dicts are not supported + for heatmaps as color_continuous_scale requires a colorscale specification. title: Plot title. facet_col: Dimension for subplot columns. animation_frame: Dimension for animation slider. @@ -280,7 +281,7 @@ def _create_stacked_bar( return go.Figure() x_col = 'time' if 'time' in df.columns else df.columns[0] variables = df['variable'].unique().tolist() - color_map = process_colors(colors, variables) + color_map = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) fig = px.bar( df, x=x_col, @@ -311,7 +312,7 @@ def _create_line( return go.Figure() x_col = 'time' if 'time' in df.columns else df.columns[0] variables = df['variable'].unique().tolist() - color_map = process_colors(colors, variables) + color_map = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) return px.line( df, x=x_col, @@ -822,7 +823,7 @@ def heatmap( *, select: SelectType | None = None, reshape: tuple[str, str] | None = ('D', 'h'), - colors: ColorType | None = None, + colors: str | list[str] | None = None, facet_col: str | None = 'period', animation_frame: str | None = 'scenario', show: bool | None = None, @@ -839,7 +840,8 @@ def heatmap( select: xarray-style selection, e.g. {'scenario': 'Base Case'}. reshape: Time reshape frequencies as (outer, inner), e.g. ('D', 'h') for days × hours. Set to None to disable reshaping. - colors: Colorscale name (e.g., 'viridis', 'plasma') for heatmap coloring. + colors: Colorscale name (str) or list of colors for heatmap coloring. + Dicts are not supported for heatmaps (use str or list[str]). facet_col: Dimension for subplot columns (default: 'period'). With multiple variables, 'variable' is used instead. animation_frame: Dimension for animation slider (default: 'scenario'). diff --git a/flixopt/topology_accessor.py b/flixopt/topology_accessor.py index b4e18eb08..de4f83685 100644 --- a/flixopt/topology_accessor.py +++ b/flixopt/topology_accessor.py @@ -216,8 +216,14 @@ def plot( from .config import CONFIG node_infos, edge_infos = self.infos() + # Normalize path=False to None for _plot_network compatibility + normalized_path = None if path is False else path return _plot_network( - node_infos, edge_infos, path, controls, show if show is not None else CONFIG.Plotting.default_show + node_infos, + edge_infos, + normalized_path, + controls, + show if show is not None else CONFIG.Plotting.default_show, ) def start_app(self) -> None: From 26cfe30184c68dd458f2537c04733b943bef5bbc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 7 Dec 2025 17:26:56 +0100 Subject: [PATCH 7/7] fix usage if index name in aggregation plot --- flixopt/clustering.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/flixopt/clustering.py b/flixopt/clustering.py index 3d049132d..1595ace5d 100644 --- a/flixopt/clustering.py +++ b/flixopt/clustering.py @@ -159,14 +159,15 @@ def plot(self, colormap: str | None = None, show: bool = True, save: pathlib.Pat ) # Create line plot for original data (dashed) - df_org_long = df_org.reset_index().melt(id_vars='index', var_name='variable', value_name='value') - fig = px.line(df_org_long, x='index', y='value', color='variable', color_discrete_sequence=colors) + index_name = df_org.index.name or 'index' + df_org_long = df_org.reset_index().melt(id_vars=index_name, var_name='variable', value_name='value') + fig = px.line(df_org_long, x=index_name, y='value', color='variable', color_discrete_sequence=colors) for trace in fig.data: trace.update(line=dict(dash='dash')) # Add aggregated data (solid lines) - df_agg_long = df_agg.reset_index().melt(id_vars='index', var_name='variable', value_name='value') - fig2 = px.line(df_agg_long, x='index', y='value', color='variable', color_discrete_sequence=colors) + df_agg_long = df_agg.reset_index().melt(id_vars=index_name, var_name='variable', value_name='value') + fig2 = px.line(df_agg_long, x=index_name, y='value', color='variable', color_discrete_sequence=colors) for trace in fig2.data: fig.add_trace(trace)