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", diff --git a/docs/notebooks/08e-clustering-internals.ipynb b/docs/notebooks/08e-clustering-internals.ipynb index bbd54f05d..506a01ed9 100644 --- a/docs/notebooks/08e-clustering-internals.ipynb +++ b/docs/notebooks/08e-clustering-internals.ipynb @@ -32,8 +32,8 @@ "\n", "fx.CONFIG.notebook()\n", "\n", - "# Create the district heating system\n", - "flow_system = create_district_heating_system()" + "flow_system = create_district_heating_system()\n", + "flow_system.connect_and_transform()" ] }, { @@ -287,17 +287,7 @@ ] } ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.11" - } - }, + "metadata": {}, "nbformat": 4, "nbformat_minor": 5 } 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", 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, diff --git a/docs/notebooks/fxplot_accessor_demo.ipynb b/docs/notebooks/fxplot_accessor_demo.ipynb new file mode 100644 index 000000000..db8684d82 --- /dev/null +++ b/docs/notebooks/fxplot_accessor_demo.ipynb @@ -0,0 +1,565 @@ +{ + "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\n", + "\n", + "import flixopt as fx\n", + "\n", + "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": {}, + "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.to_dataframe().head()" + ] + }, + { + "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": [ + "## Automatic Faceting & Animation\n", + "\n", + "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!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Dataset with scenario AND period dimensions\n", + "ds_multi = xr.Dataset(\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_multi.to_dataframe().head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Just call .line() - dimensions are auto-assigned to facet_col, facet_row, animation_frame\n", + "ds_multi.fxplot.line(title='Auto-Faceted: 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.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": {}, + "source": [ + "## Customizing Facets & Animation\n", + "\n", + "Override auto-assignment when needed. Use `None` to disable a slot entirely." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 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.sum('time').fxplot.area(\n", + " facet_col='scenario', facet_row='period', animation_frame=None, title='Grid: Period × Scenario'\n", + ")" + ] + }, + { + "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')" + ] + }, + { + "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')" + ] + }, + { + "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 - auto-assigns scenario and period to facets\n", + "ds_multi.sum('time').fxplot.pie(\n", + " title='Generation by Source (Scenario × Period)',\n", + " colors={'Solar': 'gold', 'Wind': 'skyblue'},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Duration Curve\n", + "\n", + "Use `.fxstats.to_duration_curve()` to transform data, then `.fxplot.line()` to plot. Clean separation of transformation and plotting." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Duration curve with normalized x-axis (percentage)\n", + "ds_simple.fxstats.to_duration_curve().fxplot.line(title='Duration Curves', xlabel='Duration [%]')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Duration curve with absolute timesteps\n", + "ds_simple.fxstats.to_duration_curve(normalize=False).fxplot.line(title='Duration Curves', xlabel='Timesteps')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Duration curve with auto-faceting - works seamlessly!\n", + "ds_multi.fxstats.to_duration_curve().fxplot.line(title='Duration Curves (Auto-Faceted)', xlabel='Duration [%]')" + ] + }, + { + "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": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} 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/__init__.py b/flixopt/__init__.py index 1089bf743..0fd550707 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -13,6 +13,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/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 diff --git a/flixopt/config.py b/flixopt/config.py index 7e7c784cb..454f8ad3e 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -163,8 +163,10 @@ 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'), + 'x_dim_priority': ('time', 'duration', 'duration_pct', 'period', 'scenario', 'cluster'), } ), 'solving': MappingProxyType( @@ -561,9 +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'. Examples: ```python @@ -572,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') ``` """ @@ -585,8 +585,10 @@ 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'] + x_dim_priority: tuple[str, ...] = _DEFAULTS['plotting']['x_dim_priority'] class Carriers: """Default carrier definitions for common energy types. @@ -687,8 +689,10 @@ 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, + 'x_dim_priority': cls.Plotting.x_dim_priority, }, } diff --git a/flixopt/dataset_plot_accessor.py b/flixopt/dataset_plot_accessor.py new file mode 100644 index 000000000..fc38f730b --- /dev/null +++ b/flixopt/dataset_plot_accessor.py @@ -0,0 +1,891 @@ +"""Xarray accessors for plotting (``.fxplot``) and statistics (``.fxstats``).""" + +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 _get_x_dim(dims: list[str], x: str | Literal['auto'] | None = 'auto') -> str: + """Select x-axis dim from priority list, or 'variable' for scalar data.""" + 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, or 'variable' for scalar data + return dims[0] if dims else 'variable' + + +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]: + """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} + 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 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() + # 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') +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, + *, + x: str | Literal['auto'] | None = 'auto', + colors: ColorType | None = None, + title: str = '', + xlabel: str = '', + ylabel: 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 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. + 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. + facet_cols: Number of columns in facet grid wrap. + **px_kwargs: Additional arguments passed to plotly.express.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() + + variables = df['variable'].unique().tolist() + color_map = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) + + facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols + fig_kwargs: dict[str, Any] = { + 'data_frame': df, + 'x': x_col, + 'y': 'value', + 'title': title, + 'barmode': 'group', + } + # 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'] = {x_col: xlabel} + if ylabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), 'value': ylabel} + + 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 and 'facet_row' not in px_kwargs: + fig_kwargs['facet_row'] = actual_facet_row + if actual_anim and 'animation_frame' not in px_kwargs: + fig_kwargs['animation_frame'] = actual_anim + + return px.bar(**{**fig_kwargs, **px_kwargs}) + + def stacked_bar( + self, + *, + x: str | Literal['auto'] | None = 'auto', + colors: ColorType | None = None, + title: str = '', + xlabel: str = '', + ylabel: 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 stacked bar chart from the dataset. + + Variables in the dataset become stacked segments. Positive and negative + 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. + 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.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() + + variables = df['variable'].unique().tolist() + color_map = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) + + facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols + fig_kwargs: dict[str, Any] = { + 'data_frame': df, + 'x': x_col, + 'y': 'value', + 'title': title, + } + # 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'] = {x_col: xlabel} + if ylabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), 'value': ylabel} + + 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 and 'facet_row' not in px_kwargs: + fig_kwargs['facet_row'] = actual_facet_row + if actual_anim and 'animation_frame' not in px_kwargs: + fig_kwargs['animation_frame'] = actual_anim + + 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 + + def line( + self, + *, + x: str | Literal['auto'] | None = 'auto', + colors: ColorType | None = None, + title: str = '', + xlabel: str = '', + ylabel: 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, + line_shape: str | None = None, + **px_kwargs: Any, + ) -> go.Figure: + """Create a line chart from the dataset. + + 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. + 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 from CONFIG.Plotting.default_line_shape. + **px_kwargs: Additional arguments passed to plotly.express.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() + + variables = df['variable'].unique().tolist() + color_map = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) + + facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols + fig_kwargs: dict[str, Any] = { + 'data_frame': df, + 'x': x_col, + 'y': 'value', + 'title': title, + 'line_shape': line_shape or CONFIG.Plotting.default_line_shape, + } + # 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'] = {x_col: xlabel} + if ylabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), 'value': ylabel} + + 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 and 'facet_row' not in px_kwargs: + fig_kwargs['facet_row'] = actual_facet_row + if actual_anim and 'animation_frame' not in px_kwargs: + fig_kwargs['animation_frame'] = actual_anim + + return px.line(**{**fig_kwargs, **px_kwargs}) + + def area( + self, + *, + x: str | Literal['auto'] | None = 'auto', + colors: ColorType | None = None, + title: str = '', + xlabel: str = '', + ylabel: 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, + line_shape: str | None = None, + **px_kwargs: Any, + ) -> go.Figure: + """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. + 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 from CONFIG.Plotting.default_line_shape. + **px_kwargs: Additional arguments passed to plotly.express.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() + + variables = df['variable'].unique().tolist() + color_map = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) + + facet_col_wrap = facet_cols or CONFIG.Plotting.default_facet_cols + fig_kwargs: dict[str, Any] = { + 'data_frame': df, + 'x': x_col, + 'y': 'value', + 'title': title, + 'line_shape': line_shape or CONFIG.Plotting.default_line_shape, + } + # 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'] = {x_col: xlabel} + if ylabel: + fig_kwargs['labels'] = {**fig_kwargs.get('labels', {}), 'value': ylabel} + + 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 and 'facet_row' not in px_kwargs: + fig_kwargs['facet_row'] = actual_facet_row + if actual_anim and 'animation_frame' not in px_kwargs: + fig_kwargs['animation_frame'] = actual_anim + + return px.area(**{**fig_kwargs, **px_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 = 'auto', + 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, + } + + 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, **imshow_kwargs}) + + def scatter( + self, + x: str, + y: str, + *, + title: str = '', + xlabel: str = '', + ylabel: 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 scatter plot from two variables in the dataset. + + Args: + x: Variable name for x-axis. + y: Variable name for y-axis. + 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 = '', + 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. + + 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. '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. + + Returns: + Plotly Figure. + + Example: + >>> ds.sum('time').fxplot.pie() # Sum over time, then pie chart + >>> ds.sum('time').fxplot.pie(facet_col='scenario') # Pie per scenario + """ + max_ndim = max((self._ds[v].ndim for v in self._ds.data_vars), default=0) + + names = list(self._ds.data_vars) + color_map = process_colors(colors, names, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) + + # 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, + ) + + # 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, 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, + '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 + if actual_anim: + fig_kwargs['animation_frame'] = actual_anim + + return px.pie(**fig_kwargs) + + +@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 time coordinate is replaced with duration (percentage or index). + + Args: + normalize: If True, x-axis shows percentage (0-100). If False, shows timestep index. + + Returns: + Transformed xr.Dataset with duration coordinate instead of time. + + Example: + >>> ds.fxstats.to_duration_curve().fxplot.line(title='Duration Curve') + """ + 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] + 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 + 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'}) + else: + duration_coord = np.arange(n_timesteps) + sorted_ds = sorted_ds.assign_coords({'time': duration_coord}) + sorted_ds = sorted_ds.rename({'time': 'duration'}) + + return sorted_ds + + +@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, + *, + x: str | Literal['auto'] | None = 'auto', + colors: ColorType | None = None, + title: str = '', + xlabel: str = '', + ylabel: 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 grouped bar chart. See DatasetPlotAccessor.bar for details.""" + return self._to_dataset().fxplot.bar( + x=x, + colors=colors, + title=title, + xlabel=xlabel, + ylabel=ylabel, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + facet_cols=facet_cols, + **px_kwargs, + ) + + def stacked_bar( + self, + *, + x: str | Literal['auto'] | None = 'auto', + colors: ColorType | None = None, + title: str = '', + xlabel: str = '', + ylabel: 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 stacked bar chart. See DatasetPlotAccessor.stacked_bar for details.""" + return self._to_dataset().fxplot.stacked_bar( + x=x, + colors=colors, + title=title, + xlabel=xlabel, + ylabel=ylabel, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + facet_cols=facet_cols, + **px_kwargs, + ) + + def line( + self, + *, + x: str | Literal['auto'] | None = 'auto', + colors: ColorType | None = None, + title: str = '', + xlabel: str = '', + ylabel: 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, + 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( + x=x, + colors=colors, + title=title, + xlabel=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, + ) + + def area( + self, + *, + x: str | Literal['auto'] | None = 'auto', + colors: ColorType | None = None, + title: str = '', + xlabel: str = '', + ylabel: 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, + 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( + x=x, + colors=colors, + title=title, + xlabel=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, + ) + + def heatmap( + self, + *, + colors: str | list[str] | None = None, + title: str = '', + facet_col: str | Literal['auto'] | None = 'auto', + animation_frame: str | Literal['auto'] | None = 'auto', + 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 ''), + } + + 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, **imshow_kwargs}) diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index 73d115df0..382ed1bf0 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 --- @@ -237,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. @@ -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, diff --git a/mkdocs.yml b/mkdocs.yml index 493937983..ab2e9309f 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/ @@ -233,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