From 2c6461b061de5e7b2057adc1ee1876dc21071f1d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:30:17 +0100 Subject: [PATCH 01/22] Add dataset plot accessor --- flixopt/__init__.py | 3 + flixopt/dataset_plot_accessor.py | 446 +++++++++++++++++++++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 flixopt/dataset_plot_accessor.py diff --git a/flixopt/__init__.py b/flixopt/__init__.py index d1a63a9c5..3cf219c38 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -14,6 +14,9 @@ # Import commonly used classes and functions from . import clustering, linear_converters, plotting, results, solvers + +# Register xr.Dataset.fxplot accessor (import triggers registration via decorator) +from . import dataset_plot_accessor as _ # noqa: F401 from .carrier import Carrier, CarrierContainer from .components import ( LinearConverter, diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py new file mode 100644 index 000000000..35f8db47c --- /dev/null +++ b/flixopt/dataset_plot_accessor.py @@ -0,0 +1,446 @@ +"""Dataset plot accessor for xarray Datasets. + +Provides convenient plotting methods for any xr.Dataset via the .fxplot accessor. +This is globally registered and available on all xr.Dataset objects when flixopt is imported. + +Example: + >>> import flixopt + >>> import xarray as xr + >>> ds = xr.Dataset({'temp': (['time', 'location'], data)}) + >>> ds.fxplot.line() # Line plot of all variables + >>> ds.fxplot.stacked_bar() # Stacked bar chart + >>> ds.fxplot.heatmap('temp') # Heatmap of specific variable +""" + +from __future__ import annotations + +from typing import Any, Literal + +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go +import xarray as xr + +from .color_processing import ColorType, process_colors +from .config import CONFIG + + +def _resolve_auto_facets( + ds: xr.Dataset, + facet_col: str | Literal['auto'] | None, + facet_row: str | Literal['auto'] | None, + animation_frame: str | Literal['auto'] | None = None, +) -> tuple[str | None, str | None, str | None]: + """Resolve 'auto' facet/animation dimensions based on available data dimensions. + + When 'auto' is specified, extra dimensions are assigned to slots based on: + - CONFIG.Plotting.extra_dim_priority: Order of dimensions (default: cluster -> period -> scenario) + - CONFIG.Plotting.dim_slot_priority: Order of slots (default: facet_col -> facet_row -> animation_frame) + + Args: + ds: Dataset to check for available dimensions. + facet_col: Dimension name, 'auto', or None. + facet_row: Dimension name, 'auto', or None. + animation_frame: Dimension name, 'auto', or None. + + Returns: + Tuple of (resolved_facet_col, resolved_facet_row, resolved_animation_frame). + Each is either a valid dimension name or None. + """ + # Get available extra dimensions with size > 1, sorted by priority + available = {d for d in ds.dims if ds.sizes[d] > 1} + extra_dims = [d for d in CONFIG.Plotting.extra_dim_priority if d in available] + used: set[str] = set() + + # Map slot names to their input values + slots = { + 'facet_col': facet_col, + 'facet_row': facet_row, + 'animation_frame': animation_frame, + } + results: dict[str, str | None] = {'facet_col': None, 'facet_row': None, 'animation_frame': None} + + # First pass: resolve explicit dimensions (not 'auto' or None) to mark them as used + for slot_name, value in slots.items(): + if value is not None and value != 'auto': + if value in available and value not in used: + used.add(value) + results[slot_name] = value + + # Second pass: resolve 'auto' slots in dim_slot_priority order + dim_iter = iter(d for d in extra_dims if d not in used) + for slot_name in CONFIG.Plotting.dim_slot_priority: + if slots.get(slot_name) == 'auto': + next_dim = next(dim_iter, None) + if next_dim: + used.add(next_dim) + results[slot_name] = next_dim + + return results['facet_col'], results['facet_row'], results['animation_frame'] + + +def _dataset_to_long_df(ds: xr.Dataset, value_name: str = 'value', var_name: str = 'variable') -> pd.DataFrame: + """Convert xarray Dataset to long-form DataFrame for plotly express.""" + if not ds.data_vars: + return pd.DataFrame() + if all(ds[var].ndim == 0 for var in ds.data_vars): + rows = [{var_name: var, value_name: float(ds[var].values)} for var in ds.data_vars] + return pd.DataFrame(rows) + df = ds.to_dataframe().reset_index() + # Only use coordinates that are actually present as columns after reset_index + coord_cols = [c for c in ds.coords.keys() if c in df.columns] + return df.melt(id_vars=coord_cols, var_name=var_name, value_name=value_name) + + +@xr.register_dataset_accessor('fxplot') +class DatasetPlotAccessor: + """Plot accessor for any xr.Dataset. Access via ``dataset.fxplot``. + + Provides convenient plotting methods that automatically handle multi-dimensional + data through faceting and animation. All methods return a Plotly Figure. + + This accessor is globally registered when flixopt is imported and works on + any xr.Dataset. + + Examples: + Basic usage:: + + import flixopt + import xarray as xr + + ds = xr.Dataset({'A': (['time'], [1, 2, 3]), 'B': (['time'], [3, 2, 1])}) + ds.fxplot.stacked_bar() + ds.fxplot.line() + ds.fxplot.area() + + With faceting:: + + ds.fxplot.stacked_bar(facet_col='scenario') + ds.fxplot.line(facet_col='period', animation_frame='scenario') + + Heatmap:: + + ds.fxplot.heatmap('temperature') + """ + + def __init__(self, xarray_obj: xr.Dataset) -> None: + """Initialize the accessor with an xr.Dataset object.""" + self._ds = xarray_obj + + def bar( + self, + *, + colors: ColorType | None = None, + title: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + facet_row: str | Literal['auto'] | None = None, + animation_frame: str | Literal['auto'] | None = None, + facet_cols: int | None = None, + **px_kwargs: Any, + ) -> go.Figure: + """Create a grouped bar chart from the dataset. + + Args: + colors: Color specification (colorscale name, color list, or dict mapping). + title: Plot title. + facet_col: Dimension for column facets. 'auto' uses CONFIG priority. + facet_row: Dimension for row facets. 'auto' uses CONFIG priority. + animation_frame: Dimension for animation slider. + facet_cols: Number of columns in facet grid wrap. + **px_kwargs: Additional arguments passed to plotly.express.bar. + + Returns: + Plotly Figure. + """ + df = _dataset_to_long_df(self._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 = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) + + actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( + self._ds, facet_col, facet_row, animation_frame + ) + + facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols + fig_kwargs: dict[str, Any] = { + 'data_frame': df, + 'x': x_col, + 'y': 'value', + 'color': 'variable', + 'color_discrete_map': color_map, + 'title': title, + 'barmode': 'group', + **px_kwargs, + } + + if actual_facet_col: + fig_kwargs['facet_col'] = actual_facet_col + if facet_col_wrap < self._ds.sizes.get(actual_facet_col, facet_col_wrap + 1): + fig_kwargs['facet_col_wrap'] = facet_col_wrap + if actual_facet_row: + fig_kwargs['facet_row'] = actual_facet_row + if actual_anim: + fig_kwargs['animation_frame'] = actual_anim + + return px.bar(**fig_kwargs) + + def stacked_bar( + self, + *, + colors: ColorType | None = None, + title: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + facet_row: str | Literal['auto'] | None = None, + animation_frame: str | Literal['auto'] | None = None, + facet_cols: int | None = None, + **px_kwargs: Any, + ) -> go.Figure: + """Create a stacked bar chart from the dataset. + + Variables in the dataset become stacked segments. Positive and negative + values are stacked separately. + + Args: + colors: Color specification (colorscale name, color list, or dict mapping). + title: Plot title. + facet_col: Dimension for column facets. 'auto' uses CONFIG priority. + facet_row: Dimension for row facets. + animation_frame: Dimension for animation slider. + facet_cols: Number of columns in facet grid wrap. + **px_kwargs: Additional arguments passed to plotly.express.bar. + + Returns: + Plotly Figure. + """ + df = _dataset_to_long_df(self._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 = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) + + actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( + self._ds, facet_col, facet_row, animation_frame + ) + + facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols + fig_kwargs: dict[str, Any] = { + 'data_frame': df, + 'x': x_col, + 'y': 'value', + 'color': 'variable', + 'color_discrete_map': color_map, + 'title': title, + **px_kwargs, + } + + if actual_facet_col: + fig_kwargs['facet_col'] = actual_facet_col + if facet_col_wrap < self._ds.sizes.get(actual_facet_col, facet_col_wrap + 1): + fig_kwargs['facet_col_wrap'] = facet_col_wrap + if actual_facet_row: + fig_kwargs['facet_row'] = actual_facet_row + if actual_anim: + fig_kwargs['animation_frame'] = actual_anim + + fig = px.bar(**fig_kwargs) + fig.update_layout(barmode='relative', bargap=0, bargroupgap=0) + fig.update_traces(marker_line_width=0) + return fig + + def line( + self, + *, + colors: ColorType | None = None, + title: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + facet_row: str | Literal['auto'] | None = None, + animation_frame: str | Literal['auto'] | None = None, + facet_cols: int | None = None, + line_shape: str = 'hv', + **px_kwargs: Any, + ) -> go.Figure: + """Create a line chart from the dataset. + + Each variable in the dataset becomes a separate line. + + Args: + colors: Color specification (colorscale name, color list, or dict mapping). + title: Plot title. + facet_col: Dimension for column facets. 'auto' uses CONFIG priority. + facet_row: Dimension for row facets. + animation_frame: Dimension for animation slider. + facet_cols: Number of columns in facet grid wrap. + line_shape: Line interpolation ('linear', 'hv', 'vh', 'hvh', 'vhv', 'spline'). + Default 'hv' for stepped lines. + **px_kwargs: Additional arguments passed to plotly.express.line. + + Returns: + Plotly Figure. + """ + df = _dataset_to_long_df(self._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 = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) + + actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( + self._ds, facet_col, facet_row, animation_frame + ) + + facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols + fig_kwargs: dict[str, Any] = { + 'data_frame': df, + 'x': x_col, + 'y': 'value', + 'color': 'variable', + 'color_discrete_map': color_map, + 'title': title, + 'line_shape': line_shape, + **px_kwargs, + } + + if actual_facet_col: + fig_kwargs['facet_col'] = actual_facet_col + if facet_col_wrap < self._ds.sizes.get(actual_facet_col, facet_col_wrap + 1): + fig_kwargs['facet_col_wrap'] = facet_col_wrap + if actual_facet_row: + fig_kwargs['facet_row'] = actual_facet_row + if actual_anim: + fig_kwargs['animation_frame'] = actual_anim + + return px.line(**fig_kwargs) + + def area( + self, + *, + colors: ColorType | None = None, + title: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + facet_row: str | Literal['auto'] | None = None, + animation_frame: str | Literal['auto'] | None = None, + facet_cols: int | None = None, + line_shape: str = 'hv', + **px_kwargs: Any, + ) -> go.Figure: + """Create a stacked area chart from the dataset. + + Args: + colors: Color specification (colorscale name, color list, or dict mapping). + title: Plot title. + facet_col: Dimension for column facets. 'auto' uses CONFIG priority. + facet_row: Dimension for row facets. + animation_frame: Dimension for animation slider. + facet_cols: Number of columns in facet grid wrap. + line_shape: Line interpolation. Default 'hv' for stepped. + **px_kwargs: Additional arguments passed to plotly.express.area. + + Returns: + Plotly Figure. + """ + df = _dataset_to_long_df(self._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 = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) + + actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( + self._ds, facet_col, facet_row, animation_frame + ) + + facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols + fig_kwargs: dict[str, Any] = { + 'data_frame': df, + 'x': x_col, + 'y': 'value', + 'color': 'variable', + 'color_discrete_map': color_map, + 'title': title, + 'line_shape': line_shape, + **px_kwargs, + } + + if actual_facet_col: + fig_kwargs['facet_col'] = actual_facet_col + if facet_col_wrap < self._ds.sizes.get(actual_facet_col, facet_col_wrap + 1): + fig_kwargs['facet_col_wrap'] = facet_col_wrap + if actual_facet_row: + fig_kwargs['facet_row'] = actual_facet_row + if actual_anim: + fig_kwargs['animation_frame'] = actual_anim + + return px.area(**fig_kwargs) + + def heatmap( + self, + variable: str | None = None, + *, + colors: str | list[str] | None = None, + title: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = None, + facet_cols: int | None = None, + **imshow_kwargs: Any, + ) -> go.Figure: + """Create a heatmap visualization. + + If the dataset has multiple variables, select one with the `variable` parameter. + If only one variable exists, it is used automatically. + + Args: + variable: Variable name to plot. Required if dataset has multiple variables. + If None and dataset has one variable, that variable is used. + colors: Colorscale name or list of colors. + title: Plot title. + facet_col: Dimension for column facets. + animation_frame: Dimension for animation slider. + facet_cols: Number of columns in facet grid wrap. + **imshow_kwargs: Additional arguments passed to plotly.express.imshow. + + Returns: + Plotly Figure. + """ + # Select single variable + if variable is None: + if len(self._ds.data_vars) == 1: + variable = list(self._ds.data_vars)[0] + else: + raise ValueError( + f'Dataset has {len(self._ds.data_vars)} variables. ' + f"Please specify which variable to plot with variable='name'." + ) + + da = self._ds[variable] + + if da.size == 0: + return go.Figure() + + colors = colors or CONFIG.Plotting.default_sequential_colorscale + facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols + + actual_facet_col, _, actual_anim = _resolve_auto_facets(self._ds, facet_col, None, animation_frame) + + imshow_args: dict[str, Any] = { + 'img': da, + 'color_continuous_scale': colors, + 'title': title or variable, + **imshow_kwargs, + } + + if actual_facet_col and actual_facet_col in da.dims: + imshow_args['facet_col'] = actual_facet_col + if facet_col_wrap < da.sizes[actual_facet_col]: + imshow_args['facet_col_wrap'] = facet_col_wrap + + if actual_anim and actual_anim in da.dims: + imshow_args['animation_frame'] = actual_anim + + return px.imshow(**imshow_args) From f574c0ce3deba6315af0b66f7a47793c997e91e8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:33:35 +0100 Subject: [PATCH 02/22] Add fxplot acessor showcase --- docs/notebooks/fxplot_accessor_demo.ipynb | 323 ++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 docs/notebooks/fxplot_accessor_demo.ipynb diff --git a/docs/notebooks/fxplot_accessor_demo.ipynb b/docs/notebooks/fxplot_accessor_demo.ipynb new file mode 100644 index 000000000..d4d7b69b6 --- /dev/null +++ b/docs/notebooks/fxplot_accessor_demo.ipynb @@ -0,0 +1,323 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Dataset Plot Accessor Demo (`.fxplot`)\n", + "\n", + "This notebook demonstrates the new `.fxplot` accessor for `xr.Dataset` objects.\n", + "It provides convenient Plotly Express plotting methods with smart auto-faceting and coloring." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import xarray as xr" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create Sample Data\n", + "\n", + "Let's create a multi-dimensional dataset to demonstrate the plotting capabilities." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Simple time-series dataset\n", + "np.random.seed(42)\n", + "time = pd.date_range('2024-01-01', periods=24, freq='h')\n", + "\n", + "ds_simple = xr.Dataset(\n", + " {\n", + " 'Solar': (['time'], np.maximum(0, np.sin(np.linspace(0, 2 * np.pi, 24)) * 50 + np.random.randn(24) * 5)),\n", + " 'Wind': (['time'], np.abs(np.random.randn(24) * 20 + 30)),\n", + " 'Demand': (['time'], np.abs(np.sin(np.linspace(0, 2 * np.pi, 24) + 1) * 40 + 50 + np.random.randn(24) * 5)),\n", + " },\n", + " coords={'time': time},\n", + ")\n", + "\n", + "ds_simple" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Line Plot" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ds_simple.fxplot.line(title='Energy Generation & Demand')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Stacked Bar Chart" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ds_simple[['Solar', 'Wind']].fxplot.stacked_bar(title='Renewable Generation')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Area Chart" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ds_simple[['Solar', 'Wind']].fxplot.area(title='Stacked Area - Generation')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Grouped Bar Chart" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ds_simple.fxplot.bar(title='Grouped Bar Chart')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Heatmap" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create 2D data for heatmap\n", + "ds_heatmap = xr.Dataset(\n", + " {\n", + " 'temperature': (['day', 'hour'], np.random.randn(7, 24) * 5 + 20),\n", + " },\n", + " coords={\n", + " 'day': pd.date_range('2024-01-01', periods=7, freq='D'),\n", + " 'hour': range(24),\n", + " },\n", + ")\n", + "\n", + "ds_heatmap.fxplot.heatmap('temperature', title='Temperature Heatmap')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multi-Dimensional Data with Auto-Faceting\n", + "\n", + "The accessor automatically handles extra dimensions by assigning them to facets or animation based on CONFIG priority." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Dataset with scenario dimension\n", + "ds_scenarios = xr.Dataset(\n", + " {\n", + " 'Solar': (\n", + " ['time', 'scenario'],\n", + " np.column_stack(\n", + " [\n", + " np.maximum(0, np.sin(np.linspace(0, 2 * np.pi, 24)) * 50),\n", + " np.maximum(0, np.sin(np.linspace(0, 2 * np.pi, 24)) * 70), # High scenario\n", + " ]\n", + " ),\n", + " ),\n", + " 'Wind': (\n", + " ['time', 'scenario'],\n", + " np.column_stack(\n", + " [\n", + " np.abs(np.random.randn(24) * 20 + 30),\n", + " np.abs(np.random.randn(24) * 25 + 40), # High scenario\n", + " ]\n", + " ),\n", + " ),\n", + " },\n", + " coords={\n", + " 'time': time,\n", + " 'scenario': ['base', 'high'],\n", + " },\n", + ")\n", + "\n", + "ds_scenarios" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Auto-faceting assigns 'scenario' to facet_col\n", + "ds_scenarios.fxplot.line(title='Generation by Scenario (Auto-Faceted)')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Explicit faceting\n", + "ds_scenarios.fxplot.stacked_bar(facet_col='scenario', title='Stacked Bar by Scenario')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Animation Support" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Use animation instead of faceting\n", + "ds_scenarios.fxplot.area(facet_col=None, animation_frame='scenario', title='Animated by Scenario')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Custom Colors" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Using a colorscale name\n", + "ds_simple.fxplot.line(colors='viridis', title='With Viridis Colorscale')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Using explicit color mapping\n", + "ds_simple.fxplot.stacked_bar(\n", + " colors={'Solar': 'gold', 'Wind': 'skyblue', 'Demand': 'salmon'}, title='With Custom Colors'\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Chaining with Plotly Methods\n", + "\n", + "Since all methods return `go.Figure`, you can chain Plotly's update methods." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "(\n", + " ds_simple.fxplot.line(title='Customized Plot')\n", + " .update_layout(xaxis_title='Time of Day', yaxis_title='Power (MW)', legend_title='Source', template='plotly_white')\n", + " .update_traces(line_width=2)\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pre-filtering with xarray\n", + "\n", + "Filter data using xarray methods before plotting." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Select specific time range\n", + "ds_simple.sel(time=slice('2024-01-01 06:00', '2024-01-01 18:00')).fxplot.line(title='Daytime Only')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Select specific variables\n", + "ds_simple[['Solar', 'Wind']].fxplot.area(title='Renewables Only')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} From edf89ef7271d59167d670d4cbe631566752c83e4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:50:16 +0100 Subject: [PATCH 03/22] The internal plot accessors now leverage the shared .fxplot implementation, reducing code duplication while maintaining the same functionality (data preparation, color resolution from components, PlotResult wrapping). --- flixopt/dataset_plot_accessor.py | 173 +++++++++++++++++++++++++++++++ flixopt/statistics_accessor.py | 129 ++--------------------- 2 files changed, 179 insertions(+), 123 deletions(-) diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py index 35f8db47c..9f836c56c 100644 --- a/flixopt/dataset_plot_accessor.py +++ b/flixopt/dataset_plot_accessor.py @@ -444,3 +444,176 @@ def heatmap( imshow_args['animation_frame'] = actual_anim return px.imshow(**imshow_args) + + +@xr.register_dataarray_accessor('fxplot') +class DataArrayPlotAccessor: + """Plot accessor for any xr.DataArray. Access via ``dataarray.fxplot``. + + Provides convenient plotting methods. For bar/stacked_bar/line/area, + the DataArray is converted to a Dataset first. For heatmap, it works + directly with the DataArray. + + Examples: + Basic usage:: + + import flixopt + import xarray as xr + + da = xr.DataArray([1, 2, 3], dims=['time'], name='temperature') + da.fxplot.line() + da.fxplot.heatmap() + """ + + def __init__(self, xarray_obj: xr.DataArray) -> None: + """Initialize the accessor with an xr.DataArray object.""" + self._da = xarray_obj + + def _to_dataset(self) -> xr.Dataset: + """Convert DataArray to Dataset for plotting.""" + name = self._da.name or 'value' + return self._da.to_dataset(name=name) + + def bar( + self, + *, + colors: ColorType | None = None, + title: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + facet_row: str | Literal['auto'] | None = None, + animation_frame: str | Literal['auto'] | None = None, + facet_cols: int | None = None, + **px_kwargs: Any, + ) -> go.Figure: + """Create a grouped bar chart. See DatasetPlotAccessor.bar for details.""" + return self._to_dataset().fxplot.bar( + colors=colors, + title=title, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + facet_cols=facet_cols, + **px_kwargs, + ) + + def stacked_bar( + self, + *, + colors: ColorType | None = None, + title: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + facet_row: str | Literal['auto'] | None = None, + animation_frame: str | Literal['auto'] | None = None, + facet_cols: int | None = None, + **px_kwargs: Any, + ) -> go.Figure: + """Create a stacked bar chart. See DatasetPlotAccessor.stacked_bar for details.""" + return self._to_dataset().fxplot.stacked_bar( + colors=colors, + title=title, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + facet_cols=facet_cols, + **px_kwargs, + ) + + def line( + self, + *, + colors: ColorType | None = None, + title: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + facet_row: str | Literal['auto'] | None = None, + animation_frame: str | Literal['auto'] | None = None, + facet_cols: int | None = None, + line_shape: str = 'hv', + **px_kwargs: Any, + ) -> go.Figure: + """Create a line chart. See DatasetPlotAccessor.line for details.""" + return self._to_dataset().fxplot.line( + colors=colors, + title=title, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + facet_cols=facet_cols, + line_shape=line_shape, + **px_kwargs, + ) + + def area( + self, + *, + colors: ColorType | None = None, + title: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + facet_row: str | Literal['auto'] | None = None, + animation_frame: str | Literal['auto'] | None = None, + facet_cols: int | None = None, + line_shape: str = 'hv', + **px_kwargs: Any, + ) -> go.Figure: + """Create a stacked area chart. See DatasetPlotAccessor.area for details.""" + return self._to_dataset().fxplot.area( + colors=colors, + title=title, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + facet_cols=facet_cols, + line_shape=line_shape, + **px_kwargs, + ) + + def heatmap( + self, + *, + colors: str | list[str] | None = None, + title: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = None, + facet_cols: int | None = None, + **imshow_kwargs: Any, + ) -> go.Figure: + """Create a heatmap visualization directly from the DataArray. + + Args: + colors: Colorscale name or list of colors. + title: Plot title. + facet_col: Dimension for column facets. + animation_frame: Dimension for animation slider. + facet_cols: Number of columns in facet grid wrap. + **imshow_kwargs: Additional arguments passed to plotly.express.imshow. + + Returns: + Plotly Figure. + """ + da = self._da + + if da.size == 0: + return go.Figure() + + colors = colors or CONFIG.Plotting.default_sequential_colorscale + facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols + + # Use Dataset for facet resolution + ds_for_resolution = da.to_dataset(name='_temp') + actual_facet_col, _, actual_anim = _resolve_auto_facets(ds_for_resolution, facet_col, None, animation_frame) + + imshow_args: dict[str, Any] = { + 'img': da, + 'color_continuous_scale': colors, + 'title': title or (da.name if da.name else ''), + **imshow_kwargs, + } + + if actual_facet_col and actual_facet_col in da.dims: + imshow_args['facet_col'] = actual_facet_col + if facet_col_wrap < da.sizes[actual_facet_col]: + imshow_args['facet_col_wrap'] = facet_col_wrap + + if actual_anim and actual_anim in da.dims: + imshow_args['animation_frame'] = actual_anim + + return px.imshow(**imshow_args) diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index bee26a0e2..e01880f76 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -124,54 +124,6 @@ def _reshape_time_for_heatmap( return result.transpose('timestep', 'timeframe', *other_dims) -def _heatmap_figure( - data: xr.DataArray, - colors: str | list[str] | None = 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 (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. - 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) - - # --- Helper functions --- @@ -308,69 +260,6 @@ def _dataset_to_long_df(ds: xr.Dataset, value_name: str = 'value', var_name: str return df.melt(id_vars=coord_cols, var_name=var_name, value_name=value_name) -def _create_stacked_bar( - ds: xr.Dataset, - colors: ColorType, - title: str, - facet_col: str | None, - facet_row: str | None, - animation_frame: str | None = None, - **plotly_kwargs: Any, -) -> go.Figure: - """Create a stacked bar chart from xarray Dataset.""" - 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 = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) - fig = px.bar( - df, - x=x_col, - y='value', - color='variable', - facet_col=facet_col, - facet_row=facet_row, - animation_frame=animation_frame, - color_discrete_map=color_map, - title=title, - **plotly_kwargs, - ) - fig.update_layout(barmode='relative', bargap=0, bargroupgap=0) - fig.update_traces(marker_line_width=0) - return fig - - -def _create_line( - ds: xr.Dataset, - colors: ColorType, - title: str, - facet_col: str | None, - facet_row: str | None, - animation_frame: str | None = None, - **plotly_kwargs: Any, -) -> go.Figure: - """Create a line chart from xarray Dataset.""" - 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 = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) - return px.line( - df, - x=x_col, - y='value', - color='variable', - facet_col=facet_col, - facet_row=facet_row, - animation_frame=animation_frame, - color_discrete_map=color_map, - title=title, - **plotly_kwargs, - ) - - # --- Statistics Accessor (data only) --- @@ -1507,8 +1396,7 @@ def balance( first_var = next(iter(ds.data_vars)) unit_label = ds[first_var].attrs.get('unit', '') - fig = _create_stacked_bar( - ds, + fig = ds.fxplot.stacked_bar( colors=colors, title=f'{node} [{unit_label}]' if unit_label else node, facet_col=actual_facet_col, @@ -1632,8 +1520,7 @@ def carrier_balance( first_var = next(iter(ds.data_vars)) unit_label = ds[first_var].attrs.get('unit', '') - fig = _create_stacked_bar( - ds, + fig = ds.fxplot.stacked_bar( colors=colors, title=f'{carrier.capitalize()} Balance [{unit_label}]' if unit_label else f'{carrier.capitalize()} Balance', facet_col=actual_facet_col, @@ -1766,8 +1653,7 @@ def heatmap( if has_multiple_vars: da = da.rename('') - fig = _heatmap_figure( - da, + fig = da.fxplot.heatmap( colors=colors, facet_col=actual_facet, animation_frame=actual_animation, @@ -1861,8 +1747,7 @@ def flows( first_var = next(iter(ds.data_vars)) unit_label = ds[first_var].attrs.get('unit', '') - fig = _create_line( - ds, + fig = ds.fxplot.line( colors=colors, title=f'Flows [{unit_label}]' if unit_label else 'Flows', facet_col=actual_facet_col, @@ -2038,8 +1923,7 @@ def sort_descending(arr: np.ndarray) -> np.ndarray: first_var = next(iter(ds.data_vars)) unit_label = ds[first_var].attrs.get('unit', '') - fig = _create_line( - result_ds, + fig = result_ds.fxplot.line( colors=colors, title=f'Duration Curve [{unit_label}]' if unit_label else 'Duration Curve', facet_col=actual_facet_col, @@ -2258,8 +2142,7 @@ def charge_states( ds, facet_col, facet_row, animation_frame ) - fig = _create_line( - ds, + fig = ds.fxplot.line( colors=colors, title='Storage Charge States', facet_col=actual_facet_col, From 2b7aa63f8380b7fff0dc4e4002c42d97866010f3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:54:55 +0100 Subject: [PATCH 04/22] Fix notebook --- docs/notebooks/fxplot_accessor_demo.ipynb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/notebooks/fxplot_accessor_demo.ipynb b/docs/notebooks/fxplot_accessor_demo.ipynb index d4d7b69b6..71f9b8245 100644 --- a/docs/notebooks/fxplot_accessor_demo.ipynb +++ b/docs/notebooks/fxplot_accessor_demo.ipynb @@ -18,7 +18,11 @@ "source": [ "import numpy as np\n", "import pandas as pd\n", - "import xarray as xr" + "import xarray as xr\n", + "\n", + "import flixopt as fx\n", + "\n", + "fx.__version__" ] }, { From 8d500093665f808dbc1271595318a19509f70782 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 09:56:29 +0100 Subject: [PATCH 05/22] 1. xlabel/ylabel parameters - Added to bar(), stacked_bar(), line(), area(), and duration_curve() methods in both DatasetPlotAccessor and DataArrayPlotAccessor 2. scatter() method - Plots two variables against each other with x and y parameters 3. pie() method - Creates pie charts from aggregated (scalar) dataset values, e.g. ds.sum('time').fxplot.pie() 4. duration_curve() method - Sorts values along the time dimension in descending order, with optional normalize parameter for percentage x-axis 5. CONFIG.Plotting.default_line_shape - New config option (default 'hv') that controls the default line shape for line(), area(), and duration_curve() methods --- flixopt/config.py | 3 + flixopt/dataset_plot_accessor.py | 260 ++++++++++++++++++++++++++++++- 2 files changed, 255 insertions(+), 8 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 7e7c784cb..ad5db2897 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -163,6 +163,7 @@ def format(self, record): 'default_facet_cols': 3, 'default_sequential_colorscale': 'turbo', 'default_qualitative_colorscale': 'plotly', + 'default_line_shape': 'hv', 'extra_dim_priority': ('cluster', 'period', 'scenario'), 'dim_slot_priority': ('facet_col', 'facet_row', 'animation_frame'), } @@ -585,6 +586,7 @@ class Plotting: default_facet_cols: int = _DEFAULTS['plotting']['default_facet_cols'] default_sequential_colorscale: str = _DEFAULTS['plotting']['default_sequential_colorscale'] default_qualitative_colorscale: str = _DEFAULTS['plotting']['default_qualitative_colorscale'] + default_line_shape: str = _DEFAULTS['plotting']['default_line_shape'] extra_dim_priority: tuple[str, ...] = _DEFAULTS['plotting']['extra_dim_priority'] dim_slot_priority: tuple[str, ...] = _DEFAULTS['plotting']['dim_slot_priority'] @@ -687,6 +689,7 @@ def to_dict(cls) -> dict: 'default_facet_cols': cls.Plotting.default_facet_cols, 'default_sequential_colorscale': cls.Plotting.default_sequential_colorscale, 'default_qualitative_colorscale': cls.Plotting.default_qualitative_colorscale, + 'default_line_shape': cls.Plotting.default_line_shape, 'extra_dim_priority': cls.Plotting.extra_dim_priority, 'dim_slot_priority': cls.Plotting.dim_slot_priority, }, diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py index 9f836c56c..877f8598b 100644 --- a/flixopt/dataset_plot_accessor.py +++ b/flixopt/dataset_plot_accessor.py @@ -132,6 +132,8 @@ def bar( *, colors: ColorType | None = None, title: str = '', + xlabel: str = '', + ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', facet_row: str | Literal['auto'] | None = None, animation_frame: str | Literal['auto'] | None = None, @@ -143,6 +145,8 @@ def bar( Args: colors: Color specification (colorscale name, color list, or dict mapping). title: Plot title. + xlabel: X-axis label. + ylabel: Y-axis label. facet_col: Dimension for column facets. 'auto' uses CONFIG priority. facet_row: Dimension for row facets. 'auto' uses CONFIG priority. animation_frame: Dimension for animation slider. @@ -175,6 +179,10 @@ def bar( 'barmode': 'group', **px_kwargs, } + if xlabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x_col: xlabel} + if ylabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), 'value': ylabel} if actual_facet_col: fig_kwargs['facet_col'] = actual_facet_col @@ -192,6 +200,8 @@ def stacked_bar( *, colors: ColorType | None = None, title: str = '', + xlabel: str = '', + ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', facet_row: str | Literal['auto'] | None = None, animation_frame: str | Literal['auto'] | None = None, @@ -206,6 +216,8 @@ def stacked_bar( Args: colors: Color specification (colorscale name, color list, or dict mapping). title: Plot title. + xlabel: X-axis label. + ylabel: Y-axis label. facet_col: Dimension for column facets. 'auto' uses CONFIG priority. facet_row: Dimension for row facets. animation_frame: Dimension for animation slider. @@ -237,6 +249,10 @@ def stacked_bar( 'title': title, **px_kwargs, } + if xlabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x_col: xlabel} + if ylabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), 'value': ylabel} if actual_facet_col: fig_kwargs['facet_col'] = actual_facet_col @@ -257,11 +273,13 @@ def line( *, colors: ColorType | None = None, title: str = '', + xlabel: str = '', + ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', facet_row: str | Literal['auto'] | None = None, animation_frame: str | Literal['auto'] | None = None, facet_cols: int | None = None, - line_shape: str = 'hv', + line_shape: str | None = None, **px_kwargs: Any, ) -> go.Figure: """Create a line chart from the dataset. @@ -271,12 +289,14 @@ def line( Args: colors: Color specification (colorscale name, color list, or dict mapping). title: Plot title. + xlabel: X-axis label. + ylabel: Y-axis label. facet_col: Dimension for column facets. 'auto' uses CONFIG priority. facet_row: Dimension for row facets. animation_frame: Dimension for animation slider. facet_cols: Number of columns in facet grid wrap. line_shape: Line interpolation ('linear', 'hv', 'vh', 'hvh', 'vhv', 'spline'). - Default 'hv' for stepped lines. + Default from CONFIG.Plotting.default_line_shape. **px_kwargs: Additional arguments passed to plotly.express.line. Returns: @@ -302,9 +322,13 @@ def line( 'color': 'variable', 'color_discrete_map': color_map, 'title': title, - 'line_shape': line_shape, + 'line_shape': line_shape or CONFIG.Plotting.default_line_shape, **px_kwargs, } + if xlabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x_col: xlabel} + if ylabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), 'value': ylabel} if actual_facet_col: fig_kwargs['facet_col'] = actual_facet_col @@ -322,11 +346,13 @@ def area( *, colors: ColorType | None = None, title: str = '', + xlabel: str = '', + ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', facet_row: str | Literal['auto'] | None = None, animation_frame: str | Literal['auto'] | None = None, facet_cols: int | None = None, - line_shape: str = 'hv', + line_shape: str | None = None, **px_kwargs: Any, ) -> go.Figure: """Create a stacked area chart from the dataset. @@ -334,11 +360,13 @@ def area( Args: colors: Color specification (colorscale name, color list, or dict mapping). title: Plot title. + xlabel: X-axis label. + ylabel: Y-axis label. facet_col: Dimension for column facets. 'auto' uses CONFIG priority. facet_row: Dimension for row facets. animation_frame: Dimension for animation slider. facet_cols: Number of columns in facet grid wrap. - line_shape: Line interpolation. Default 'hv' for stepped. + line_shape: Line interpolation. Default from CONFIG.Plotting.default_line_shape. **px_kwargs: Additional arguments passed to plotly.express.area. Returns: @@ -364,9 +392,13 @@ def area( 'color': 'variable', 'color_discrete_map': color_map, 'title': title, - 'line_shape': line_shape, + 'line_shape': line_shape or CONFIG.Plotting.default_line_shape, **px_kwargs, } + if xlabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x_col: xlabel} + if ylabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), 'value': ylabel} if actual_facet_col: fig_kwargs['facet_col'] = actual_facet_col @@ -445,6 +477,202 @@ def heatmap( return px.imshow(**imshow_args) + def scatter( + self, + x: str, + y: str, + *, + colors: ColorType | None = None, + title: str = '', + xlabel: str = '', + ylabel: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + facet_row: str | Literal['auto'] | None = None, + animation_frame: str | Literal['auto'] | None = None, + facet_cols: int | None = None, + **px_kwargs: Any, + ) -> go.Figure: + """Create a scatter plot from two variables in the dataset. + + Args: + x: Variable name for x-axis. + y: Variable name for y-axis. + colors: Color specification (colorscale name, color list, or dict mapping). + title: Plot title. + xlabel: X-axis label. + ylabel: Y-axis label. + facet_col: Dimension for column facets. 'auto' uses CONFIG priority. + facet_row: Dimension for row facets. + animation_frame: Dimension for animation slider. + facet_cols: Number of columns in facet grid wrap. + **px_kwargs: Additional arguments passed to plotly.express.scatter. + + Returns: + Plotly Figure. + """ + if x not in self._ds.data_vars: + raise ValueError(f"Variable '{x}' not found in dataset. Available: {list(self._ds.data_vars)}") + if y not in self._ds.data_vars: + raise ValueError(f"Variable '{y}' not found in dataset. Available: {list(self._ds.data_vars)}") + + df = self._ds[[x, y]].to_dataframe().reset_index() + if df.empty: + return go.Figure() + + actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( + self._ds, facet_col, facet_row, animation_frame + ) + + facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols + fig_kwargs: dict[str, Any] = { + 'data_frame': df, + 'x': x, + 'y': y, + 'title': title, + **px_kwargs, + } + if xlabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x: xlabel} + if ylabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), y: ylabel} + + if actual_facet_col: + fig_kwargs['facet_col'] = actual_facet_col + if facet_col_wrap < self._ds.sizes.get(actual_facet_col, facet_col_wrap + 1): + fig_kwargs['facet_col_wrap'] = facet_col_wrap + if actual_facet_row: + fig_kwargs['facet_row'] = actual_facet_row + if actual_anim: + fig_kwargs['animation_frame'] = actual_anim + + return px.scatter(**fig_kwargs) + + def pie( + self, + *, + colors: ColorType | None = None, + title: str = '', + **px_kwargs: Any, + ) -> go.Figure: + """Create a pie chart from aggregated dataset values. + + The dataset should be reduced to scalar values per variable (e.g., via .sum()). + Each variable becomes a slice of the pie. + + Args: + colors: Color specification (colorscale name, color list, or dict mapping). + title: Plot title. + **px_kwargs: Additional arguments passed to plotly.express.pie. + + Returns: + Plotly Figure. + + Example: + >>> ds.sum('time').fxplot.pie() # Sum over time, then pie chart + """ + # Check that all variables are scalar + non_scalar = [v for v in self._ds.data_vars if self._ds[v].ndim > 0] + if non_scalar: + raise ValueError( + f'Pie chart requires scalar values per variable. ' + f'Non-scalar variables: {non_scalar}. ' + f"Try reducing first: ds.sum('time').fxplot.pie()" + ) + + names = list(self._ds.data_vars) + values = [float(self._ds[v].values) for v in names] + df = pd.DataFrame({'variable': names, 'value': values}) + + color_map = process_colors(colors, names, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) + + return px.pie( + df, + names='variable', + values='value', + title=title, + color='variable', + color_discrete_map=color_map, + **px_kwargs, + ) + + def duration_curve( + self, + *, + colors: ColorType | None = None, + title: str = '', + xlabel: str = '', + ylabel: str = '', + normalize: bool = True, + facet_col: str | Literal['auto'] | None = 'auto', + facet_row: str | Literal['auto'] | None = None, + animation_frame: str | Literal['auto'] | None = None, + facet_cols: int | None = None, + line_shape: str | None = None, + **px_kwargs: Any, + ) -> go.Figure: + """Create a duration curve (sorted values) from the dataset. + + Values are sorted in descending order along the 'time' dimension. + The x-axis shows duration (percentage or timesteps). + + Args: + colors: Color specification (colorscale name, color list, or dict mapping). + title: Plot title. + xlabel: X-axis label. Default 'Duration [%]' or 'Timesteps'. + ylabel: Y-axis label. + normalize: If True, x-axis shows percentage (0-100). If False, shows timestep index. + facet_col: Dimension for column facets. 'auto' uses CONFIG priority. + facet_row: Dimension for row facets. + animation_frame: Dimension for animation slider. + facet_cols: Number of columns in facet grid wrap. + line_shape: Line interpolation. Default from CONFIG.Plotting.default_line_shape. + **px_kwargs: Additional arguments passed to plotly.express.line. + + Returns: + Plotly Figure. + """ + import numpy as np + + if 'time' not in self._ds.dims: + raise ValueError("Duration curve requires a 'time' dimension.") + + # Sort each variable along time dimension (descending) + sorted_ds = self._ds.copy() + for var in sorted_ds.data_vars: + da = sorted_ds[var] + # Sort along time axis + sorted_values = np.sort(da.values, axis=da.dims.index('time'))[::-1] + sorted_ds[var] = (da.dims, sorted_values) + + # Replace time coordinate with duration + n_timesteps = sorted_ds.sizes['time'] + if normalize: + duration_coord = np.linspace(0, 100, n_timesteps) + sorted_ds = sorted_ds.assign_coords({'time': duration_coord}) + sorted_ds = sorted_ds.rename({'time': 'duration_pct'}) + default_xlabel = 'Duration [%]' + else: + duration_coord = np.arange(n_timesteps) + sorted_ds = sorted_ds.assign_coords({'time': duration_coord}) + sorted_ds = sorted_ds.rename({'time': 'duration'}) + default_xlabel = 'Timesteps' + + # Use line plot + fig = sorted_ds.fxplot.line( + colors=colors, + title=title or 'Duration Curve', + xlabel=xlabel or default_xlabel, + ylabel=ylabel, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + facet_cols=facet_cols, + line_shape=line_shape, + **px_kwargs, + ) + + return fig + @xr.register_dataarray_accessor('fxplot') class DataArrayPlotAccessor: @@ -479,6 +707,8 @@ def bar( *, colors: ColorType | None = None, title: str = '', + xlabel: str = '', + ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', facet_row: str | Literal['auto'] | None = None, animation_frame: str | Literal['auto'] | None = None, @@ -489,6 +719,8 @@ def bar( return self._to_dataset().fxplot.bar( colors=colors, title=title, + xlabel=xlabel, + ylabel=ylabel, facet_col=facet_col, facet_row=facet_row, animation_frame=animation_frame, @@ -501,6 +733,8 @@ def stacked_bar( *, colors: ColorType | None = None, title: str = '', + xlabel: str = '', + ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', facet_row: str | Literal['auto'] | None = None, animation_frame: str | Literal['auto'] | None = None, @@ -511,6 +745,8 @@ def stacked_bar( return self._to_dataset().fxplot.stacked_bar( colors=colors, title=title, + xlabel=xlabel, + ylabel=ylabel, facet_col=facet_col, facet_row=facet_row, animation_frame=animation_frame, @@ -523,17 +759,21 @@ def line( *, colors: ColorType | None = None, title: str = '', + xlabel: str = '', + ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', facet_row: str | Literal['auto'] | None = None, animation_frame: str | Literal['auto'] | None = None, facet_cols: int | None = None, - line_shape: str = 'hv', + line_shape: str | None = None, **px_kwargs: Any, ) -> go.Figure: """Create a line chart. See DatasetPlotAccessor.line for details.""" return self._to_dataset().fxplot.line( colors=colors, title=title, + xlabel=xlabel, + ylabel=ylabel, facet_col=facet_col, facet_row=facet_row, animation_frame=animation_frame, @@ -547,17 +787,21 @@ def area( *, colors: ColorType | None = None, title: str = '', + xlabel: str = '', + ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', facet_row: str | Literal['auto'] | None = None, animation_frame: str | Literal['auto'] | None = None, facet_cols: int | None = None, - line_shape: str = 'hv', + line_shape: str | None = None, **px_kwargs: Any, ) -> go.Figure: """Create a stacked area chart. See DatasetPlotAccessor.area for details.""" return self._to_dataset().fxplot.area( colors=colors, title=title, + xlabel=xlabel, + ylabel=ylabel, facet_col=facet_col, facet_row=facet_row, animation_frame=animation_frame, From 1e70c78d9144df621b2d9c1b551bf055417b7e7f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:24:47 +0100 Subject: [PATCH 06/22] Fix faceting of pie --- flixopt/dataset_plot_accessor.py | 72 +++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py index 877f8598b..112d15412 100644 --- a/flixopt/dataset_plot_accessor.py +++ b/flixopt/dataset_plot_accessor.py @@ -552,16 +552,22 @@ def pie( *, colors: ColorType | None = None, title: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + facet_row: str | Literal['auto'] | None = None, + facet_cols: int | None = None, **px_kwargs: Any, ) -> go.Figure: """Create a pie chart from aggregated dataset values. - The dataset should be reduced to scalar values per variable (e.g., via .sum()). - Each variable becomes a slice of the pie. + The dataset should be reduced so each variable has at most one remaining + dimension (for faceting). For scalar values, a single pie is shown. Args: colors: Color specification (colorscale name, color list, or dict mapping). title: Plot title. + facet_col: Dimension for column facets. 'auto' uses CONFIG priority. + facet_row: Dimension for row facets. + facet_cols: Number of columns in facet grid wrap. **px_kwargs: Additional arguments passed to plotly.express.pie. Returns: @@ -569,31 +575,59 @@ def pie( Example: >>> ds.sum('time').fxplot.pie() # Sum over time, then pie chart + >>> ds.sum('time').fxplot.pie(facet_col='scenario') # Pie per scenario """ - # Check that all variables are scalar - non_scalar = [v for v in self._ds.data_vars if self._ds[v].ndim > 0] - if non_scalar: + # Check dimensionality - allow at most 1D for faceting + max_ndim = max((self._ds[v].ndim for v in self._ds.data_vars), default=0) + if max_ndim > 1: raise ValueError( - f'Pie chart requires scalar values per variable. ' - f'Non-scalar variables: {non_scalar}. ' - f"Try reducing first: ds.sum('time').fxplot.pie()" + 'Pie chart requires at most 1D data per variable (for faceting). ' + "Try reducing first: ds.sum('time').fxplot.pie()" ) names = list(self._ds.data_vars) - values = [float(self._ds[v].values) for v in names] - df = pd.DataFrame({'variable': names, 'value': values}) - color_map = process_colors(colors, names, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) - return px.pie( - df, - names='variable', - values='value', - title=title, - color='variable', - color_discrete_map=color_map, + # Scalar case - single pie + if max_ndim == 0: + values = [float(self._ds[v].values) for v in names] + df = pd.DataFrame({'variable': names, 'value': values}) + return px.pie( + df, + names='variable', + values='value', + title=title, + color='variable', + color_discrete_map=color_map, + **px_kwargs, + ) + + # 1D case - faceted pies + df = _dataset_to_long_df(self._ds) + if df.empty: + return go.Figure() + + actual_facet_col, actual_facet_row, _ = _resolve_auto_facets(self._ds, facet_col, facet_row, None) + + facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols + fig_kwargs: dict[str, Any] = { + 'data_frame': df, + 'names': 'variable', + 'values': 'value', + 'title': title, + 'color': 'variable', + 'color_discrete_map': color_map, **px_kwargs, - ) + } + + if actual_facet_col: + fig_kwargs['facet_col'] = actual_facet_col + if facet_col_wrap < self._ds.sizes.get(actual_facet_col, facet_col_wrap + 1): + fig_kwargs['facet_col_wrap'] = facet_col_wrap + if actual_facet_row: + fig_kwargs['facet_row'] = actual_facet_row + + return px.pie(**fig_kwargs) def duration_curve( self, From 7be17d02e786ca4b8a5080857e6043935586c1cc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:35:16 +0100 Subject: [PATCH 07/22] Improve auto dim handling --- flixopt/dataset_plot_accessor.py | 46 ++++++++++++++++---------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py index 112d15412..276081dee 100644 --- a/flixopt/dataset_plot_accessor.py +++ b/flixopt/dataset_plot_accessor.py @@ -135,8 +135,8 @@ def bar( xlabel: str = '', ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', - facet_row: str | Literal['auto'] | None = None, - animation_frame: str | Literal['auto'] | None = None, + facet_row: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, **px_kwargs: Any, ) -> go.Figure: @@ -203,8 +203,8 @@ def stacked_bar( xlabel: str = '', ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', - facet_row: str | Literal['auto'] | None = None, - animation_frame: str | Literal['auto'] | None = None, + facet_row: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, **px_kwargs: Any, ) -> go.Figure: @@ -276,8 +276,8 @@ def line( xlabel: str = '', ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', - facet_row: str | Literal['auto'] | None = None, - animation_frame: str | Literal['auto'] | None = None, + facet_row: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, line_shape: str | None = None, **px_kwargs: Any, @@ -349,8 +349,8 @@ def area( xlabel: str = '', ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', - facet_row: str | Literal['auto'] | None = None, - animation_frame: str | Literal['auto'] | None = None, + facet_row: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, line_shape: str | None = None, **px_kwargs: Any, @@ -418,7 +418,7 @@ def heatmap( colors: str | list[str] | None = None, title: str = '', facet_col: str | Literal['auto'] | None = 'auto', - animation_frame: str | Literal['auto'] | None = None, + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, **imshow_kwargs: Any, ) -> go.Figure: @@ -487,8 +487,8 @@ def scatter( xlabel: str = '', ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', - facet_row: str | Literal['auto'] | None = None, - animation_frame: str | Literal['auto'] | None = None, + facet_row: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, **px_kwargs: Any, ) -> go.Figure: @@ -553,7 +553,7 @@ def pie( colors: ColorType | None = None, title: str = '', facet_col: str | Literal['auto'] | None = 'auto', - facet_row: str | Literal['auto'] | None = None, + facet_row: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, **px_kwargs: Any, ) -> go.Figure: @@ -638,8 +638,8 @@ def duration_curve( ylabel: str = '', normalize: bool = True, facet_col: str | Literal['auto'] | None = 'auto', - facet_row: str | Literal['auto'] | None = None, - animation_frame: str | Literal['auto'] | None = None, + facet_row: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, line_shape: str | None = None, **px_kwargs: Any, @@ -744,8 +744,8 @@ def bar( xlabel: str = '', ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', - facet_row: str | Literal['auto'] | None = None, - animation_frame: str | Literal['auto'] | None = None, + facet_row: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, **px_kwargs: Any, ) -> go.Figure: @@ -770,8 +770,8 @@ def stacked_bar( xlabel: str = '', ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', - facet_row: str | Literal['auto'] | None = None, - animation_frame: str | Literal['auto'] | None = None, + facet_row: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, **px_kwargs: Any, ) -> go.Figure: @@ -796,8 +796,8 @@ def line( xlabel: str = '', ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', - facet_row: str | Literal['auto'] | None = None, - animation_frame: str | Literal['auto'] | None = None, + facet_row: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, line_shape: str | None = None, **px_kwargs: Any, @@ -824,8 +824,8 @@ def area( xlabel: str = '', ylabel: str = '', facet_col: str | Literal['auto'] | None = 'auto', - facet_row: str | Literal['auto'] | None = None, - animation_frame: str | Literal['auto'] | None = None, + facet_row: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, line_shape: str | None = None, **px_kwargs: Any, @@ -850,7 +850,7 @@ def heatmap( colors: str | list[str] | None = None, title: str = '', facet_col: str | Literal['auto'] | None = 'auto', - animation_frame: str | Literal['auto'] | None = None, + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, **imshow_kwargs: Any, ) -> go.Figure: From da72bb85a893433225c8fac10282f1e94fd873aa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:39:45 +0100 Subject: [PATCH 08/22] Improve notebook --- docs/notebooks/fxplot_accessor_demo.ipynb | 268 +++++++++++++++++++--- 1 file changed, 238 insertions(+), 30 deletions(-) diff --git a/docs/notebooks/fxplot_accessor_demo.ipynb b/docs/notebooks/fxplot_accessor_demo.ipynb index 71f9b8245..934b819cb 100644 --- a/docs/notebooks/fxplot_accessor_demo.ipynb +++ b/docs/notebooks/fxplot_accessor_demo.ipynb @@ -151,9 +151,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Multi-Dimensional Data with Auto-Faceting\n", + "## Automatic Faceting & Animation\n", "\n", - "The accessor automatically handles extra dimensions by assigning them to facets or animation based on CONFIG priority." + "Extra dimensions are **automatically** assigned to `facet_col`, `facet_row`, and `animation_frame` based on CONFIG priority. Just call the plot method - no configuration needed!" ] }, { @@ -162,35 +162,20 @@ "metadata": {}, "outputs": [], "source": [ - "# Dataset with scenario dimension\n", - "ds_scenarios = xr.Dataset(\n", + "# Dataset with scenario AND period dimensions\n", + "ds_multi = xr.Dataset(\n", " {\n", - " 'Solar': (\n", - " ['time', 'scenario'],\n", - " np.column_stack(\n", - " [\n", - " np.maximum(0, np.sin(np.linspace(0, 2 * np.pi, 24)) * 50),\n", - " np.maximum(0, np.sin(np.linspace(0, 2 * np.pi, 24)) * 70), # High scenario\n", - " ]\n", - " ),\n", - " ),\n", - " 'Wind': (\n", - " ['time', 'scenario'],\n", - " np.column_stack(\n", - " [\n", - " np.abs(np.random.randn(24) * 20 + 30),\n", - " np.abs(np.random.randn(24) * 25 + 40), # High scenario\n", - " ]\n", - " ),\n", - " ),\n", + " 'Solar': (['time', 'scenario', 'period'], np.random.rand(24, 2, 3) * 50),\n", + " 'Wind': (['time', 'scenario', 'period'], np.random.rand(24, 2, 3) * 40 + 20),\n", " },\n", " coords={\n", " 'time': time,\n", " 'scenario': ['base', 'high'],\n", + " 'period': ['winter', 'spring', 'summer'],\n", " },\n", ")\n", "\n", - "ds_scenarios" + "ds_multi" ] }, { @@ -199,8 +184,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Auto-faceting assigns 'scenario' to facet_col\n", - "ds_scenarios.fxplot.line(title='Generation by Scenario (Auto-Faceted)')" + "# Just call .line() - dimensions are auto-assigned to facet_col, facet_row, animation_frame\n", + "ds_multi.fxplot.line(title='Auto-Faceted: Just Works!')" ] }, { @@ -209,15 +194,17 @@ "metadata": {}, "outputs": [], "source": [ - "# Explicit faceting\n", - "ds_scenarios.fxplot.stacked_bar(facet_col='scenario', title='Stacked Bar by Scenario')" + "# Same for stacked bar - auto-assigns period to facet_col, scenario to animation\n", + "ds_multi.fxplot.stacked_bar(title='Stacked Bar: Also Just Works!')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Animation Support" + "## Customizing Facets & Animation\n", + "\n", + "Override auto-assignment when needed. Use `None` to disable a slot entirely." ] }, { @@ -226,8 +213,28 @@ "metadata": {}, "outputs": [], "source": [ - "# Use animation instead of faceting\n", - "ds_scenarios.fxplot.area(facet_col=None, animation_frame='scenario', title='Animated by Scenario')" + "# Swap: put scenario in facet_col, period in animation\n", + "ds_multi.fxplot.line(facet_col='scenario', animation_frame='period', title='Swapped: Scenario in Columns')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Use both row and column facets - no animation\n", + "ds_multi.fxplot.area(facet_col='scenario', facet_row='period', animation_frame=None, title='Grid: Period × Scenario')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Or reduce dimensions with .sel() for a simpler plot\n", + "ds_multi.sel(scenario='base', period='summer').fxplot.line(title='Single Slice: No Faceting Needed')" ] }, { @@ -309,6 +316,207 @@ "# Select specific variables\n", "ds_simple[['Solar', 'Wind']].fxplot.area(title='Renewables Only')" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## DataArray Accessor\n", + "\n", + "The `.fxplot` accessor also works on `xr.DataArray` objects directly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a DataArray\n", + "da = xr.DataArray(\n", + " np.random.randn(24, 7) * 5 + 20,\n", + " dims=['time', 'day'],\n", + " coords={\n", + " 'time': pd.date_range('2024-01-01', periods=24, freq='h'),\n", + " 'day': pd.date_range('2024-01-01', periods=7, freq='D'),\n", + " },\n", + " name='temperature',\n", + ")\n", + "\n", + "# Heatmap directly from DataArray\n", + "da.fxplot.heatmap(title='DataArray Heatmap')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Line plot from DataArray (converts to Dataset internally)\n", + "da_1d = xr.DataArray(\n", + " np.sin(np.linspace(0, 4 * np.pi, 100)) * 50,\n", + " dims=['time'],\n", + " coords={'time': pd.date_range('2024-01-01', periods=100, freq='h')},\n", + " name='signal',\n", + ")\n", + "da_1d.fxplot.line(title='DataArray Line Plot')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Axis Labels\n", + "\n", + "Use `xlabel` and `ylabel` parameters to customize axis labels." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ds_simple.fxplot.line(title='Generation with Custom Axis Labels', xlabel='Time of Day', ylabel='Power [MW]')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Scatter Plot\n", + "\n", + "Plot two variables against each other." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Basic scatter plot\n", + "ds_simple.fxplot.scatter(\n", + " x='Solar', y='Demand', title='Solar vs Demand Correlation', xlabel='Solar Generation [MW]', ylabel='Demand [MW]'\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Scatter with faceting by period, for one scenario\n", + "ds_multi.sel(scenario='high').fxplot.scatter(\n", + " x='Solar', y='Wind', facet_col='period', title='Solar vs Wind by Period (High Scenario)'\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pie Chart\n", + "\n", + "Aggregate data to at most 1D per variable. Scalar data creates a single pie; 1D data creates faceted pies." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Single pie from scalar values (sum over time)\n", + "ds_simple[['Solar', 'Wind']].sum('time').fxplot.pie(\n", + " title='Total Generation by Source', colors={'Solar': 'gold', 'Wind': 'skyblue'}\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Faceted pie - one pie per period (for one scenario)\n", + "ds_multi.sum('time').fxplot.pie(\n", + " facet_col='period',\n", + " title='Generation by Source per Period (Base Scenario)',\n", + " colors={'Solar': 'gold', 'Wind': 'skyblue'},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Duration Curve\n", + "\n", + "Sort values along the time dimension to create a duration curve. Useful for analyzing capacity utilization." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Duration curve with normalized x-axis (percentage)\n", + "ds_simple.fxplot.duration_curve(title='Generation Duration Curves')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Duration curve with absolute timesteps on x-axis\n", + "ds_simple.fxplot.duration_curve(normalize=False, title='Duration Curves (Timesteps)')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Duration curve with faceting by period (for one scenario)\n", + "ds_multi.sel(scenario='base').fxplot.duration_curve(facet_col='period', title='Duration Curves by Period')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Line Shape Configuration\n", + "\n", + "The default line shape is controlled by `CONFIG.Plotting.default_line_shape` (default: `'hv'` for step plots).\n", + "Override per-plot with the `line_shape` parameter. Options: `'linear'`, `'hv'`, `'vh'`, `'hvh'`, `'vhv'`, `'spline'`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Default step plot (hv)\n", + "ds_simple[['Solar']].fxplot.line(title='Default Step Plot (hv)')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Override to linear interpolation\n", + "ds_simple[['Solar']].fxplot.line(line_shape='linear', title='Linear Interpolation')" + ] } ], "metadata": { From 31bfb85d957061cec2ab3c1c4fbb39de894a0559 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:44:35 +0100 Subject: [PATCH 09/22] Fix pie plot --- flixopt/dataset_plot_accessor.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py index 276081dee..ed235074b 100644 --- a/flixopt/dataset_plot_accessor.py +++ b/flixopt/dataset_plot_accessor.py @@ -554,19 +554,21 @@ def pie( title: str = '', facet_col: str | Literal['auto'] | None = 'auto', facet_row: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = 'auto', facet_cols: int | None = None, **px_kwargs: Any, ) -> go.Figure: """Create a pie chart from aggregated dataset values. - The dataset should be reduced so each variable has at most one remaining - dimension (for faceting). For scalar values, a single pie is shown. + Extra dimensions are auto-assigned to facet_col, facet_row, and animation_frame. + For scalar values, a single pie is shown. Args: colors: Color specification (colorscale name, color list, or dict mapping). title: Plot title. facet_col: Dimension for column facets. 'auto' uses CONFIG priority. - facet_row: Dimension for row facets. + facet_row: Dimension for row facets. 'auto' uses CONFIG priority. + animation_frame: Dimension for animation slider. 'auto' uses CONFIG priority. facet_cols: Number of columns in facet grid wrap. **px_kwargs: Additional arguments passed to plotly.express.pie. @@ -577,13 +579,7 @@ def pie( >>> ds.sum('time').fxplot.pie() # Sum over time, then pie chart >>> ds.sum('time').fxplot.pie(facet_col='scenario') # Pie per scenario """ - # Check dimensionality - allow at most 1D for faceting max_ndim = max((self._ds[v].ndim for v in self._ds.data_vars), default=0) - if max_ndim > 1: - raise ValueError( - 'Pie chart requires at most 1D data per variable (for faceting). ' - "Try reducing first: ds.sum('time').fxplot.pie()" - ) names = list(self._ds.data_vars) color_map = process_colors(colors, names, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) @@ -602,12 +598,14 @@ def pie( **px_kwargs, ) - # 1D case - faceted pies + # Multi-dimensional case - faceted/animated pies df = _dataset_to_long_df(self._ds) if df.empty: return go.Figure() - actual_facet_col, actual_facet_row, _ = _resolve_auto_facets(self._ds, facet_col, facet_row, None) + actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( + self._ds, facet_col, facet_row, animation_frame + ) facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols fig_kwargs: dict[str, Any] = { @@ -626,6 +624,8 @@ def pie( fig_kwargs['facet_col_wrap'] = facet_col_wrap if actual_facet_row: fig_kwargs['facet_row'] = actual_facet_row + if actual_anim: + fig_kwargs['animation_frame'] = actual_anim return px.pie(**fig_kwargs) From 450ec0e7a7db8e8909907066f4b2e8e60c4d9d42 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:36:02 +0100 Subject: [PATCH 10/22] Logic order changed: 1. X-axis is now determined first using CONFIG.Plotting.x_dim_priority 2. Facets are resolved from remaining dimensions (x-axis excluded) x_dim_priority expanded: x_dim_priority = ('time', 'duration', 'duration_pct', 'period', 'scenario', 'cluster') - Time-like dims first, then common grouping dims as fallback - variable stays excluded (it's used for color, not x-axis) _get_x_dim() refactored: - Now takes dims: list[str] instead of a DataFrame - More versatile - works with any list of dimension names --- flixopt/config.py | 5 + flixopt/dataset_plot_accessor.py | 165 ++++++++++++++++++------------- 2 files changed, 102 insertions(+), 68 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index ad5db2897..87d16615a 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -166,6 +166,7 @@ def format(self, record): 'default_line_shape': 'hv', 'extra_dim_priority': ('cluster', 'period', 'scenario'), 'dim_slot_priority': ('facet_col', 'facet_row', 'animation_frame'), + 'x_dim_priority': ('time', 'duration', 'duration_pct', 'period', 'scenario', 'cluster'), } ), 'solving': MappingProxyType( @@ -565,6 +566,8 @@ class Plotting: Default: ('cluster', 'period', 'scenario'). dim_slot_priority: Order of slots to fill with extra dimensions. Default: ('facet_col', 'facet_row', 'animation_frame'). + x_dim_priority: Order of dimensions to prefer for x-axis when 'auto'. + Default: ('time', 'duration', 'duration_pct'). Examples: ```python @@ -589,6 +592,7 @@ class Plotting: default_line_shape: str = _DEFAULTS['plotting']['default_line_shape'] extra_dim_priority: tuple[str, ...] = _DEFAULTS['plotting']['extra_dim_priority'] dim_slot_priority: tuple[str, ...] = _DEFAULTS['plotting']['dim_slot_priority'] + x_dim_priority: tuple[str, ...] = _DEFAULTS['plotting']['x_dim_priority'] class Carriers: """Default carrier definitions for common energy types. @@ -692,6 +696,7 @@ def to_dict(cls) -> dict: 'default_line_shape': cls.Plotting.default_line_shape, 'extra_dim_priority': cls.Plotting.extra_dim_priority, 'dim_slot_priority': cls.Plotting.dim_slot_priority, + 'x_dim_priority': cls.Plotting.x_dim_priority, }, } diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py index ed235074b..34253327b 100644 --- a/flixopt/dataset_plot_accessor.py +++ b/flixopt/dataset_plot_accessor.py @@ -25,11 +25,34 @@ from .config import CONFIG +def _get_x_dim(dims: list[str], x: str | Literal['auto'] | None = 'auto') -> str: + """Determine the x-axis dimension from available dimensions. + + Args: + dims: List of available dimension names. + x: Explicit dimension name, 'auto' to use priority list, or None. + + Returns: + Dimension name to use for x-axis. + """ + if x and x != 'auto': + return x + + # Check priority list first + for dim in CONFIG.Plotting.x_dim_priority: + if dim in dims: + return dim + + # Fallback to first available dimension + return dims[0] if dims else '' + + def _resolve_auto_facets( ds: xr.Dataset, facet_col: str | Literal['auto'] | None, facet_row: str | Literal['auto'] | None, animation_frame: str | Literal['auto'] | None = None, + exclude_dims: set[str] | None = None, ) -> tuple[str | None, str | None, str | None]: """Resolve 'auto' facet/animation dimensions based on available data dimensions. @@ -42,13 +65,15 @@ def _resolve_auto_facets( facet_col: Dimension name, 'auto', or None. facet_row: Dimension name, 'auto', or None. animation_frame: Dimension name, 'auto', or None. + exclude_dims: Dimensions to exclude (e.g., x-axis dimension). Returns: Tuple of (resolved_facet_col, resolved_facet_row, resolved_animation_frame). Each is either a valid dimension name or None. """ - # Get available extra dimensions with size > 1, sorted by priority - available = {d for d in ds.dims if ds.sizes[d] > 1} + # Get available extra dimensions with size > 1, excluding specified dims + exclude = exclude_dims or set() + available = {d for d in ds.dims if ds.sizes[d] > 1 and d not in exclude} extra_dims = [d for d in CONFIG.Plotting.extra_dim_priority if d in available] used: set[str] = set() @@ -130,6 +155,7 @@ def __init__(self, xarray_obj: xr.Dataset) -> None: def bar( self, *, + x: str | Literal['auto'] | None = 'auto', colors: ColorType | None = None, title: str = '', xlabel: str = '', @@ -143,6 +169,7 @@ def bar( """Create a grouped bar chart from the dataset. Args: + x: Dimension for x-axis. 'auto' uses CONFIG.Plotting.x_dim_priority. colors: Color specification (colorscale name, color list, or dict mapping). title: Plot title. xlabel: X-axis label. @@ -156,18 +183,20 @@ def bar( Returns: Plotly Figure. """ + # Determine x-axis first, then resolve facets from remaining dims + dims = list(self._ds.dims) + x_col = _get_x_dim(dims, x) + actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( + self._ds, facet_col, facet_row, animation_frame, exclude_dims={x_col} + ) + df = _dataset_to_long_df(self._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 = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) - actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( - self._ds, facet_col, facet_row, animation_frame - ) - facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols fig_kwargs: dict[str, Any] = { 'data_frame': df, @@ -198,6 +227,7 @@ def bar( def stacked_bar( self, *, + x: str | Literal['auto'] | None = 'auto', colors: ColorType | None = None, title: str = '', xlabel: str = '', @@ -214,6 +244,7 @@ def stacked_bar( values are stacked separately. Args: + x: Dimension for x-axis. 'auto' uses CONFIG.Plotting.x_dim_priority. colors: Color specification (colorscale name, color list, or dict mapping). title: Plot title. xlabel: X-axis label. @@ -227,18 +258,20 @@ def stacked_bar( Returns: Plotly Figure. """ + # Determine x-axis first, then resolve facets from remaining dims + dims = list(self._ds.dims) + x_col = _get_x_dim(dims, x) + actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( + self._ds, facet_col, facet_row, animation_frame, exclude_dims={x_col} + ) + df = _dataset_to_long_df(self._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 = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) - actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( - self._ds, facet_col, facet_row, animation_frame - ) - facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols fig_kwargs: dict[str, Any] = { 'data_frame': df, @@ -271,6 +304,7 @@ def stacked_bar( def line( self, *, + x: str | Literal['auto'] | None = 'auto', colors: ColorType | None = None, title: str = '', xlabel: str = '', @@ -287,6 +321,7 @@ def line( Each variable in the dataset becomes a separate line. Args: + x: Dimension for x-axis. 'auto' uses CONFIG.Plotting.x_dim_priority. colors: Color specification (colorscale name, color list, or dict mapping). title: Plot title. xlabel: X-axis label. @@ -302,18 +337,20 @@ def line( Returns: Plotly Figure. """ + # Determine x-axis first, then resolve facets from remaining dims + dims = list(self._ds.dims) + x_col = _get_x_dim(dims, x) + actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( + self._ds, facet_col, facet_row, animation_frame, exclude_dims={x_col} + ) + df = _dataset_to_long_df(self._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 = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) - actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( - self._ds, facet_col, facet_row, animation_frame - ) - facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols fig_kwargs: dict[str, Any] = { 'data_frame': df, @@ -344,6 +381,7 @@ def line( def area( self, *, + x: str | Literal['auto'] | None = 'auto', colors: ColorType | None = None, title: str = '', xlabel: str = '', @@ -358,6 +396,7 @@ def area( """Create a stacked area chart from the dataset. Args: + x: Dimension for x-axis. 'auto' uses CONFIG.Plotting.x_dim_priority. colors: Color specification (colorscale name, color list, or dict mapping). title: Plot title. xlabel: X-axis label. @@ -372,18 +411,20 @@ def area( Returns: Plotly Figure. """ + # Determine x-axis first, then resolve facets from remaining dims + dims = list(self._ds.dims) + x_col = _get_x_dim(dims, x) + actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( + self._ds, facet_col, facet_row, animation_frame, exclude_dims={x_col} + ) + df = _dataset_to_long_df(self._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 = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) - actual_facet_col, actual_facet_row, actual_anim = _resolve_auto_facets( - self._ds, facet_col, facet_row, animation_frame - ) - facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols fig_kwargs: dict[str, Any] = { 'data_frame': df, @@ -629,41 +670,37 @@ def pie( return px.pie(**fig_kwargs) - def duration_curve( - self, - *, - colors: ColorType | None = None, - title: str = '', - xlabel: str = '', - ylabel: str = '', - normalize: bool = True, - facet_col: str | Literal['auto'] | None = 'auto', - facet_row: str | Literal['auto'] | None = 'auto', - animation_frame: str | Literal['auto'] | None = 'auto', - facet_cols: int | None = None, - line_shape: str | None = None, - **px_kwargs: Any, - ) -> go.Figure: - """Create a duration curve (sorted values) from the dataset. + +@xr.register_dataset_accessor('fxstats') +class DatasetStatsAccessor: + """Statistics/transformation accessor for any xr.Dataset. Access via ``dataset.fxstats``. + + Provides data transformation methods that return new datasets. + Chain with ``.fxplot`` for visualization. + + Examples: + Duration curve:: + + ds.fxstats.to_duration_curve().fxplot.line() + """ + + def __init__(self, xarray_obj: xr.Dataset) -> None: + self._ds = xarray_obj + + def to_duration_curve(self, *, normalize: bool = True) -> xr.Dataset: + """Transform dataset to duration curve format (sorted values). Values are sorted in descending order along the 'time' dimension. - The x-axis shows duration (percentage or timesteps). + The time coordinate is replaced with duration (percentage or index). Args: - colors: Color specification (colorscale name, color list, or dict mapping). - title: Plot title. - xlabel: X-axis label. Default 'Duration [%]' or 'Timesteps'. - ylabel: Y-axis label. normalize: If True, x-axis shows percentage (0-100). If False, shows timestep index. - facet_col: Dimension for column facets. 'auto' uses CONFIG priority. - facet_row: Dimension for row facets. - animation_frame: Dimension for animation slider. - facet_cols: Number of columns in facet grid wrap. - line_shape: Line interpolation. Default from CONFIG.Plotting.default_line_shape. - **px_kwargs: Additional arguments passed to plotly.express.line. Returns: - Plotly Figure. + Transformed xr.Dataset with duration coordinate instead of time. + + Example: + >>> ds.fxstats.to_duration_curve().fxplot.line(title='Duration Curve') """ import numpy as np @@ -674,7 +711,7 @@ def duration_curve( sorted_ds = self._ds.copy() for var in sorted_ds.data_vars: da = sorted_ds[var] - # Sort along time axis + # Sort along time axis (descending) sorted_values = np.sort(da.values, axis=da.dims.index('time'))[::-1] sorted_ds[var] = (da.dims, sorted_values) @@ -684,28 +721,12 @@ def duration_curve( duration_coord = np.linspace(0, 100, n_timesteps) sorted_ds = sorted_ds.assign_coords({'time': duration_coord}) sorted_ds = sorted_ds.rename({'time': 'duration_pct'}) - default_xlabel = 'Duration [%]' else: duration_coord = np.arange(n_timesteps) sorted_ds = sorted_ds.assign_coords({'time': duration_coord}) sorted_ds = sorted_ds.rename({'time': 'duration'}) - default_xlabel = 'Timesteps' - - # Use line plot - fig = sorted_ds.fxplot.line( - colors=colors, - title=title or 'Duration Curve', - xlabel=xlabel or default_xlabel, - ylabel=ylabel, - facet_col=facet_col, - facet_row=facet_row, - animation_frame=animation_frame, - facet_cols=facet_cols, - line_shape=line_shape, - **px_kwargs, - ) - return fig + return sorted_ds @xr.register_dataarray_accessor('fxplot') @@ -739,6 +760,7 @@ def _to_dataset(self) -> xr.Dataset: def bar( self, *, + x: str | Literal['auto'] | None = 'auto', colors: ColorType | None = None, title: str = '', xlabel: str = '', @@ -751,6 +773,7 @@ def bar( ) -> go.Figure: """Create a grouped bar chart. See DatasetPlotAccessor.bar for details.""" return self._to_dataset().fxplot.bar( + x=x, colors=colors, title=title, xlabel=xlabel, @@ -765,6 +788,7 @@ def bar( def stacked_bar( self, *, + x: str | Literal['auto'] | None = 'auto', colors: ColorType | None = None, title: str = '', xlabel: str = '', @@ -777,6 +801,7 @@ def stacked_bar( ) -> go.Figure: """Create a stacked bar chart. See DatasetPlotAccessor.stacked_bar for details.""" return self._to_dataset().fxplot.stacked_bar( + x=x, colors=colors, title=title, xlabel=xlabel, @@ -791,6 +816,7 @@ def stacked_bar( def line( self, *, + x: str | Literal['auto'] | None = 'auto', colors: ColorType | None = None, title: str = '', xlabel: str = '', @@ -804,6 +830,7 @@ def line( ) -> go.Figure: """Create a line chart. See DatasetPlotAccessor.line for details.""" return self._to_dataset().fxplot.line( + x=x, colors=colors, title=title, xlabel=xlabel, @@ -819,6 +846,7 @@ def line( def area( self, *, + x: str | Literal['auto'] | None = 'auto', colors: ColorType | None = None, title: str = '', xlabel: str = '', @@ -832,6 +860,7 @@ def area( ) -> go.Figure: """Create a stacked area chart. See DatasetPlotAccessor.area for details.""" return self._to_dataset().fxplot.area( + x=x, colors=colors, title=title, xlabel=xlabel, From 29752381a1501f41e02777fe08f2440273db7970 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:24:26 +0100 Subject: [PATCH 11/22] Add x parameter and x_dim_priority config to fxplot - Add `x` parameter to bar/stacked_bar/line/area for explicit x-axis control - Add CONFIG.Plotting.x_dim_priority for auto x-axis selection order - X-axis determined first, facets from remaining dimensions - Refactor _get_x_column -> _get_x_dim (takes dim list, not DataFrame) - Support scalar data (no dims) by using 'variable' as x-axis --- docs/notebooks/fxplot_accessor_demo.ipynb | 41 +++++++++++++++++------ flixopt/dataset_plot_accessor.py | 6 ++-- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/docs/notebooks/fxplot_accessor_demo.ipynb b/docs/notebooks/fxplot_accessor_demo.ipynb index 934b819cb..bcf065cc8 100644 --- a/docs/notebooks/fxplot_accessor_demo.ipynb +++ b/docs/notebooks/fxplot_accessor_demo.ipynb @@ -198,6 +198,16 @@ "ds_multi.fxplot.stacked_bar(title='Stacked Bar: Also Just Works!')" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Same for stacked bar - auto-assigns period to facet_col, scenario to animation\n", + "ds_multi.sum('time').fxplot.stacked_bar(title='Stacked Bar: Also Just Works!', x='variable', colors=None)" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -224,7 +234,9 @@ "outputs": [], "source": [ "# Use both row and column facets - no animation\n", - "ds_multi.fxplot.area(facet_col='scenario', facet_row='period', animation_frame=None, title='Grid: Period × Scenario')" + "ds_multi.sum('time').fxplot.area(\n", + " facet_col='scenario', facet_row='period', animation_frame=None, title='Grid: Period × Scenario'\n", + ")" ] }, { @@ -441,10 +453,9 @@ "metadata": {}, "outputs": [], "source": [ - "# Faceted pie - one pie per period (for one scenario)\n", + "# Faceted pie - auto-assigns scenario and period to facets\n", "ds_multi.sum('time').fxplot.pie(\n", - " facet_col='period',\n", - " title='Generation by Source per Period (Base Scenario)',\n", + " title='Generation by Source (Scenario × Period)',\n", " colors={'Solar': 'gold', 'Wind': 'skyblue'},\n", ")" ] @@ -455,7 +466,7 @@ "source": [ "## Duration Curve\n", "\n", - "Sort values along the time dimension to create a duration curve. Useful for analyzing capacity utilization." + "Use `.fxstats.to_duration_curve()` to transform data, then `.fxplot.line()` to plot. Clean separation of transformation and plotting." ] }, { @@ -465,7 +476,7 @@ "outputs": [], "source": [ "# Duration curve with normalized x-axis (percentage)\n", - "ds_simple.fxplot.duration_curve(title='Generation Duration Curves')" + "ds_simple.fxstats.to_duration_curve().fxplot.line(title='Duration Curves', xlabel='Duration [%]')" ] }, { @@ -474,8 +485,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Duration curve with absolute timesteps on x-axis\n", - "ds_simple.fxplot.duration_curve(normalize=False, title='Duration Curves (Timesteps)')" + "# Duration curve with absolute timesteps\n", + "ds_simple.fxstats.to_duration_curve(normalize=False).fxplot.line(title='Duration Curves', xlabel='Timesteps')" ] }, { @@ -484,8 +495,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Duration curve with faceting by period (for one scenario)\n", - "ds_multi.sel(scenario='base').fxplot.duration_curve(facet_col='period', title='Duration Curves by Period')" + "# Duration curve with auto-faceting - works seamlessly!\n", + "ds_multi.fxstats.to_duration_curve().fxplot.line(title='Duration Curves (Auto-Faceted)', xlabel='Duration [%]')" ] }, { @@ -526,8 +537,16 @@ "name": "python3" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.11.0" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" } }, "nbformat": 4, diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py index 34253327b..acc8a0d44 100644 --- a/flixopt/dataset_plot_accessor.py +++ b/flixopt/dataset_plot_accessor.py @@ -33,7 +33,7 @@ def _get_x_dim(dims: list[str], x: str | Literal['auto'] | None = 'auto') -> str x: Explicit dimension name, 'auto' to use priority list, or None. Returns: - Dimension name to use for x-axis. + Dimension name to use for x-axis. Returns 'variable' for scalar data. """ if x and x != 'auto': return x @@ -43,8 +43,8 @@ def _get_x_dim(dims: list[str], x: str | Literal['auto'] | None = 'auto') -> str if dim in dims: return dim - # Fallback to first available dimension - return dims[0] if dims else '' + # Fallback to first available dimension, or 'variable' for scalar data + return dims[0] if dims else 'variable' def _resolve_auto_facets( From 21350db6d56fd572f302e69d6d8b0ccf7744a1ee Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:32:10 +0100 Subject: [PATCH 12/22] Add x parameter and smart dimension handling to fxplot - Add `x` parameter to bar/stacked_bar/line/area for explicit x-axis control - Add CONFIG.Plotting.x_dim_priority for auto x-axis selection Default: ('time', 'duration', 'duration_pct', 'period', 'scenario', 'cluster') - X-axis determined first, facets resolved from remaining dimensions - Refactor _get_x_column -> _get_x_dim (takes dim list, more versatile) - Support scalar data (no dims) by using 'variable' as x-axis - Skip color='variable' when x='variable' to avoid double encoding - Fix _dataset_to_long_df to use dims (not just coords) as id_vars --- flixopt/dataset_plot_accessor.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py index acc8a0d44..ba8bbe6d8 100644 --- a/flixopt/dataset_plot_accessor.py +++ b/flixopt/dataset_plot_accessor.py @@ -112,9 +112,9 @@ def _dataset_to_long_df(ds: xr.Dataset, value_name: str = 'value', var_name: str rows = [{var_name: var, value_name: float(ds[var].values)} for var in ds.data_vars] return pd.DataFrame(rows) df = ds.to_dataframe().reset_index() - # Only use coordinates that are actually present as columns after reset_index - coord_cols = [c for c in ds.coords.keys() if c in df.columns] - return df.melt(id_vars=coord_cols, var_name=var_name, value_name=value_name) + # Use dims (not just coords) as id_vars - dims without coords become integer indices + id_cols = [c for c in ds.dims if c in df.columns] + return df.melt(id_vars=id_cols, var_name=var_name, value_name=value_name) @xr.register_dataset_accessor('fxplot') @@ -202,12 +202,14 @@ def bar( 'data_frame': df, 'x': x_col, 'y': 'value', - 'color': 'variable', - 'color_discrete_map': color_map, 'title': title, 'barmode': 'group', **px_kwargs, } + # Only color by variable if it's not already on x-axis + if x_col != 'variable': + fig_kwargs['color'] = 'variable' + fig_kwargs['color_discrete_map'] = color_map if xlabel: fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x_col: xlabel} if ylabel: @@ -277,11 +279,13 @@ def stacked_bar( 'data_frame': df, 'x': x_col, 'y': 'value', - 'color': 'variable', - 'color_discrete_map': color_map, 'title': title, **px_kwargs, } + # Only color by variable if it's not already on x-axis + if x_col != 'variable': + fig_kwargs['color'] = 'variable' + fig_kwargs['color_discrete_map'] = color_map if xlabel: fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x_col: xlabel} if ylabel: @@ -356,12 +360,14 @@ def line( 'data_frame': df, 'x': x_col, 'y': 'value', - 'color': 'variable', - 'color_discrete_map': color_map, 'title': title, 'line_shape': line_shape or CONFIG.Plotting.default_line_shape, **px_kwargs, } + # Only color by variable if it's not already on x-axis + if x_col != 'variable': + fig_kwargs['color'] = 'variable' + fig_kwargs['color_discrete_map'] = color_map if xlabel: fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x_col: xlabel} if ylabel: @@ -430,12 +436,14 @@ def area( 'data_frame': df, 'x': x_col, 'y': 'value', - 'color': 'variable', - 'color_discrete_map': color_map, 'title': title, 'line_shape': line_shape or CONFIG.Plotting.default_line_shape, **px_kwargs, } + # Only color by variable if it's not already on x-axis + if x_col != 'variable': + fig_kwargs['color'] = 'variable' + fig_kwargs['color_discrete_map'] = color_map if xlabel: fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x_col: xlabel} if ylabel: From d1f1a39b7f59ba1e6ddcc6dc88aa0685c8bc40ec Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:55:44 +0100 Subject: [PATCH 13/22] Add x parameter and smart dimension handling to fxplot - Add `x` parameter to bar/stacked_bar/line/area for explicit x-axis control - Add CONFIG.Plotting.x_dim_priority for auto x-axis selection Default: ('time', 'duration', 'duration_pct', 'period', 'scenario', 'cluster') - X-axis determined first, facets resolved from remaining dimensions - Refactor _get_x_column -> _get_x_dim (takes dim list, more versatile) - Support scalar data (no dims) by using 'variable' as x-axis - Skip color='variable' when x='variable' to avoid double encoding - Fix _dataset_to_long_df to use dims (not just coords) as id_vars - Ensure px_kwargs properly overrides all defaults (color, facets, etc.) --- flixopt/dataset_plot_accessor.py | 60 +++++++++++++++----------------- 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py index ba8bbe6d8..cb9c7ac72 100644 --- a/flixopt/dataset_plot_accessor.py +++ b/flixopt/dataset_plot_accessor.py @@ -204,27 +204,26 @@ def bar( 'y': 'value', 'title': title, 'barmode': 'group', - **px_kwargs, } - # Only color by variable if it's not already on x-axis - if x_col != 'variable': + # Only color by variable if it's not already on x-axis (and user didn't override) + if x_col != 'variable' and 'color' not in px_kwargs: fig_kwargs['color'] = 'variable' fig_kwargs['color_discrete_map'] = color_map if xlabel: - fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x_col: xlabel} + fig_kwargs['labels'] = {x_col: xlabel} if ylabel: fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), 'value': ylabel} - if actual_facet_col: + if actual_facet_col and 'facet_col' not in px_kwargs: fig_kwargs['facet_col'] = actual_facet_col if facet_col_wrap < self._ds.sizes.get(actual_facet_col, facet_col_wrap + 1): fig_kwargs['facet_col_wrap'] = facet_col_wrap - if actual_facet_row: + if actual_facet_row and 'facet_row' not in px_kwargs: fig_kwargs['facet_row'] = actual_facet_row - if actual_anim: + if actual_anim and 'animation_frame' not in px_kwargs: fig_kwargs['animation_frame'] = actual_anim - return px.bar(**fig_kwargs) + return px.bar(**{**fig_kwargs, **px_kwargs}) def stacked_bar( self, @@ -280,27 +279,26 @@ def stacked_bar( 'x': x_col, 'y': 'value', 'title': title, - **px_kwargs, } - # Only color by variable if it's not already on x-axis - if x_col != 'variable': + # Only color by variable if it's not already on x-axis (and user didn't override) + if x_col != 'variable' and 'color' not in px_kwargs: fig_kwargs['color'] = 'variable' fig_kwargs['color_discrete_map'] = color_map if xlabel: - fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x_col: xlabel} + fig_kwargs['labels'] = {x_col: xlabel} if ylabel: fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), 'value': ylabel} - if actual_facet_col: + if actual_facet_col and 'facet_col' not in px_kwargs: fig_kwargs['facet_col'] = actual_facet_col if facet_col_wrap < self._ds.sizes.get(actual_facet_col, facet_col_wrap + 1): fig_kwargs['facet_col_wrap'] = facet_col_wrap - if actual_facet_row: + if actual_facet_row and 'facet_row' not in px_kwargs: fig_kwargs['facet_row'] = actual_facet_row - if actual_anim: + if actual_anim and 'animation_frame' not in px_kwargs: fig_kwargs['animation_frame'] = actual_anim - fig = px.bar(**fig_kwargs) + fig = px.bar(**{**fig_kwargs, **px_kwargs}) fig.update_layout(barmode='relative', bargap=0, bargroupgap=0) fig.update_traces(marker_line_width=0) return fig @@ -362,27 +360,26 @@ def line( 'y': 'value', 'title': title, 'line_shape': line_shape or CONFIG.Plotting.default_line_shape, - **px_kwargs, } - # Only color by variable if it's not already on x-axis - if x_col != 'variable': + # Only color by variable if it's not already on x-axis (and user didn't override) + if x_col != 'variable' and 'color' not in px_kwargs: fig_kwargs['color'] = 'variable' fig_kwargs['color_discrete_map'] = color_map if xlabel: - fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x_col: xlabel} + fig_kwargs['labels'] = {x_col: xlabel} if ylabel: fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), 'value': ylabel} - if actual_facet_col: + if actual_facet_col and 'facet_col' not in px_kwargs: fig_kwargs['facet_col'] = actual_facet_col if facet_col_wrap < self._ds.sizes.get(actual_facet_col, facet_col_wrap + 1): fig_kwargs['facet_col_wrap'] = facet_col_wrap - if actual_facet_row: + if actual_facet_row and 'facet_row' not in px_kwargs: fig_kwargs['facet_row'] = actual_facet_row - if actual_anim: + if actual_anim and 'animation_frame' not in px_kwargs: fig_kwargs['animation_frame'] = actual_anim - return px.line(**fig_kwargs) + return px.line(**{**fig_kwargs, **px_kwargs}) def area( self, @@ -438,27 +435,26 @@ def area( 'y': 'value', 'title': title, 'line_shape': line_shape or CONFIG.Plotting.default_line_shape, - **px_kwargs, } - # Only color by variable if it's not already on x-axis - if x_col != 'variable': + # Only color by variable if it's not already on x-axis (and user didn't override) + if x_col != 'variable' and 'color' not in px_kwargs: fig_kwargs['color'] = 'variable' fig_kwargs['color_discrete_map'] = color_map if xlabel: - fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), x_col: xlabel} + fig_kwargs['labels'] = {x_col: xlabel} if ylabel: fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), 'value': ylabel} - if actual_facet_col: + if actual_facet_col and 'facet_col' not in px_kwargs: fig_kwargs['facet_col'] = actual_facet_col if facet_col_wrap < self._ds.sizes.get(actual_facet_col, facet_col_wrap + 1): fig_kwargs['facet_col_wrap'] = facet_col_wrap - if actual_facet_row: + if actual_facet_row and 'facet_row' not in px_kwargs: fig_kwargs['facet_row'] = actual_facet_row - if actual_anim: + if actual_anim and 'animation_frame' not in px_kwargs: fig_kwargs['animation_frame'] = actual_anim - return px.area(**fig_kwargs) + return px.area(**{**fig_kwargs, **px_kwargs}) def heatmap( self, From 9d40c82d1299dd33895a268f6a46e8c328932387 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:41:21 +0100 Subject: [PATCH 14/22] Improve documentation --- .../recipes/plotting-custom-data.md | 129 +++--------------- flixopt/dataset_plot_accessor.py | 44 +----- mkdocs.yml | 1 + 3 files changed, 22 insertions(+), 152 deletions(-) diff --git a/docs/user-guide/recipes/plotting-custom-data.md b/docs/user-guide/recipes/plotting-custom-data.md index 3c539e6ce..8c19931f3 100644 --- a/docs/user-guide/recipes/plotting-custom-data.md +++ b/docs/user-guide/recipes/plotting-custom-data.md @@ -1,125 +1,30 @@ # Plotting Custom Data -The plot accessor (`flow_system.statistics.plot`) is designed for visualizing optimization results using element labels. If you want to create faceted plots with your own custom data (not from a FlowSystem), you can use Plotly Express directly with xarray data. +While the plot accessor (`flow_system.statistics.plot`) is designed for optimization results, you often need to plot custom xarray data. The `.fxplot` accessor provides the same convenience for any `xr.Dataset` or `xr.DataArray`. -## Faceted Plots with Custom xarray Data - -The key is converting your xarray Dataset to a long-form DataFrame that Plotly Express expects: +## Quick Example ```python +import flixopt as fx import xarray as xr -import pandas as pd -import plotly.express as px -# Your custom xarray Dataset -my_data = xr.Dataset({ - 'Solar': (['time', 'scenario'], solar_values), - 'Wind': (['time', 'scenario'], wind_values), - 'Demand': (['time', 'scenario'], demand_values), -}, coords={ - 'time': timestamps, - 'scenario': ['Base', 'High RE', 'Low Demand'] +ds = xr.Dataset({ + 'Solar': (['time'], solar_values), + 'Wind': (['time'], wind_values), }) -# Convert to long-form DataFrame for Plotly Express -df = ( - my_data - .to_dataframe() - .reset_index() - .melt( - id_vars=['time', 'scenario'], # Keep as columns - var_name='variable', - value_name='value' - ) -) - -# Faceted stacked bar chart -fig = px.bar( - df, - x='time', - y='value', - color='variable', - facet_col='scenario', - barmode='relative', - title='Energy Balance by Scenario' -) -fig.show() - -# Faceted line plot -fig = px.line( - df, - x='time', - y='value', - color='variable', - facet_col='scenario' -) -fig.show() - -# Faceted area chart -fig = px.area( - df, - x='time', - y='value', - color='variable', - facet_col='scenario' -) -fig.show() -``` - -## Common Plotly Express Faceting Options - -| Parameter | Description | -|-----------|-------------| -| `facet_col` | Dimension for column subplots | -| `facet_row` | Dimension for row subplots | -| `animation_frame` | Dimension for animation slider | -| `facet_col_wrap` | Number of columns before wrapping | - -```python -# Row and column facets -fig = px.line(df, x='time', y='value', color='variable', - facet_col='scenario', facet_row='region') - -# Animation over time periods -fig = px.bar(df, x='variable', y='value', color='variable', - animation_frame='period', barmode='group') - -# Wrap columns -fig = px.line(df, x='time', y='value', color='variable', - facet_col='scenario', facet_col_wrap=2) +# Plot directly - no conversion needed! +ds.fxplot.line(title='Energy Generation') +ds.fxplot.stacked_bar(title='Stacked Generation') ``` -## Heatmaps with Custom Data - -For heatmaps, you can pass 2D arrays directly to `px.imshow`: - -```python -import plotly.express as px - -# 2D data (e.g., days × hours) -heatmap_data = my_data['Solar'].sel(scenario='Base').values.reshape(365, 24) +## Full Documentation -fig = px.imshow( - heatmap_data, - labels={'x': 'Hour', 'y': 'Day', 'color': 'Power [kW]'}, - aspect='auto', - color_continuous_scale='portland' -) -fig.show() - -# Faceted heatmaps using subplots -from plotly.subplots import make_subplots -import plotly.graph_objects as go - -scenarios = ['Base', 'High RE'] -fig = make_subplots(rows=1, cols=len(scenarios), subplot_titles=scenarios) - -for i, scenario in enumerate(scenarios, 1): - data = my_data['Solar'].sel(scenario=scenario).values.reshape(365, 24) - fig.add_trace(go.Heatmap(z=data, colorscale='portland'), row=1, col=i) - -fig.update_layout(title='Solar Output by Scenario') -fig.show() -``` +For comprehensive documentation with interactive examples, see the [Custom Data Plotting](../../notebooks/fxplot_accessor_demo.ipynb) notebook which covers: -This approach gives you full control over your visualizations while leveraging Plotly's powerful faceting capabilities. +- All available plot methods (line, bar, stacked_bar, area, scatter, heatmap, pie) +- Automatic x-axis selection and faceting +- Custom colors and axis labels +- Duration curves with `.fxstats.to_duration_curve()` +- Configuration options +- Combining with xarray operations diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py index cb9c7ac72..70f7990c1 100644 --- a/flixopt/dataset_plot_accessor.py +++ b/flixopt/dataset_plot_accessor.py @@ -1,16 +1,4 @@ -"""Dataset plot accessor for xarray Datasets. - -Provides convenient plotting methods for any xr.Dataset via the .fxplot accessor. -This is globally registered and available on all xr.Dataset objects when flixopt is imported. - -Example: - >>> import flixopt - >>> import xarray as xr - >>> ds = xr.Dataset({'temp': (['time', 'location'], data)}) - >>> ds.fxplot.line() # Line plot of all variables - >>> ds.fxplot.stacked_bar() # Stacked bar chart - >>> ds.fxplot.heatmap('temp') # Heatmap of specific variable -""" +"""Xarray accessors for plotting (``.fxplot``) and statistics (``.fxstats``).""" from __future__ import annotations @@ -26,15 +14,7 @@ def _get_x_dim(dims: list[str], x: str | Literal['auto'] | None = 'auto') -> str: - """Determine the x-axis dimension from available dimensions. - - Args: - dims: List of available dimension names. - x: Explicit dimension name, 'auto' to use priority list, or None. - - Returns: - Dimension name to use for x-axis. Returns 'variable' for scalar data. - """ + """Select x-axis dim from priority list, or 'variable' for scalar data.""" if x and x != 'auto': return x @@ -54,23 +34,7 @@ def _resolve_auto_facets( animation_frame: str | Literal['auto'] | None = None, exclude_dims: set[str] | None = None, ) -> tuple[str | None, str | None, str | None]: - """Resolve 'auto' facet/animation dimensions based on available data dimensions. - - When 'auto' is specified, extra dimensions are assigned to slots based on: - - CONFIG.Plotting.extra_dim_priority: Order of dimensions (default: cluster -> period -> scenario) - - CONFIG.Plotting.dim_slot_priority: Order of slots (default: facet_col -> facet_row -> animation_frame) - - Args: - ds: Dataset to check for available dimensions. - facet_col: Dimension name, 'auto', or None. - facet_row: Dimension name, 'auto', or None. - animation_frame: Dimension name, 'auto', or None. - exclude_dims: Dimensions to exclude (e.g., x-axis dimension). - - Returns: - Tuple of (resolved_facet_col, resolved_facet_row, resolved_animation_frame). - Each is either a valid dimension name or None. - """ + """Assign 'auto' facet slots from available dims using CONFIG priority lists.""" # Get available extra dimensions with size > 1, excluding specified dims exclude = exclude_dims or set() available = {d for d in ds.dims if ds.sizes[d] > 1 and d not in exclude} @@ -105,7 +69,7 @@ def _resolve_auto_facets( def _dataset_to_long_df(ds: xr.Dataset, value_name: str = 'value', var_name: str = 'variable') -> pd.DataFrame: - """Convert xarray Dataset to long-form DataFrame for plotly express.""" + """Convert Dataset to long-form DataFrame for Plotly Express.""" if not ds.data_vars: return pd.DataFrame() if all(ds[var].ndim == 0 for var in ds.data_vars): diff --git a/mkdocs.yml b/mkdocs.yml index 493937983..ca94a6302 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,6 +73,7 @@ nav: - Rolling Horizon: notebooks/08b-rolling-horizon.ipynb - Results: - Plotting: notebooks/09-plotting-and-data-access.ipynb + - Custom Data Plotting: notebooks/fxplot_accessor_demo.ipynb - API Reference: api-reference/ From bd314e03ed1625e9d4f68bae9be5fa36665445bd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:06:57 +0100 Subject: [PATCH 15/22] Fix notebook in docs --- docs/notebooks/fxplot_accessor_demo.ipynb | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/notebooks/fxplot_accessor_demo.ipynb b/docs/notebooks/fxplot_accessor_demo.ipynb index bcf065cc8..db8684d82 100644 --- a/docs/notebooks/fxplot_accessor_demo.ipynb +++ b/docs/notebooks/fxplot_accessor_demo.ipynb @@ -25,6 +25,17 @@ "fx.__version__" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import plotly.io as pio\n", + "\n", + "pio.renderers.default = 'notebook_connected'" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -53,7 +64,7 @@ " coords={'time': time},\n", ")\n", "\n", - "ds_simple" + "ds_simple.to_dataframe().head()" ] }, { @@ -175,7 +186,7 @@ " },\n", ")\n", "\n", - "ds_multi" + "ds_multi.to_dataframe().head()" ] }, { From 22702e0550b0d327338172d45323581b09b5c113 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 3 Jan 2026 12:50:47 +0100 Subject: [PATCH 16/22] 1. heatmap kwarg merge order - Now uses **{**imshow_args, **imshow_kwargs} so user can override 2. scatter unused colors - Removed the unused parameter 3. to_duration_curve sorting - Changed [::-1] to np.flip(..., axis=time_axis) for correct multi-dimensional handling 4. DataArrayPlotAccessor.heatmap - Same kwarg merge fix --- flixopt/dataset_plot_accessor.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py index 70f7990c1..fc38f730b 100644 --- a/flixopt/dataset_plot_accessor.py +++ b/flixopt/dataset_plot_accessor.py @@ -473,7 +473,6 @@ def heatmap( 'img': da, 'color_continuous_scale': colors, 'title': title or variable, - **imshow_kwargs, } if actual_facet_col and actual_facet_col in da.dims: @@ -484,14 +483,13 @@ def heatmap( if actual_anim and actual_anim in da.dims: imshow_args['animation_frame'] = actual_anim - return px.imshow(**imshow_args) + return px.imshow(**{**imshow_args, **imshow_kwargs}) def scatter( self, x: str, y: str, *, - colors: ColorType | None = None, title: str = '', xlabel: str = '', ylabel: str = '', @@ -506,7 +504,6 @@ def scatter( Args: x: Variable name for x-axis. y: Variable name for y-axis. - colors: Color specification (colorscale name, color list, or dict mapping). title: Plot title. xlabel: X-axis label. ylabel: Y-axis label. @@ -679,8 +676,9 @@ def to_duration_curve(self, *, normalize: bool = True) -> xr.Dataset: sorted_ds = self._ds.copy() for var in sorted_ds.data_vars: da = sorted_ds[var] - # Sort along time axis (descending) - sorted_values = np.sort(da.values, axis=da.dims.index('time'))[::-1] + time_axis = da.dims.index('time') + # Sort along time axis (descending) - use flip for correct axis + sorted_values = np.flip(np.sort(da.values, axis=time_axis), axis=time_axis) sorted_ds[var] = (da.dims, sorted_values) # Replace time coordinate with duration @@ -880,7 +878,6 @@ def heatmap( 'img': da, 'color_continuous_scale': colors, 'title': title or (da.name if da.name else ''), - **imshow_kwargs, } if actual_facet_col and actual_facet_col in da.dims: @@ -891,4 +888,4 @@ def heatmap( if actual_anim and actual_anim in da.dims: imshow_args['animation_frame'] = actual_anim - return px.imshow(**imshow_args) + return px.imshow(**{**imshow_args, **imshow_kwargs}) From ed33706bd57e9926f22f8639a842b80589e107d2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:59:07 +0100 Subject: [PATCH 17/22] Improve docstrings --- flixopt/config.py | 10 +++------- flixopt/statistics_accessor.py | 4 ++-- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 87d16615a..454f8ad3e 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -563,11 +563,8 @@ class Plotting: default_sequential_colorscale: Default colorscale for heatmaps and continuous data. default_qualitative_colorscale: Default colormap for categorical plots (bar/line/area charts). extra_dim_priority: Order of extra dimensions when auto-assigning to slots. - Default: ('cluster', 'period', 'scenario'). dim_slot_priority: Order of slots to fill with extra dimensions. - Default: ('facet_col', 'facet_row', 'animation_frame'). x_dim_priority: Order of dimensions to prefer for x-axis when 'auto'. - Default: ('time', 'duration', 'duration_pct'). Examples: ```python @@ -576,10 +573,9 @@ class Plotting: CONFIG.Plotting.default_sequential_colorscale = 'plasma' CONFIG.Plotting.default_qualitative_colorscale = 'Dark24' - # Customize dimension handling - # With 2 extra dims (period, scenario): period → facet_col, scenario → facet_row - CONFIG.Plotting.extra_dim_priority = ('cluster', 'period', 'scenario') - CONFIG.Plotting.dim_slot_priority = ('facet_col', 'facet_row', 'animation_frame') + # Customize dimension handling for faceting + CONFIG.Plotting.extra_dim_priority = ('scenario', 'period', 'cluster') + CONFIG.Plotting.dim_slot_priority = ('facet_row', 'facet_col', 'animation_frame') ``` """ diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index e01880f76..1cbcbcd7d 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -189,8 +189,8 @@ def _resolve_auto_facets( """Resolve 'auto' facet/animation dimensions based on available data dimensions. When 'auto' is specified, extra dimensions are assigned to slots based on: - - CONFIG.Plotting.extra_dim_priority: Order of dimensions (default: cluster → period → scenario) - - CONFIG.Plotting.dim_slot_priority: Order of slots (default: facet_col → facet_row → animation_frame) + - CONFIG.Plotting.extra_dim_priority: Order of dimensions to assign. + - CONFIG.Plotting.dim_slot_priority: Order of slots to fill. Args: ds: Dataset to check for available dimensions. From 0c7965f357caa198ec2efff385b9b6e7a8344821 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:25:01 +0100 Subject: [PATCH 18/22] Update notebooks to not do file operations --- docs/notebooks/08a-aggregation.ipynb | 24 ++- docs/notebooks/08b-rolling-horizon.ipynb | 16 +- docs/notebooks/08c-clustering.ipynb | 15 +- .../08c2-clustering-storage-modes.ipynb | 15 +- .../08d-clustering-multiperiod.ipynb | 13 +- docs/notebooks/08e-clustering-internals.ipynb | 13 +- .../09-plotting-and-data-access.ipynb | 175 ++++++++---------- 7 files changed, 108 insertions(+), 163 deletions(-) diff --git a/docs/notebooks/08a-aggregation.ipynb b/docs/notebooks/08a-aggregation.ipynb index 6d0260539..410cd1715 100644 --- a/docs/notebooks/08a-aggregation.ipynb +++ b/docs/notebooks/08a-aggregation.ipynb @@ -59,21 +59,13 @@ "metadata": {}, "outputs": [], "source": [ - "from pathlib import Path\n", + "from data.generate_example_systems import create_district_heating_system\n", "\n", - "# Generate example data if not present (for local development)\n", - "data_file = Path('data/district_heating_system.nc4')\n", - "if not data_file.exists():\n", - " from data.generate_example_systems import create_district_heating_system\n", - "\n", - " fs = create_district_heating_system()\n", - " fs.to_netcdf(data_file)\n", - "\n", - "# Load the district heating system (real data from Zeitreihen2020.csv)\n", - "flow_system = fx.FlowSystem.from_netcdf(data_file)\n", + "flow_system = create_district_heating_system()\n", + "flow_system.connect_and_transform()\n", "\n", "timesteps = flow_system.timesteps\n", - "print(f'Loaded FlowSystem: {len(timesteps)} timesteps ({len(timesteps) / 96:.0f} days at 15-min resolution)')\n", + "print(f'FlowSystem: {len(timesteps)} timesteps ({len(timesteps) / 96:.0f} days at 15-min resolution)')\n", "print(f'Components: {list(flow_system.components.keys())}')" ] }, @@ -397,7 +389,13 @@ ] } ], - "metadata": {}, + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + } + }, "nbformat": 4, "nbformat_minor": 5 } diff --git a/docs/notebooks/08b-rolling-horizon.ipynb b/docs/notebooks/08b-rolling-horizon.ipynb index e43da8f2c..5032588fe 100644 --- a/docs/notebooks/08b-rolling-horizon.ipynb +++ b/docs/notebooks/08b-rolling-horizon.ipynb @@ -63,21 +63,13 @@ "metadata": {}, "outputs": [], "source": [ - "from pathlib import Path\n", + "from data.generate_example_systems import create_operational_system\n", "\n", - "# Generate example data if not present (for local development)\n", - "data_file = Path('data/operational_system.nc4')\n", - "if not data_file.exists():\n", - " from data.generate_example_systems import create_operational_system\n", - "\n", - " fs = create_operational_system()\n", - " fs.to_netcdf(data_file)\n", - "\n", - "# Load the operational system (real data from Zeitreihen2020.csv, two weeks)\n", - "flow_system = fx.FlowSystem.from_netcdf(data_file)\n", + "flow_system = create_operational_system()\n", + "flow_system.connect_and_transform()\n", "\n", "timesteps = flow_system.timesteps\n", - "print(f'Loaded FlowSystem: {len(timesteps)} timesteps ({len(timesteps) / 96:.0f} days at 15-min resolution)')\n", + "print(f'FlowSystem: {len(timesteps)} timesteps ({len(timesteps) / 96:.0f} days at 15-min resolution)')\n", "print(f'Components: {list(flow_system.components.keys())}')" ] }, diff --git a/docs/notebooks/08c-clustering.ipynb b/docs/notebooks/08c-clustering.ipynb index cf5b53b53..acd30ea94 100644 --- a/docs/notebooks/08c-clustering.ipynb +++ b/docs/notebooks/08c-clustering.ipynb @@ -27,7 +27,6 @@ "outputs": [], "source": [ "import timeit\n", - "from pathlib import Path\n", "\n", "import numpy as np\n", "import pandas as pd\n", @@ -56,19 +55,13 @@ "metadata": {}, "outputs": [], "source": [ - "# Generate example data if not present\n", - "data_file = Path('data/district_heating_system.nc4')\n", - "if not data_file.exists():\n", - " from data.generate_example_systems import create_district_heating_system\n", + "from data.generate_example_systems import create_district_heating_system\n", "\n", - " fs = create_district_heating_system()\n", - " fs.to_netcdf(data_file)\n", - "\n", - "# Load the district heating system\n", - "flow_system = fx.FlowSystem.from_netcdf(data_file)\n", + "flow_system = create_district_heating_system()\n", + "flow_system.connect_and_transform()\n", "\n", "timesteps = flow_system.timesteps\n", - "print(f'Loaded FlowSystem: {len(timesteps)} timesteps ({len(timesteps) / 96:.0f} days at 15-min resolution)')\n", + "print(f'FlowSystem: {len(timesteps)} timesteps ({len(timesteps) / 96:.0f} days at 15-min resolution)')\n", "print(f'Components: {list(flow_system.components.keys())}')" ] }, diff --git a/docs/notebooks/08c2-clustering-storage-modes.ipynb b/docs/notebooks/08c2-clustering-storage-modes.ipynb index 163cf8729..c99d25dbd 100644 --- a/docs/notebooks/08c2-clustering-storage-modes.ipynb +++ b/docs/notebooks/08c2-clustering-storage-modes.ipynb @@ -27,7 +27,6 @@ "outputs": [], "source": [ "import timeit\n", - "from pathlib import Path\n", "\n", "import numpy as np\n", "import pandas as pd\n", @@ -61,19 +60,13 @@ "metadata": {}, "outputs": [], "source": [ - "# Generate example data if not present\n", - "data_file = Path('data/seasonal_storage_system.nc4')\n", - "if not data_file.exists():\n", - " from data.generate_example_systems import create_seasonal_storage_system\n", + "from data.generate_example_systems import create_seasonal_storage_system\n", "\n", - " fs = create_seasonal_storage_system()\n", - " fs.to_netcdf(data_file)\n", - "\n", - "# Load the seasonal storage system\n", - "flow_system = fx.FlowSystem.from_netcdf(data_file)\n", + "flow_system = create_seasonal_storage_system()\n", + "flow_system.connect_and_transform()\n", "\n", "timesteps = flow_system.timesteps\n", - "print(f'Loaded FlowSystem: {len(timesteps)} timesteps ({len(timesteps) / 24:.0f} days)')\n", + "print(f'FlowSystem: {len(timesteps)} timesteps ({len(timesteps) / 24:.0f} days)')\n", "print(f'Components: {list(flow_system.components.keys())}')" ] }, diff --git a/docs/notebooks/08d-clustering-multiperiod.ipynb b/docs/notebooks/08d-clustering-multiperiod.ipynb index 84ff468ea..31c47ff38 100644 --- a/docs/notebooks/08d-clustering-multiperiod.ipynb +++ b/docs/notebooks/08d-clustering-multiperiod.ipynb @@ -28,7 +28,6 @@ "outputs": [], "source": [ "import timeit\n", - "from pathlib import Path\n", "\n", "import numpy as np\n", "import pandas as pd\n", @@ -62,16 +61,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Generate example data if not present\n", - "data_file = Path('data/multiperiod_system.nc4')\n", - "if not data_file.exists():\n", - " from data.generate_example_systems import create_multiperiod_system\n", + "from data.generate_example_systems import create_multiperiod_system\n", "\n", - " fs = create_multiperiod_system()\n", - " fs.to_netcdf(data_file)\n", - "\n", - "# Load the multi-period system\n", - "flow_system = fx.FlowSystem.from_netcdf(data_file)\n", + "flow_system = create_multiperiod_system()\n", + "flow_system.connect_and_transform()\n", "\n", "print(f'Timesteps: {len(flow_system.timesteps)} ({len(flow_system.timesteps) // 24} days)')\n", "print(f'Periods: {list(flow_system.periods.values)}')\n", diff --git a/docs/notebooks/08e-clustering-internals.ipynb b/docs/notebooks/08e-clustering-internals.ipynb index a0ac80ca7..066ec749c 100644 --- a/docs/notebooks/08e-clustering-internals.ipynb +++ b/docs/notebooks/08e-clustering-internals.ipynb @@ -26,21 +26,14 @@ "metadata": {}, "outputs": [], "source": [ - "from pathlib import Path\n", + "from data.generate_example_systems import create_district_heating_system\n", "\n", "import flixopt as fx\n", "\n", "fx.CONFIG.notebook()\n", "\n", - "# Load the district heating system\n", - "data_file = Path('data/district_heating_system.nc4')\n", - "if not data_file.exists():\n", - " from data.generate_example_systems import create_district_heating_system\n", - "\n", - " fs = create_district_heating_system()\n", - " fs.to_netcdf(data_file)\n", - "\n", - "flow_system = fx.FlowSystem.from_netcdf(data_file)" + "flow_system = create_district_heating_system()\n", + "flow_system.connect_and_transform()" ] }, { diff --git a/docs/notebooks/09-plotting-and-data-access.ipynb b/docs/notebooks/09-plotting-and-data-access.ipynb index a4803adf4..39fa788da 100644 --- a/docs/notebooks/09-plotting-and-data-access.ipynb +++ b/docs/notebooks/09-plotting-and-data-access.ipynb @@ -11,7 +11,6 @@ "\n", "This notebook covers:\n", "\n", - "- Loading saved FlowSystems from NetCDF files\n", "- Accessing data (flow rates, sizes, effects, charge states)\n", "- Time series plots (balance, flows, storage)\n", "- Aggregated plots (sizes, effects, duration curves)\n", @@ -36,7 +35,7 @@ "metadata": {}, "outputs": [], "source": [ - "from pathlib import Path\n", + "from data.generate_example_systems import create_complex_system, create_multiperiod_system, create_simple_system\n", "\n", "import flixopt as fx\n", "\n", @@ -48,9 +47,9 @@ "id": "3", "metadata": {}, "source": [ - "## Generate Example Data\n", + "## Generate Example Systems\n", "\n", - "First, run the script that generates three example FlowSystems with solutions:" + "First, create three example FlowSystems with solutions:" ] }, { @@ -60,35 +59,19 @@ "metadata": {}, "outputs": [], "source": [ - "# Run the generation script (only needed once, or to regenerate)\n", - "!python data/generate_example_systems.py > /dev/null 2>&1" - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "## 1. Loading Saved FlowSystems\n", + "# Create and optimize the example systems\n", + "solver = fx.solvers.HighsSolver(mip_gap=0.01, log_to_console=False)\n", "\n", - "FlowSystems can be saved to and loaded from NetCDF files, preserving the full structure and solution:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "DATA_DIR = Path('data')\n", + "simple = create_simple_system()\n", + "simple.optimize(solver)\n", + "\n", + "complex_sys = create_complex_system()\n", + "complex_sys.optimize(solver)\n", "\n", - "# Load the three example systems\n", - "simple = fx.FlowSystem.from_netcdf(DATA_DIR / 'simple_system.nc4')\n", - "complex_sys = fx.FlowSystem.from_netcdf(DATA_DIR / 'complex_system.nc4')\n", - "multiperiod = fx.FlowSystem.from_netcdf(DATA_DIR / 'multiperiod_system.nc4')\n", + "multiperiod = create_multiperiod_system()\n", + "multiperiod.optimize(solver)\n", "\n", - "print('Loaded systems:')\n", + "print('Created systems:')\n", "print(f' simple: {len(simple.components)} components, {len(simple.buses)} buses')\n", "print(f' complex_sys: {len(complex_sys.components)} components, {len(complex_sys.buses)} buses')\n", "print(f' multiperiod: {len(multiperiod.components)} components, dims={dict(multiperiod.solution.sizes)}')" @@ -96,7 +79,7 @@ }, { "cell_type": "markdown", - "id": "7", + "id": "5", "metadata": {}, "source": [ "## 2. Quick Overview: Balance Plot\n", @@ -107,7 +90,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -117,7 +100,7 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "7", "metadata": {}, "source": [ "### Accessing Plot Data\n", @@ -128,7 +111,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -142,7 +125,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "9", "metadata": {}, "source": [ "### Energy Totals\n", @@ -153,7 +136,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -167,7 +150,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "11", "metadata": {}, "source": [ "## 3. Time Series Plots" @@ -175,7 +158,7 @@ }, { "cell_type": "markdown", - "id": "14", + "id": "12", "metadata": {}, "source": [ "### 3.1 Balance Plot\n", @@ -186,7 +169,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -196,7 +179,7 @@ }, { "cell_type": "markdown", - "id": "16", + "id": "14", "metadata": {}, "source": [ "### 3.2 Carrier Balance\n", @@ -207,7 +190,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -217,7 +200,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -226,7 +209,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "17", "metadata": {}, "source": [ "### 3.3 Flow Rates\n", @@ -237,7 +220,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -248,7 +231,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -258,7 +241,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "20", "metadata": {}, "source": [ "### 3.4 Storage Plot\n", @@ -269,7 +252,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -278,7 +261,7 @@ }, { "cell_type": "markdown", - "id": "24", + "id": "22", "metadata": {}, "source": [ "### 3.5 Charge States Plot\n", @@ -289,7 +272,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -298,7 +281,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "24", "metadata": {}, "source": [ "## 4. Aggregated Plots" @@ -306,7 +289,7 @@ }, { "cell_type": "markdown", - "id": "27", + "id": "25", "metadata": {}, "source": [ "### 4.1 Sizes Plot\n", @@ -317,7 +300,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -326,7 +309,7 @@ }, { "cell_type": "markdown", - "id": "29", + "id": "27", "metadata": {}, "source": [ "### 4.2 Effects Plot\n", @@ -337,7 +320,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -347,7 +330,7 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -358,7 +341,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -367,7 +350,7 @@ }, { "cell_type": "markdown", - "id": "33", + "id": "31", "metadata": {}, "source": [ "### 4.3 Duration Curve\n", @@ -378,7 +361,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -388,7 +371,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "33", "metadata": {}, "outputs": [], "source": [ @@ -398,7 +381,7 @@ }, { "cell_type": "markdown", - "id": "36", + "id": "34", "metadata": {}, "source": [ "## 5. Heatmaps\n", @@ -409,7 +392,7 @@ { "cell_type": "code", "execution_count": null, - "id": "37", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -420,7 +403,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -431,7 +414,7 @@ { "cell_type": "code", "execution_count": null, - "id": "39", + "id": "37", "metadata": {}, "outputs": [], "source": [ @@ -441,7 +424,7 @@ }, { "cell_type": "markdown", - "id": "40", + "id": "38", "metadata": {}, "source": [ "## 6. Sankey Diagrams\n", @@ -451,7 +434,7 @@ }, { "cell_type": "markdown", - "id": "41", + "id": "39", "metadata": {}, "source": [ "### 6.1 Flow Sankey\n", @@ -462,7 +445,7 @@ { "cell_type": "code", "execution_count": null, - "id": "42", + "id": "40", "metadata": {}, "outputs": [], "source": [ @@ -472,7 +455,7 @@ { "cell_type": "code", "execution_count": null, - "id": "43", + "id": "41", "metadata": {}, "outputs": [], "source": [ @@ -482,7 +465,7 @@ }, { "cell_type": "markdown", - "id": "44", + "id": "42", "metadata": {}, "source": [ "### 6.2 Sizes Sankey\n", @@ -493,7 +476,7 @@ { "cell_type": "code", "execution_count": null, - "id": "45", + "id": "43", "metadata": {}, "outputs": [], "source": [ @@ -502,7 +485,7 @@ }, { "cell_type": "markdown", - "id": "46", + "id": "44", "metadata": {}, "source": [ "### 6.3 Peak Flow Sankey\n", @@ -513,7 +496,7 @@ { "cell_type": "code", "execution_count": null, - "id": "47", + "id": "45", "metadata": {}, "outputs": [], "source": [ @@ -522,7 +505,7 @@ }, { "cell_type": "markdown", - "id": "48", + "id": "46", "metadata": {}, "source": [ "### 6.4 Effects Sankey\n", @@ -533,7 +516,7 @@ { "cell_type": "code", "execution_count": null, - "id": "49", + "id": "47", "metadata": {}, "outputs": [], "source": [ @@ -543,7 +526,7 @@ { "cell_type": "code", "execution_count": null, - "id": "50", + "id": "48", "metadata": {}, "outputs": [], "source": [ @@ -553,7 +536,7 @@ }, { "cell_type": "markdown", - "id": "51", + "id": "49", "metadata": {}, "source": [ "### 6.5 Filtering with `select`\n", @@ -564,7 +547,7 @@ { "cell_type": "code", "execution_count": null, - "id": "52", + "id": "50", "metadata": {}, "outputs": [], "source": [ @@ -574,7 +557,7 @@ }, { "cell_type": "markdown", - "id": "53", + "id": "51", "metadata": {}, "source": [ "## 7. Topology Visualization\n", @@ -584,7 +567,7 @@ }, { "cell_type": "markdown", - "id": "54", + "id": "52", "metadata": {}, "source": [ "### 7.1 Topology Plot\n", @@ -595,7 +578,7 @@ { "cell_type": "code", "execution_count": null, - "id": "55", + "id": "53", "metadata": {}, "outputs": [], "source": [ @@ -605,7 +588,7 @@ { "cell_type": "code", "execution_count": null, - "id": "56", + "id": "54", "metadata": {}, "outputs": [], "source": [ @@ -614,7 +597,7 @@ }, { "cell_type": "markdown", - "id": "57", + "id": "55", "metadata": {}, "source": [ "### 7.2 Topology Info\n", @@ -625,7 +608,7 @@ { "cell_type": "code", "execution_count": null, - "id": "58", + "id": "56", "metadata": {}, "outputs": [], "source": [ @@ -642,7 +625,7 @@ }, { "cell_type": "markdown", - "id": "59", + "id": "57", "metadata": {}, "source": [ "## 8. Multi-Period/Scenario Data\n", @@ -653,7 +636,7 @@ { "cell_type": "code", "execution_count": null, - "id": "60", + "id": "58", "metadata": {}, "outputs": [], "source": [ @@ -666,7 +649,7 @@ { "cell_type": "code", "execution_count": null, - "id": "61", + "id": "59", "metadata": {}, "outputs": [], "source": [ @@ -677,7 +660,7 @@ { "cell_type": "code", "execution_count": null, - "id": "62", + "id": "60", "metadata": {}, "outputs": [], "source": [ @@ -688,7 +671,7 @@ { "cell_type": "code", "execution_count": null, - "id": "63", + "id": "61", "metadata": {}, "outputs": [], "source": [ @@ -698,7 +681,7 @@ }, { "cell_type": "markdown", - "id": "64", + "id": "62", "metadata": {}, "source": [ "## 9. Color Customization\n", @@ -709,7 +692,7 @@ { "cell_type": "code", "execution_count": null, - "id": "65", + "id": "63", "metadata": {}, "outputs": [], "source": [ @@ -720,7 +703,7 @@ { "cell_type": "code", "execution_count": null, - "id": "66", + "id": "64", "metadata": {}, "outputs": [], "source": [ @@ -731,7 +714,7 @@ { "cell_type": "code", "execution_count": null, - "id": "67", + "id": "65", "metadata": {}, "outputs": [], "source": [ @@ -749,7 +732,7 @@ }, { "cell_type": "markdown", - "id": "68", + "id": "66", "metadata": {}, "source": [ "## 10. Exporting Results\n", @@ -760,7 +743,7 @@ { "cell_type": "code", "execution_count": null, - "id": "69", + "id": "67", "metadata": {}, "outputs": [], "source": [ @@ -775,7 +758,7 @@ { "cell_type": "code", "execution_count": null, - "id": "70", + "id": "68", "metadata": {}, "outputs": [], "source": [ @@ -787,7 +770,7 @@ { "cell_type": "code", "execution_count": null, - "id": "71", + "id": "69", "metadata": {}, "outputs": [], "source": [ @@ -800,7 +783,7 @@ }, { "cell_type": "markdown", - "id": "72", + "id": "70", "metadata": {}, "source": [ "## Summary\n", From 23b2a61cdd5540cd1c087cf9208c2e1afc8b17a4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:57:12 +0100 Subject: [PATCH 19/22] Fix notebook --- docs/notebooks/08c-clustering.ipynb | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/notebooks/08c-clustering.ipynb b/docs/notebooks/08c-clustering.ipynb index de1a05482..0e9cda7b7 100644 --- a/docs/notebooks/08c-clustering.ipynb +++ b/docs/notebooks/08c-clustering.ipynb @@ -58,6 +58,7 @@ "from data.generate_example_systems import create_district_heating_system\n", "\n", "flow_system = create_district_heating_system()\n", + "flow_system.connect_and_transform()\n", "\n", "timesteps = flow_system.timesteps\n", "print(f'Loaded FlowSystem: {len(timesteps)} timesteps ({len(timesteps) / 24:.0f} days at hourly resolution)')\n", From 7892170eb4e74426712ff792b0d4798ae0d00263 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:24:23 +0100 Subject: [PATCH 20/22] Fix CI --- docs/notebooks/data/generate_example_systems.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/notebooks/data/generate_example_systems.py b/docs/notebooks/data/generate_example_systems.py index ec645e8b2..c53322ef2 100644 --- a/docs/notebooks/data/generate_example_systems.py +++ b/docs/notebooks/data/generate_example_systems.py @@ -11,11 +11,13 @@ Run this script to regenerate the example data files. """ +import sys from pathlib import Path import numpy as np import pandas as pd +# Handle imports in different contexts (direct run, package import, mkdocs-jupyter) try: from .generate_realistic_profiles import ( ElectricityLoadGenerator, @@ -25,6 +27,13 @@ load_weather, ) except ImportError: + # Add data directory to path for mkdocs-jupyter context + try: + _data_dir = Path(__file__).parent + except NameError: + _data_dir = Path('docs/notebooks/data') + if str(_data_dir) not in sys.path: + sys.path.insert(0, str(_data_dir)) from generate_realistic_profiles import ( ElectricityLoadGenerator, GasPriceGenerator, From 53216e10788eb2edbb597b58f311d5a402bac8a9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 3 Jan 2026 21:49:44 +0100 Subject: [PATCH 21/22] mkdocs-jupyter was treating this .py file as a notebook and executing it, causing the NetCDF write failure in CI --- mkdocs.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index ca94a6302..ab2e9309f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -234,6 +234,10 @@ plugins: allow_errors: false include_source: true include_requirejs: true + ignore: + - "notebooks/data/*.py" # Data generation scripts, not notebooks + execute_ignore: + - "notebooks/data/*.py" - plotly From 90e56565842f887846ac6f95121f6325ec7d3157 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 3 Jan 2026 22:26:59 +0100 Subject: [PATCH 22/22] Add missing type annotation --- flixopt/clustering/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/clustering/base.py b/flixopt/clustering/base.py index fadf28247..4b31832e4 100644 --- a/flixopt/clustering/base.py +++ b/flixopt/clustering/base.py @@ -197,7 +197,7 @@ def get_cluster_weight_per_timestep(self) -> xr.DataArray: name='cluster_weight', ) - def plot(self, show: bool | None = None): + def plot(self, show: bool | None = None) -> PlotResult: """Plot cluster assignment visualization. Shows which cluster each original period belongs to, and the