diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d3d6b92..bad4e4d52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,33 @@ Until here --> ### ✨ Added +**FlowSystem Comparison**: New `Comparison` class for comparing multiple FlowSystems side-by-side: + +```python +# Compare systems (uses FlowSystem.name by default) +comp = fx.Comparison([fs_base, fs_modified]) + +# Or with custom names +comp = fx.Comparison([fs1, fs2, fs3], names=['baseline', 'low_cost', 'high_eff']) + +# Side-by-side plots (auto-facets by 'case' dimension) +comp.statistics.plot.balance('Heat') +comp.statistics.flow_rates.fxplot.line() + +# Access combined data with 'case' dimension +comp.solution # xr.Dataset +comp.statistics.flow_rates # xr.Dataset + +# Compute differences relative to a reference case +comp.diff() # vs first case +comp.diff('baseline') # vs named case +``` + +- Concatenates solutions and statistics from multiple FlowSystems with a `'case'` dimension +- Mirrors all `StatisticsAccessor` properties (`flow_rates`, `flow_hours`, `sizes`, `charge_states`, `temporal_effects`, `periodic_effects`, `total_effects`) +- Mirrors all `StatisticsPlotAccessor` methods (`balance`, `carrier_balance`, `flows`, `sizes`, `duration_curve`, `effects`, `charge_states`, `heatmap`, `storage`) +- Existing plotting infrastructure automatically handles faceting by `'case'` + **Time-Series Clustering**: Reduce large time series to representative typical periods for faster investment optimization, then expand results back to full resolution. ```python diff --git a/docs/notebooks/02-heat-system.ipynb b/docs/notebooks/02-heat-system.ipynb index 9b47a96b4..d3514de15 100644 --- a/docs/notebooks/02-heat-system.ipynb +++ b/docs/notebooks/02-heat-system.ipynb @@ -32,6 +32,7 @@ "metadata": {}, "outputs": [], "source": [ + "import pandas as pd\n", "import xarray as xr\n", "\n", "import flixopt as fx\n", @@ -281,12 +282,16 @@ "metadata": {}, "outputs": [], "source": [ - "total_costs = flow_system.solution['costs'].item()\n", "total_heat = heat_demand.sum()\n", "\n", - "print(f'Total operating costs: {total_costs:.2f} €')\n", - "print(f'Total heat delivered: {total_heat:.0f} kWh')\n", - "print(f'Average cost: {total_costs / total_heat * 100:.2f} ct/kWh')" + "pd.DataFrame(\n", + " {\n", + " 'Total operating costs [EUR]': flow_system.solution['costs'].item(),\n", + " 'Total heat delivered [kWh]': total_heat,\n", + " 'Average cost [ct/kWh]': flow_system.solution['costs'].item() / total_heat * 100,\n", + " },\n", + " index=['Value'],\n", + ").T" ] }, { @@ -370,25 +375,7 @@ ] } ], - "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" - } - }, + "metadata": {}, "nbformat": 4, "nbformat_minor": 5 } diff --git a/docs/notebooks/03-investment-optimization.ipynb b/docs/notebooks/03-investment-optimization.ipynb index c31bfaee2..a4ae769c5 100644 --- a/docs/notebooks/03-investment-optimization.ipynb +++ b/docs/notebooks/03-investment-optimization.ipynb @@ -32,6 +32,7 @@ "metadata": {}, "outputs": [], "source": [ + "import pandas as pd\n", "import xarray as xr\n", "\n", "import flixopt as fx\n", @@ -229,9 +230,14 @@ "solar_size = flow_system.statistics.sizes['SolarCollectors(Heat)'].item()\n", "tank_size = flow_system.statistics.sizes['BufferTank'].item()\n", "\n", - "print(\n", - " f'Optimal sizes: Solar {solar_size:.0f} kW, Tank {tank_size:.0f} kWh (ratio: {tank_size / solar_size:.1f} kWh/kW)'\n", - ")" + "pd.DataFrame(\n", + " {\n", + " 'Solar [kW]': solar_size,\n", + " 'Tank [kWh]': tank_size,\n", + " 'Ratio [kWh/kW]': tank_size / solar_size,\n", + " },\n", + " index=['Optimal Size'],\n", + ").T" ] }, { @@ -274,8 +280,13 @@ "tank_invest = tank_size * TANK_COST_WEEKLY\n", "gas_costs = total_costs - solar_invest - tank_invest\n", "\n", - "print(\n", - " f'Weekly costs: Solar {solar_invest:.1f}€ ({solar_invest / total_costs * 100:.0f}%) + Tank {tank_invest:.1f}€ ({tank_invest / total_costs * 100:.0f}%) + Gas {gas_costs:.1f}€ ({gas_costs / total_costs * 100:.0f}%) = {total_costs:.1f}€'\n", + "pd.DataFrame(\n", + " {\n", + " 'Solar Investment': {'EUR': solar_invest, '%': solar_invest / total_costs * 100},\n", + " 'Tank Investment': {'EUR': tank_invest, '%': tank_invest / total_costs * 100},\n", + " 'Gas Costs': {'EUR': gas_costs, '%': gas_costs / total_costs * 100},\n", + " 'Total': {'EUR': total_costs, '%': 100.0},\n", + " }\n", ")" ] }, @@ -334,14 +345,21 @@ "metadata": {}, "outputs": [], "source": [ - "# Gas-only scenario\n", + "# Gas-only scenario for comparison\n", "total_demand = pool_demand.sum()\n", "gas_only_cost = total_demand / 0.92 * GAS_PRICE # All heat from gas boiler\n", - "\n", "savings = gas_only_cost - total_costs\n", - "print(\n", - " f'Solar saves {savings:.1f}€/week ({savings / gas_only_cost * 100:.0f}%) vs gas-only ({gas_only_cost:.1f}€) → {savings * 52:.0f}€/year'\n", - ")" + "\n", + "pd.DataFrame(\n", + " {\n", + " 'Gas-only [EUR/week]': gas_only_cost,\n", + " 'With Solar [EUR/week]': total_costs,\n", + " 'Savings [EUR/week]': savings,\n", + " 'Savings [%]': savings / gas_only_cost * 100,\n", + " 'Savings [EUR/year]': savings * 52,\n", + " },\n", + " index=['Value'],\n", + ").T" ] }, { @@ -406,25 +424,7 @@ ] } ], - "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" - } - }, + "metadata": {}, "nbformat": 4, "nbformat_minor": 5 } diff --git a/docs/notebooks/04-operational-constraints.ipynb b/docs/notebooks/04-operational-constraints.ipynb index 017761b5a..e55f2aded 100644 --- a/docs/notebooks/04-operational-constraints.ipynb +++ b/docs/notebooks/04-operational-constraints.ipynb @@ -32,6 +32,7 @@ "metadata": {}, "outputs": [], "source": [ + "import pandas as pd\n", "import xarray as xr\n", "\n", "import flixopt as fx\n", @@ -84,8 +85,12 @@ "metadata": {}, "outputs": [], "source": [ - "# Visualize demand with fxplot\n", - "demand_ds = xr.Dataset({'Steam Demand': xr.DataArray(steam_demand, dims=['time'], coords={'time': timesteps})})\n", + "# Visualize the demand with fxplot\n", + "demand_ds = xr.Dataset(\n", + " {\n", + " 'Steam Demand [kW]': xr.DataArray(steam_demand, dims=['time'], coords={'time': timesteps}),\n", + " }\n", + ")\n", "demand_ds.fxplot.line(title='Factory Steam Demand')" ] }, @@ -104,7 +109,7 @@ "metadata": {}, "outputs": [], "source": [ - "flow_system = fx.FlowSystem(timesteps)\n", + "flow_system = fx.FlowSystem(timesteps, name='Constrained')\n", "\n", "# Define and register custom carriers\n", "flow_system.add_carriers(\n", @@ -268,8 +273,12 @@ "startup_costs = total_startups * 50\n", "gas_costs = total_costs - startup_costs\n", "\n", - "print(\n", - " f'{total_startups} startups × 50€ = {startup_costs:.0f}€ startup + {gas_costs:.0f}€ gas = {total_costs:.0f}€ total'\n", + "pd.DataFrame(\n", + " {\n", + " 'Startups': {'Count': total_startups, 'EUR': startup_costs},\n", + " 'Gas': {'Count': '-', 'EUR': gas_costs},\n", + " 'Total': {'Count': '-', 'EUR': total_costs},\n", + " }\n", ")" ] }, @@ -321,7 +330,7 @@ "outputs": [], "source": [ "# Build unconstrained system\n", - "fs_unconstrained = fx.FlowSystem(timesteps)\n", + "fs_unconstrained = fx.FlowSystem(timesteps, name='Unconstrained')\n", "fs_unconstrained.add_carriers(\n", " fx.Carrier('gas', '#3498db', 'kW'),\n", " fx.Carrier('steam', '#87CEEB', 'kW_th', 'Process steam'),\n", @@ -351,14 +360,43 @@ "fs_unconstrained.optimize(fx.solvers.HighsSolver())\n", "unconstrained_costs = fs_unconstrained.solution['costs'].item()\n", "\n", - "constraint_overhead = (total_costs - unconstrained_costs) / unconstrained_costs * 100\n", - "print(f'Constraints add {constraint_overhead:.1f}% cost: {unconstrained_costs:.0f}€ → {total_costs:.0f}€')" + "pd.DataFrame(\n", + " {\n", + " 'Without Constraints': {'Cost [EUR]': unconstrained_costs},\n", + " 'With Constraints': {'Cost [EUR]': total_costs},\n", + " 'Overhead': {\n", + " 'Cost [EUR]': total_costs - unconstrained_costs,\n", + " '%': (total_costs - unconstrained_costs) / unconstrained_costs * 100,\n", + " },\n", + " }\n", + ")" ] }, { "cell_type": "markdown", "id": "24", "metadata": {}, + "source": [ + "### Side-by-Side Comparison\n", + "\n", + "Use the `Comparison` class to visualize both systems together:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "comp = fx.Comparison([fs_unconstrained, flow_system])\n", + "comp.statistics.plot.effects()" + ] + }, + { + "cell_type": "markdown", + "id": "26", + "metadata": {}, "source": [ "### Energy Flow Sankey\n", "\n", @@ -368,7 +406,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -377,7 +415,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "28", "metadata": {}, "source": [ "## Key Concepts\n", @@ -430,17 +468,10 @@ } ], "metadata": { - "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" + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" } }, "nbformat": 4, diff --git a/docs/notebooks/05-multi-carrier-system.ipynb b/docs/notebooks/05-multi-carrier-system.ipynb index 83115e129..1feab6e4f 100644 --- a/docs/notebooks/05-multi-carrier-system.ipynb +++ b/docs/notebooks/05-multi-carrier-system.ipynb @@ -32,6 +32,7 @@ "metadata": {}, "outputs": [], "source": [ + "import pandas as pd\n", "import xarray as xr\n", "\n", "import flixopt as fx\n", @@ -105,7 +106,7 @@ " {\n", " 'Electricity Demand [kW]': xr.DataArray(electricity_demand, dims=['time'], coords={'time': timesteps}),\n", " 'Heat Demand [kW]': xr.DataArray(heat_demand, dims=['time'], coords={'time': timesteps}),\n", - " 'Elec. Buy Price [€/kWh]': xr.DataArray(elec_buy_price, dims=['time'], coords={'time': timesteps}),\n", + " 'Elec. Buy Price [EUR/kWh]': xr.DataArray(elec_buy_price, dims=['time'], coords={'time': timesteps}),\n", " }\n", ")\n", "profiles.fxplot.line(title='Hospital Energy Profiles', height=300)" @@ -126,7 +127,7 @@ "metadata": {}, "outputs": [], "source": [ - "flow_system = fx.FlowSystem(timesteps)\n", + "flow_system = fx.FlowSystem(timesteps, name='With CHP')\n", "flow_system.add_carriers(\n", " fx.Carrier('gas', '#3498db', 'kW'),\n", " fx.Carrier('electricity', '#f1c40f', 'kW'),\n", @@ -320,9 +321,6 @@ "metadata": {}, "outputs": [], "source": [ - "total_costs = flow_system.solution['costs'].item()\n", - "total_co2 = flow_system.solution['CO2'].item()\n", - "\n", "# Energy flows\n", "flow_rates = flow_system.statistics.flow_rates\n", "grid_buy = flow_rates['GridBuy(Electricity)'].sum().item()\n", @@ -334,12 +332,20 @@ "total_elec = electricity_demand.sum()\n", "total_heat = heat_demand.sum()\n", "\n", - "# Display as compact summary\n", - "print(\n", - " f'Electricity: {chp_elec:.0f} kWh CHP ({chp_elec / total_elec * 100:.0f}%) + {grid_buy:.0f} kWh grid, {grid_sell:.0f} kWh sold'\n", - ")\n", - "print(f'Heat: {chp_heat:.0f} kWh CHP ({chp_heat / total_heat * 100:.0f}%) + {boiler_heat:.0f} kWh boiler')\n", - "print(f'Costs: {total_costs:.2f} € | CO2: {total_co2:.0f} kg')" + "pd.DataFrame(\n", + " {\n", + " 'CHP Electricity [kWh]': chp_elec,\n", + " 'CHP Electricity [%]': chp_elec / total_elec * 100,\n", + " 'Grid Buy [kWh]': grid_buy,\n", + " 'Grid Sell [kWh]': grid_sell,\n", + " 'CHP Heat [kWh]': chp_heat,\n", + " 'CHP Heat [%]': chp_heat / total_heat * 100,\n", + " 'Boiler Heat [kWh]': boiler_heat,\n", + " 'Total Costs [EUR]': flow_system.solution['costs'].item(),\n", + " 'Total CO2 [kg]': flow_system.solution['CO2'].item(),\n", + " },\n", + " index=['Value'],\n", + ").T" ] }, { @@ -360,7 +366,7 @@ "outputs": [], "source": [ "# Build system without CHP\n", - "fs_no_chp = fx.FlowSystem(timesteps)\n", + "fs_no_chp = fx.FlowSystem(timesteps, name='No CHP')\n", "fs_no_chp.add_carriers(\n", " fx.Carrier('gas', '#3498db', 'kW'),\n", " fx.Carrier('electricity', '#f1c40f', 'kW'),\n", @@ -399,13 +405,24 @@ "\n", "fs_no_chp.optimize(fx.solvers.HighsSolver())\n", "\n", + "total_costs = flow_system.solution['costs'].item()\n", + "total_co2 = flow_system.solution['CO2'].item()\n", "no_chp_costs = fs_no_chp.solution['costs'].item()\n", "no_chp_co2 = fs_no_chp.solution['CO2'].item()\n", "\n", - "cost_saving = (no_chp_costs - total_costs) / no_chp_costs * 100\n", - "co2_saving = (no_chp_co2 - total_co2) / no_chp_co2 * 100\n", - "print(\n", - " f'CHP saves {cost_saving:.1f}% costs ({no_chp_costs:.0f}→{total_costs:.0f} €) and {co2_saving:.1f}% CO2 ({no_chp_co2:.0f}→{total_co2:.0f} kg)'\n", + "pd.DataFrame(\n", + " {\n", + " 'Without CHP': {'Cost [EUR]': no_chp_costs, 'CO2 [kg]': no_chp_co2},\n", + " 'With CHP': {'Cost [EUR]': total_costs, 'CO2 [kg]': total_co2},\n", + " 'Savings': {\n", + " 'Cost [EUR]': no_chp_costs - total_costs,\n", + " 'CO2 [kg]': no_chp_co2 - total_co2,\n", + " },\n", + " 'Savings [%]': {\n", + " 'Cost [EUR]': (no_chp_costs - total_costs) / no_chp_costs * 100,\n", + " 'CO2 [kg]': (no_chp_co2 - total_co2) / no_chp_co2 * 100,\n", + " },\n", + " }\n", ")" ] }, @@ -413,6 +430,37 @@ "cell_type": "markdown", "id": "23", "metadata": {}, + "source": [ + "### Side-by-Side Comparison\n", + "\n", + "Use the `Comparison` class to visualize both systems together:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "comp = fx.Comparison([fs_no_chp, flow_system])\n", + "comp.statistics.plot.balance('Electricity')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "comp.statistics.plot.balance('Heat')" + ] + }, + { + "cell_type": "markdown", + "id": "26", + "metadata": {}, "source": [ "### Energy Flow Sankey\n", "\n", @@ -422,7 +470,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -431,7 +479,7 @@ }, { "cell_type": "markdown", - "id": "25", + "id": "28", "metadata": {}, "source": [ "## Key Concepts\n", @@ -493,16 +541,8 @@ "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" + "version": "3.11" } }, "nbformat": 4, diff --git a/docs/notebooks/06a-time-varying-parameters.ipynb b/docs/notebooks/06a-time-varying-parameters.ipynb index ac248aacd..5ebca688e 100644 --- a/docs/notebooks/06a-time-varying-parameters.ipynb +++ b/docs/notebooks/06a-time-varying-parameters.ipynb @@ -308,25 +308,7 @@ ] } ], - "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" - } - }, + "metadata": {}, "nbformat": 4, "nbformat_minor": 5 } diff --git a/docs/notebooks/07-scenarios-and-periods.ipynb b/docs/notebooks/07-scenarios-and-periods.ipynb index 9f80a6c9b..0f3cbaef0 100644 --- a/docs/notebooks/07-scenarios-and-periods.ipynb +++ b/docs/notebooks/07-scenarios-and-periods.ipynb @@ -32,7 +32,8 @@ "metadata": {}, "outputs": [], "source": [ - "import plotly.express as px\n", + "import pandas as pd\n", + "import xarray as xr\n", "\n", "import flixopt as fx\n", "\n", @@ -82,26 +83,40 @@ "elec_prices = data['elec_prices']" ] }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## Scenario-Dependent Demand Profiles\n", + "\n", + "Heat demand differs significantly between mild and harsh winters:" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "6", + "id": "7", "metadata": {}, "outputs": [], "source": [ - "# Visualize demand scenarios with plotly\n", - "fig = px.line(\n", - " heat_demand.iloc[:48],\n", - " title='Heat Demand by Scenario (First 2 Days)',\n", - " labels={'index': 'Time', 'value': 'kW', 'variable': 'Scenario'},\n", + "# Visualize demand scenarios with fxplot\n", + "demand_ds = xr.Dataset(\n", + " {\n", + " scenario: xr.DataArray(\n", + " heat_demand[scenario].values,\n", + " dims=['time'],\n", + " coords={'time': timesteps},\n", + " )\n", + " for scenario in scenarios\n", + " }\n", ")\n", - "fig.update_traces(mode='lines')\n", - "fig" + "demand_ds.fxplot.line(title='Heat Demand by Scenario')" ] }, { "cell_type": "markdown", - "id": "7", + "id": "8", "metadata": {}, "source": [ "## Build the Flow System\n", @@ -112,7 +127,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -121,6 +136,7 @@ " periods=periods,\n", " scenarios=scenarios,\n", " scenario_weights=scenario_weights,\n", + " name='Both Scenarios',\n", ")\n", "flow_system.add_carriers(\n", " fx.Carrier('gas', '#3498db', 'kW'),\n", @@ -128,12 +144,12 @@ " fx.Carrier('heat', '#e74c3c', 'kW'),\n", ")\n", "\n", - "print(flow_system)" + "flow_system" ] }, { "cell_type": "markdown", - "id": "9", + "id": "10", "metadata": {}, "source": [ "## Add Components" @@ -142,7 +158,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -219,7 +235,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "12", "metadata": {}, "source": [ "## Run Optimization" @@ -228,7 +244,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -237,7 +253,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "14", "metadata": {}, "source": [ "## Analyze Results\n", @@ -248,20 +264,25 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "15", "metadata": {}, "outputs": [], "source": [ "chp_size = flow_system.statistics.sizes['CHP(P_el)']\n", - "total_cost = flow_system.solution['costs']\n", "\n", - "print(f'Optimal CHP: {float(chp_size.max()):.0f} kW electrical ({float(chp_size.max()) * 0.50 / 0.35:.0f} kW thermal)')\n", - "print(f'Expected cost: {float(total_cost.sum()):.0f} €')" + "pd.DataFrame(\n", + " {\n", + " 'CHP Electrical [kW]': float(chp_size.max()),\n", + " 'CHP Thermal [kW]': float(chp_size.max()) * 0.50 / 0.35,\n", + " 'Expected Cost [EUR]': float(flow_system.solution['costs'].sum()),\n", + " },\n", + " index=['Optimal'],\n", + ").T" ] }, { "cell_type": "markdown", - "id": "15", + "id": "16", "metadata": {}, "source": [ "### Heat Balance by Scenario\n", @@ -272,7 +293,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -281,7 +302,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "18", "metadata": {}, "source": [ "### CHP Operation Patterns" @@ -290,7 +311,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -299,7 +320,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "20", "metadata": {}, "source": [ "### Multi-Dimensional Data Access\n", @@ -310,13 +331,11 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "21", "metadata": {}, "outputs": [], "source": [ - "# View dimensions\n", "flow_rates = flow_system.statistics.flow_rates\n", - "print('Flow rates dimensions:', dict(flow_rates.sizes))\n", "\n", "# Plot flow rates\n", "flow_system.statistics.plot.flows()" @@ -325,22 +344,27 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "22", "metadata": {}, "outputs": [], "source": [ "# CHP operation summary by scenario\n", "chp_heat = flow_rates['CHP(Q_th)']\n", "\n", - "for scenario in scenarios:\n", - " scenario_avg = float(chp_heat.sel(scenario=scenario).mean())\n", - " scenario_max = float(chp_heat.sel(scenario=scenario).max())\n", - " print(f'{scenario}: avg {scenario_avg:.0f} kW, max {scenario_max:.0f} kW')" + "pd.DataFrame(\n", + " {\n", + " scenario: {\n", + " 'Avg [kW]': float(chp_heat.sel(scenario=scenario).mean()),\n", + " 'Max [kW]': float(chp_heat.sel(scenario=scenario).max()),\n", + " }\n", + " for scenario in scenarios\n", + " }\n", + ")" ] }, { "cell_type": "markdown", - "id": "22", + "id": "23", "metadata": {}, "source": [ "## Sensitivity: What if Only Mild Winter?\n", @@ -351,7 +375,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -362,14 +386,18 @@ "chp_size_mild = float(fs_mild.statistics.sizes['CHP(P_el)'].max())\n", "chp_size_both = float(chp_size.max())\n", "\n", - "print(\n", - " f'CHP sizing: {chp_size_mild:.0f} kW (mild only) vs {chp_size_both:.0f} kW (both scenarios) → +{chp_size_both - chp_size_mild:.0f} kW for uncertainty'\n", + "pd.DataFrame(\n", + " {\n", + " 'Mild Only': {'CHP Size [kW]': chp_size_mild},\n", + " 'Both Scenarios': {'CHP Size [kW]': chp_size_both},\n", + " 'Uncertainty Buffer': {'CHP Size [kW]': chp_size_both - chp_size_mild},\n", + " }\n", ")" ] }, { "cell_type": "markdown", - "id": "24", + "id": "25", "metadata": {}, "source": [ "### Energy Flow Sankey\n", @@ -380,7 +408,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -389,7 +417,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "27", "metadata": {}, "source": [ "## Key Concepts\n", diff --git a/docs/notebooks/08a-aggregation.ipynb b/docs/notebooks/08a-aggregation.ipynb index 8bc1a4774..e26c19223 100644 --- a/docs/notebooks/08a-aggregation.ipynb +++ b/docs/notebooks/08a-aggregation.ipynb @@ -172,6 +172,7 @@ "# Stage 2: Dispatch at full resolution with fixed sizes\n", "start = timeit.default_timer()\n", "fs_dispatch = flow_system.transform.fix_sizes(fs_sizing.statistics.sizes)\n", + "fs_dispatch.name = 'Two-Stage'\n", "fs_dispatch.optimize(solver)\n", "time_stage2 = timeit.default_timer() - start\n", "\n", @@ -199,6 +200,7 @@ "source": [ "start = timeit.default_timer()\n", "fs_full = flow_system.copy()\n", + "fs_full.name = 'Full Optimization'\n", "fs_full.optimize(solver)\n", "time_full = timeit.default_timer() - start\n", "\n", @@ -271,7 +273,9 @@ "id": "16", "metadata": {}, "source": [ - "## Visual Comparison: Heat Balance" + "## Visual Comparison: Heat Balance\n", + "\n", + "Compare the full optimization with the two-stage approach side-by-side:" ] }, { @@ -281,24 +285,14 @@ "metadata": {}, "outputs": [], "source": [ - "# Full optimization heat balance\n", - "fs_full.statistics.plot.balance('Heat')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18", - "metadata": {}, - "outputs": [], - "source": [ - "# Two-stage optimization heat balance\n", - "fs_dispatch.statistics.plot.balance('Heat')" + "# Side-by-side comparison of full optimization vs two-stage\n", + "comp = fx.Comparison([fs_full, fs_dispatch])\n", + "comp.statistics.plot.balance('Heat')" ] }, { "cell_type": "markdown", - "id": "19", + "id": "18", "metadata": {}, "source": [ "### Energy Flow Sankey (Full Optimization)\n", @@ -309,7 +303,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -318,7 +312,7 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "20", "metadata": {}, "source": [ "## When to Use Each Technique\n", @@ -358,7 +352,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "21", "metadata": {}, "source": [ "## Summary\n", @@ -389,7 +383,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 c0d7bdf24..90da6d2ca 100644 --- a/docs/notebooks/08b-rolling-horizon.ipynb +++ b/docs/notebooks/08b-rolling-horizon.ipynb @@ -94,6 +94,7 @@ "\n", "start = timeit.default_timer()\n", "fs_full = flow_system.copy()\n", + "fs_full.name = 'Full Optimization'\n", "fs_full.optimize(solver)\n", "time_full = timeit.default_timer() - start\n", "\n", @@ -133,6 +134,7 @@ "source": [ "start = timeit.default_timer()\n", "fs_rolling = flow_system.copy()\n", + "fs_rolling.name = 'Rolling Horizon'\n", "segments = fs_rolling.optimize.rolling_horizon(\n", " solver,\n", " horizon=192, # 2-day segments (192 timesteps at 15-min resolution)\n", @@ -179,7 +181,9 @@ "id": "11", "metadata": {}, "source": [ - "## Visualize: Heat Balance Comparison" + "markdown## Visualize: Heat Balance Comparison\n", + "\n", + "Use the `Comparison` class to view both methods side-by-side:" ] }, { @@ -189,22 +193,13 @@ "metadata": {}, "outputs": [], "source": [ - "fs_full.statistics.plot.balance('Heat').figure.update_layout(title='Heat Balance (Full)')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13", - "metadata": {}, - "outputs": [], - "source": [ - "fs_rolling.statistics.plot.balance('Heat').figure.update_layout(title='Heat Balance (Rolling)')" + "comp = fx.Comparison([fs_full, fs_rolling])\n", + "comp.statistics.plot.balance('Heat')" ] }, { "cell_type": "markdown", - "id": "14", + "id": "13", "metadata": {}, "source": [ "## Storage State Continuity\n", @@ -215,7 +210,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "14", "metadata": {}, "outputs": [], "source": [ @@ -239,7 +234,7 @@ }, { "cell_type": "markdown", - "id": "16", + "id": "15", "metadata": {}, "source": [ "## Inspect Individual Segments\n", @@ -250,7 +245,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -263,7 +258,7 @@ }, { "cell_type": "markdown", - "id": "18", + "id": "17", "metadata": {}, "source": [ "## Visualize Segment Overlaps\n", @@ -274,7 +269,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -291,7 +286,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -303,7 +298,7 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "20", "metadata": {}, "source": [ "## When to Use Rolling Horizon\n", @@ -324,7 +319,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "21", "metadata": {}, "source": [ "## API Reference\n", @@ -348,7 +343,7 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "22", "metadata": {}, "source": [ "## Summary\n", diff --git a/docs/notebooks/08c-clustering.ipynb b/docs/notebooks/08c-clustering.ipynb index 0f3b4cc29..6d85e60ba 100644 --- a/docs/notebooks/08c-clustering.ipynb +++ b/docs/notebooks/08c-clustering.ipynb @@ -101,6 +101,7 @@ "\n", "start = timeit.default_timer()\n", "fs_full = flow_system.copy()\n", + "fs_full.name = 'Full Optimization'\n", "fs_full.optimize(solver)\n", "time_full = timeit.default_timer() - start" ] @@ -142,6 +143,7 @@ " cluster_duration='1D', # Daily clustering\n", " time_series_for_high_peaks=peak_series, # Capture peak demand day\n", ")\n", + "fs_clustered.name = 'Clustered (8 days)'\n", "\n", "time_clustering = timeit.default_timer() - start" ] @@ -239,9 +241,7 @@ " cluster_method='k_means', # Alternative: 'hierarchical' (default), 'k_medoids', 'averaging'\n", ")\n", "\n", - "# Compare cluster assignments between algorithms\n", - "print('hierarchical clusters:', fs_clustered.clustering.cluster_order.values)\n", - "print('k_means clusters: ', fs_kmeans.clustering.cluster_order.values)" + "fs_kmeans.clustering" ] }, { @@ -251,13 +251,12 @@ "metadata": {}, "outputs": [], "source": [ - "# Compare RMSE between algorithms\n", - "print('Quality comparison (RMSE for HeatDemand):')\n", - "print(\n", - " f' hierarchical: {float(fs_clustered.clustering.metrics[\"RMSE\"].sel(time_series=\"HeatDemand(Q_th)|fixed_relative_profile\")):.4f}'\n", - ")\n", - "print(\n", - " f' k_means: {float(fs_kmeans.clustering.metrics[\"RMSE\"].sel(time_series=\"HeatDemand(Q_th)|fixed_relative_profile\")):.4f}'\n", + "# Compare quality metrics between algorithms\n", + "pd.DataFrame(\n", + " {\n", + " 'hierarchical': fs_clustered.clustering.metrics.to_dataframe().iloc[0],\n", + " 'k_means': fs_kmeans.clustering.metrics.to_dataframe().iloc[0],\n", + " }\n", ")" ] }, @@ -293,7 +292,6 @@ "source": [ "# Save the cluster order from our optimized system\n", "cluster_order = fs_clustered.clustering.cluster_order.values\n", - "print(f'Cluster order to reuse: {cluster_order}')\n", "\n", "# Now modify the FlowSystem (e.g., increase storage capacity limits)\n", "flow_system_modified = flow_system.copy()\n", @@ -305,15 +303,13 @@ " cluster_duration='1D',\n", " predef_cluster_order=cluster_order, # Reuse cluster assignments\n", ")\n", + "fs_modified_clustered.name = 'Modified (larger storage limit)'\n", "\n", "# Optimize the modified system\n", "fs_modified_clustered.optimize(solver)\n", "\n", - "print('\\nComparison (same cluster structure):')\n", - "print(f' Original storage size: {fs_clustered.statistics.sizes[\"Storage\"].item():.0f}')\n", - "print(f' Modified storage size: {fs_modified_clustered.statistics.sizes[\"Storage\"].item():.0f}')\n", - "print(f' Original cost: {fs_clustered.solution[\"costs\"].item():,.0f} €')\n", - "print(f' Modified cost: {fs_modified_clustered.solution[\"costs\"].item():,.0f} €')" + "# Compare results using Comparison class\n", + "fx.Comparison([fs_clustered, fs_modified_clustered])" ] }, { @@ -356,6 +352,7 @@ "start = timeit.default_timer()\n", "\n", "fs_dispatch = flow_system.transform.fix_sizes(sizes_with_margin)\n", + "fs_dispatch.name = 'Two-Stage'\n", "fs_dispatch.optimize(solver)\n", "\n", "time_dispatch = timeit.default_timer() - start\n", @@ -544,7 +541,32 @@ "| `'cyclic'` | Each cluster is independent but cyclic (start = end) |\n", "| `'independent'` | Each cluster is independent, free start/end |\n", "\n", - "For a detailed comparison of storage modes, see [08c2-clustering-storage-modes](08c2-clustering-storage-modes.ipynb)." + "For a detailed comparison of storage modes, see [08c2-clustering-storage-modes](08c2-clustering-storage-modes.ipynb).\n", + "\n", + "### Peak Forcing Format\n", + "\n", + "```python\n", + "time_series_for_high_peaks = ['ComponentName(FlowName)|fixed_relative_profile']\n", + "```\n", + "\n", + "### Recommended Workflow\n", + "\n", + "```python\n", + "# Stage 1: Fast sizing\n", + "fs_sizing = flow_system.transform.cluster(\n", + " n_clusters=8,\n", + " cluster_duration='1D',\n", + " time_series_for_high_peaks=['Demand(Flow)|fixed_relative_profile'],\n", + ")\n", + "fs_sizing.optimize(solver)\n", + "\n", + "# Apply safety margin\n", + "sizes = {k: v.item() * 1.05 for k, v in fs_sizing.statistics.sizes.items()}\n", + "\n", + "# Stage 2: Accurate dispatch\n", + "fs_dispatch = flow_system.transform.fix_sizes(sizes)\n", + "fs_dispatch.optimize(solver)\n", + "```" ] }, { @@ -580,17 +602,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/08c2-clustering-storage-modes.ipynb b/docs/notebooks/08c2-clustering-storage-modes.ipynb index 7a760edc3..3a9ab88ad 100644 --- a/docs/notebooks/08c2-clustering-storage-modes.ipynb +++ b/docs/notebooks/08c2-clustering-storage-modes.ipynb @@ -65,7 +65,7 @@ "flow_system.connect_and_transform() # Align all data as xarray\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())}')" ] }, @@ -142,6 +142,7 @@ "\n", "start = timeit.default_timer()\n", "fs_full = flow_system.copy()\n", + "fs_full.name = 'Full Optimization'\n", "fs_full.optimize(solver)\n", "time_full = timeit.default_timer() - start\n", "\n", @@ -277,7 +278,9 @@ "# Expand clustered solutions to full resolution\n", "expanded_systems = {}\n", "for mode in storage_modes:\n", - " expanded_systems[mode] = clustered_systems[mode].transform.expand_solution()" + " fs_expanded = clustered_systems[mode].transform.expand_solution()\n", + " fs_expanded.name = f'Mode: {mode}'\n", + " expanded_systems[mode] = fs_expanded" ] }, { @@ -316,6 +319,28 @@ "cell_type": "markdown", "id": "14", "metadata": {}, + "source": [ + "### Side-by-Side Comparison\n", + "\n", + "Use the `Comparison` class to compare the full optimization with the recommended mode:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "# Compare full optimization with the recommended intercluster_cyclic mode\n", + "comp = fx.Comparison([fs_full, expanded_systems['intercluster_cyclic']])\n", + "comp.statistics.plot.balance('Heat')" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, "source": [ "## Interpretation\n", "\n", @@ -343,7 +368,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "17", "metadata": {}, "source": [ "## When to Use Each Mode\n", @@ -378,7 +403,7 @@ }, { "cell_type": "markdown", - "id": "16", + "id": "18", "metadata": {}, "source": [ "## Summary\n", diff --git a/docs/notebooks/08d-clustering-multiperiod.ipynb b/docs/notebooks/08d-clustering-multiperiod.ipynb index 016d9555b..e599384ac 100644 --- a/docs/notebooks/08d-clustering-multiperiod.ipynb +++ b/docs/notebooks/08d-clustering-multiperiod.ipynb @@ -140,6 +140,7 @@ "\n", "start = timeit.default_timer()\n", "fs_full = flow_system.copy()\n", + "fs_full.name = 'Full Optimization'\n", "fs_full.optimize(solver)\n", "time_full = timeit.default_timer() - start\n", "\n", @@ -345,6 +346,7 @@ "start = timeit.default_timer()\n", "\n", "fs_dispatch = flow_system.transform.fix_sizes(sizes_with_margin)\n", + "fs_dispatch.name = 'Two-Stage'\n", "fs_dispatch.optimize(solver)\n", "\n", "time_dispatch = timeit.default_timer() - start\n", @@ -434,9 +436,21 @@ ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "id": "25", "metadata": {}, + "outputs": [], + "source": [ + "# Side-by-side comparison using the Comparison class\n", + "comp = fx.Comparison([fs_full, fs_dispatch])\n", + "comp.statistics.plot.balance('Heat')" + ] + }, + { + "cell_type": "markdown", + "id": "26", + "metadata": {}, "source": [ "## Expand Clustered Solution to Full Resolution\n", "\n", @@ -446,7 +460,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -460,7 +474,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -470,7 +484,7 @@ }, { "cell_type": "markdown", - "id": "28", + "id": "29", "metadata": {}, "source": [ "## Key Considerations for Multi-Period Clustering\n", @@ -504,7 +518,7 @@ }, { "cell_type": "markdown", - "id": "29", + "id": "30", "metadata": {}, "source": [ "## Summary\n", diff --git a/docs/user-guide/results/index.md b/docs/user-guide/results/index.md index a9b40f7f9..500a64cd9 100644 --- a/docs/user-guide/results/index.md +++ b/docs/user-guide/results/index.md @@ -277,6 +277,156 @@ flow_system.statistics.plot.heatmap('Boiler(Q_th)|flow_rate') flow_system.to_netcdf('results/optimized_system.nc') ``` +## Comparing Multiple Systems + +Use the [`Comparison`][flixopt.comparison.Comparison] class to analyze and visualize multiple FlowSystems side-by-side. This is useful for: + +- Comparing different design alternatives (with/without CHP, different storage sizes) +- Analyzing optimization method trade-offs (full vs. two-stage, different aggregation levels) +- Sensitivity analysis (different scenarios, parameter variations) + +### Basic Usage + +```python +import flixopt as fx + +# Optimize two system variants +fs_baseline = create_system() +fs_baseline.name = 'Baseline' +fs_baseline.optimize(solver) + +fs_with_storage = create_system_with_storage() +fs_with_storage.name = 'With Storage' +fs_with_storage.optimize(solver) + +# Create comparison +comp = fx.Comparison([fs_baseline, fs_with_storage]) + +# Side-by-side balance plots (auto-faceted by 'case' dimension) +comp.statistics.plot.balance('Heat') + +# Access combined data with 'case' dimension +comp.statistics.flow_rates # xr.Dataset with dims: (time, case) +comp.solution # Combined solution dataset +``` + +### Requirements + +All FlowSystems must have **matching core dimensions** (`time`, `period`, `scenario`). Auxiliary dimensions like `cluster_boundary` are ignored. If core dimensions differ, use `.transform.sel()` to align them first: + +```python +# Systems with different scenarios +fs_both = flow_system # Has 'Mild Winter' and 'Harsh Winter' scenarios +fs_mild = flow_system.transform.sel(scenario='Mild Winter') # Single scenario + +# Cannot compare directly - scenario dimension mismatch! +# fx.Comparison([fs_both, fs_mild]) # Raises ValueError + +# Instead, select matching dimensions +fs_both_mild = fs_both.transform.sel(scenario='Mild Winter') +comp = fx.Comparison([fs_both_mild, fs_mild]) # Works! + +# Auxiliary dimensions are OK (e.g., expanded clustered solutions) +fs_expanded = fs_clustered.transform.expand_solution() # Has cluster_boundary dim +comp = fx.Comparison([fs_full, fs_expanded]) # Works! cluster_boundary is ignored +``` + +### Available Properties + +The `Comparison.statistics` accessor mirrors all `StatisticsAccessor` properties, returning combined datasets with an added `'case'` dimension: + +| Property | Description | +|----------|-------------| +| `flow_rates` | All flow rate variables | +| `flow_hours` | Flow hours (energy) | +| `sizes` | Component sizes | +| `storage_sizes` | Storage capacities | +| `charge_states` | Storage charge states | +| `temporal_effects` | Effects per timestep | +| `periodic_effects` | Investment effects | +| `total_effects` | Combined effects | + +### Available Plot Methods + +All standard plot methods work on the comparison, with the `'case'` dimension automatically used for faceting: + +```python +comp = fx.Comparison([fs_baseline, fs_modified]) + +# Balance plots - faceted by case +comp.statistics.plot.balance('Heat') +comp.statistics.plot.balance('Electricity', mode='area') + +# Flow plots +comp.statistics.plot.flows(component='CHP') + +# Effect breakdowns +comp.statistics.plot.effects() + +# Heatmaps +comp.statistics.plot.heatmap('Boiler(Q_th)') + +# Duration curves +comp.statistics.plot.duration_curve('CHP(Q_th)') + +# Storage plots +comp.statistics.plot.storage('Battery') +``` + +### Computing Differences + +Use the `diff()` method to compute differences relative to a reference case: + +```python +# Differences relative to first case (default) +differences = comp.diff() + +# Differences relative to specific case +differences = comp.diff(reference='Baseline') +differences = comp.diff(reference=0) # By index + +# Analyze differences +print(differences['costs']) # Cost difference per case +``` + +### Naming Systems + +System names come from `FlowSystem.name` by default. Override with the `names` parameter: + +```python +# Using FlowSystem.name (default) +fs1.name = 'Scenario A' +fs2.name = 'Scenario B' +comp = fx.Comparison([fs1, fs2]) + +# Or override explicitly +comp = fx.Comparison([fs1, fs2], names=['Base Case', 'Alternative']) +``` + +### Example: Comparing Optimization Methods + +```python +# Full optimization +fs_full = flow_system.copy() +fs_full.name = 'Full Optimization' +fs_full.optimize(solver) + +# Two-stage optimization +fs_sizing = flow_system.transform.resample('4h') +fs_sizing.optimize(solver) +fs_dispatch = flow_system.transform.fix_sizes(fs_sizing.statistics.sizes) +fs_dispatch.name = 'Two-Stage' +fs_dispatch.optimize(solver) + +# Compare results +comp = fx.Comparison([fs_full, fs_dispatch]) +comp.statistics.plot.balance('Heat') + +# Check cost difference +diff = comp.diff() +print(f"Cost difference: {diff['costs'].sel(case='Two-Stage').item():.0f} €") +``` + ## Next Steps - [Plotting Results](../results-plotting.md) - Detailed plotting documentation diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 54fa21274..b84b82a4f 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -17,6 +17,7 @@ # Register xr.Dataset.fxplot accessor (import triggers registration via decorator) from . import dataset_plot_accessor as _ # noqa: F401 from .carrier import Carrier, CarrierContainer +from .comparison import Comparison from .components import ( LinearConverter, Sink, @@ -39,6 +40,7 @@ 'CONFIG', 'Carrier', 'CarrierContainer', + 'Comparison', 'Flow', 'Bus', 'Effect', diff --git a/flixopt/comparison.py b/flixopt/comparison.py new file mode 100644 index 000000000..63a00a0f1 --- /dev/null +++ b/flixopt/comparison.py @@ -0,0 +1,609 @@ +"""Compare multiple FlowSystems side-by-side.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import xarray as xr + +from .config import CONFIG +from .plot_result import PlotResult + +if TYPE_CHECKING: + from .flow_system import FlowSystem + +__all__ = ['Comparison'] + +# Type aliases (matching statistics_accessor.py) +SelectType = dict[str, Any] +FilterType = str | list[str] +ColorType = str | list[str] | dict[str, str] | None + + +class Comparison: + """Compare multiple FlowSystems side-by-side. + + Combines solutions and statistics from multiple FlowSystems into unified + xarray Datasets with a 'case' dimension. The existing plotting infrastructure + automatically handles faceting by the 'case' dimension. + + All FlowSystems must have matching dimensions (time, period, scenario, etc.). + Use `flow_system.transform.sel()` to align dimensions before comparing. + + Args: + flow_systems: List of FlowSystems to compare. All must be optimized + and have matching dimensions. + names: Optional names for each case. If None, uses FlowSystem.name. + + Raises: + ValueError: If FlowSystems have mismatched dimensions. + RuntimeError: If any FlowSystem has no solution. + + Examples: + ```python + # Compare two systems (uses FlowSystem.name by default) + comp = fx.Comparison([fs_base, fs_modified]) + + # Or with custom names + comp = fx.Comparison([fs_base, fs_modified], names=['baseline', 'modified']) + + # Side-by-side plots (auto-facets by 'case') + comp.statistics.plot.balance('Heat') + comp.statistics.flow_rates.fxplot.line() + + # Access combined data + comp.solution # xr.Dataset with 'case' dimension + comp.statistics.flow_rates # xr.Dataset with 'case' dimension + + # Compute differences relative to first case + comp.diff() # Returns xr.Dataset of differences + comp.diff('baseline') # Or specify reference by name + + # For systems with different dimensions, align first: + fs_both = ... # Has scenario dimension + fs_mild = fs_both.transform.sel(scenario='Mild') # Select one scenario + fs_other = ... # Also select to match + comp = fx.Comparison([fs_mild, fs_other]) # Now dimensions match + ``` + """ + + def __init__(self, flow_systems: list[FlowSystem], names: list[str] | None = None) -> None: + if len(flow_systems) < 2: + raise ValueError('Comparison requires at least 2 FlowSystems') + + self._systems = flow_systems + self._names = names or [fs.name or f'System {i}' for i, fs in enumerate(flow_systems)] + + if len(self._names) != len(self._systems): + raise ValueError( + f'Number of names ({len(self._names)}) must match number of FlowSystems ({len(self._systems)})' + ) + + if len(set(self._names)) != len(self._names): + raise ValueError(f'Case names must be unique, got: {self._names}') + + # Validate all FlowSystems have solutions + for fs in flow_systems: + if fs.solution is None: + raise RuntimeError(f"FlowSystem '{fs.name}' has no solution. Run optimize() first.") + + # Validate matching dimensions across all FlowSystems + self._validate_matching_dimensions() + + # Caches + self._solution: xr.Dataset | None = None + self._statistics: ComparisonStatistics | None = None + + # Core dimensions that must match across FlowSystems + # Note: 'cluster' and 'cluster_boundary' are auxiliary dimensions from clustering + _CORE_DIMS = {'time', 'period', 'scenario'} + + def _validate_matching_dimensions(self) -> None: + """Validate that all FlowSystems have matching core dimensions. + + Only validates core dimensions (time, period, scenario). Auxiliary + dimensions like 'cluster_boundary' are ignored as they don't affect + the comparison logic. + """ + reference = self._systems[0] + ref_core_dims = set(reference.solution.dims) & self._CORE_DIMS + ref_name = self._names[0] + + for fs, name in zip(self._systems[1:], self._names[1:], strict=True): + fs_core_dims = set(fs.solution.dims) & self._CORE_DIMS + if fs_core_dims != ref_core_dims: + missing = ref_core_dims - fs_core_dims + extra = fs_core_dims - ref_core_dims + msg_parts = [f"Core dimension mismatch between '{ref_name}' and '{name}'."] + if missing: + msg_parts.append(f"Missing in '{name}': {missing}.") + if extra: + msg_parts.append(f"Extra in '{name}': {extra}.") + msg_parts.append('Use .transform.sel() to align dimensions before comparing.') + raise ValueError(' '.join(msg_parts)) + + @property + def names(self) -> list[str]: + """Case names for each FlowSystem.""" + return self._names + + @property + def solution(self) -> xr.Dataset: + """Combined solution Dataset with 'case' dimension.""" + if self._solution is None: + datasets = [] + for fs, name in zip(self._systems, self._names, strict=True): + ds = fs.solution.expand_dims(case=[name]) + datasets.append(ds) + self._solution = xr.concat(datasets, dim='case', join='outer', fill_value=float('nan')) + return self._solution + + @property + def statistics(self) -> ComparisonStatistics: + """Combined statistics accessor with 'case' dimension.""" + if self._statistics is None: + self._statistics = ComparisonStatistics(self) + return self._statistics + + def diff(self, reference: str | int = 0) -> xr.Dataset: + """Compute differences relative to a reference case. + + Args: + reference: Reference case name or index (default: 0, first case). + + Returns: + Dataset with differences (each case minus reference). + """ + if isinstance(reference, str): + if reference not in self._names: + raise ValueError(f"Reference '{reference}' not found. Available: {self._names}") + ref_idx = self._names.index(reference) + else: + ref_idx = reference + + ref_data = self.solution.isel(case=ref_idx) + return self.solution - ref_data + + +class ComparisonStatistics: + """Combined statistics accessor for comparing FlowSystems. + + Mirrors StatisticsAccessor properties, concatenating data with a 'case' dimension. + Access via ``Comparison.statistics``. + """ + + def __init__(self, comparison: Comparison) -> None: + self._comp = comparison + # Caches for dataset properties + self._flow_rates: xr.Dataset | None = None + self._flow_hours: xr.Dataset | None = None + self._flow_sizes: xr.Dataset | None = None + self._storage_sizes: xr.Dataset | None = None + self._sizes: xr.Dataset | None = None + self._charge_states: xr.Dataset | None = None + self._temporal_effects: xr.Dataset | None = None + self._periodic_effects: xr.Dataset | None = None + self._total_effects: xr.Dataset | None = None + # Caches for dict properties + self._carrier_colors: dict[str, str] | None = None + self._component_colors: dict[str, str] | None = None + self._bus_colors: dict[str, str] | None = None + self._carrier_units: dict[str, str] | None = None + self._effect_units: dict[str, str] | None = None + # Plot accessor + self._plot: ComparisonStatisticsPlot | None = None + + def _concat_property(self, prop_name: str) -> xr.Dataset: + """Concatenate a statistics property across all cases.""" + datasets = [] + for fs, name in zip(self._comp._systems, self._comp._names, strict=True): + ds = getattr(fs.statistics, prop_name) + datasets.append(ds.expand_dims(case=[name])) + return xr.concat(datasets, dim='case', join='outer', fill_value=float('nan')) + + def _merge_dict_property(self, prop_name: str) -> dict[str, str]: + """Merge a dict property from all cases (later cases override).""" + result: dict[str, str] = {} + for fs in self._comp._systems: + result.update(getattr(fs.statistics, prop_name)) + return result + + @property + def flow_rates(self) -> xr.Dataset: + """Combined flow rates with 'case' dimension.""" + if self._flow_rates is None: + self._flow_rates = self._concat_property('flow_rates') + return self._flow_rates + + @property + def flow_hours(self) -> xr.Dataset: + """Combined flow hours (energy) with 'case' dimension.""" + if self._flow_hours is None: + self._flow_hours = self._concat_property('flow_hours') + return self._flow_hours + + @property + def flow_sizes(self) -> xr.Dataset: + """Combined flow investment sizes with 'case' dimension.""" + if self._flow_sizes is None: + self._flow_sizes = self._concat_property('flow_sizes') + return self._flow_sizes + + @property + def storage_sizes(self) -> xr.Dataset: + """Combined storage capacity sizes with 'case' dimension.""" + if self._storage_sizes is None: + self._storage_sizes = self._concat_property('storage_sizes') + return self._storage_sizes + + @property + def sizes(self) -> xr.Dataset: + """Combined sizes (flow + storage) with 'case' dimension.""" + if self._sizes is None: + self._sizes = self._concat_property('sizes') + return self._sizes + + @property + def charge_states(self) -> xr.Dataset: + """Combined storage charge states with 'case' dimension.""" + if self._charge_states is None: + self._charge_states = self._concat_property('charge_states') + return self._charge_states + + @property + def temporal_effects(self) -> xr.Dataset: + """Combined temporal effects with 'case' dimension.""" + if self._temporal_effects is None: + self._temporal_effects = self._concat_property('temporal_effects') + return self._temporal_effects + + @property + def periodic_effects(self) -> xr.Dataset: + """Combined periodic effects with 'case' dimension.""" + if self._periodic_effects is None: + self._periodic_effects = self._concat_property('periodic_effects') + return self._periodic_effects + + @property + def total_effects(self) -> xr.Dataset: + """Combined total effects with 'case' dimension.""" + if self._total_effects is None: + self._total_effects = self._concat_property('total_effects') + return self._total_effects + + @property + def carrier_colors(self) -> dict[str, str]: + """Merged carrier colors from all cases.""" + if self._carrier_colors is None: + self._carrier_colors = self._merge_dict_property('carrier_colors') + return self._carrier_colors + + @property + def component_colors(self) -> dict[str, str]: + """Merged component colors from all cases.""" + if self._component_colors is None: + self._component_colors = self._merge_dict_property('component_colors') + return self._component_colors + + @property + def bus_colors(self) -> dict[str, str]: + """Merged bus colors from all cases.""" + if self._bus_colors is None: + self._bus_colors = self._merge_dict_property('bus_colors') + return self._bus_colors + + @property + def carrier_units(self) -> dict[str, str]: + """Merged carrier units from all cases.""" + if self._carrier_units is None: + self._carrier_units = self._merge_dict_property('carrier_units') + return self._carrier_units + + @property + def effect_units(self) -> dict[str, str]: + """Merged effect units from all cases.""" + if self._effect_units is None: + self._effect_units = self._merge_dict_property('effect_units') + return self._effect_units + + @property + def plot(self) -> ComparisonStatisticsPlot: + """Access plot methods for comparison statistics.""" + if self._plot is None: + self._plot = ComparisonStatisticsPlot(self) + return self._plot + + +class ComparisonStatisticsPlot: + """Plot accessor for comparison statistics. + + Wraps StatisticsPlotAccessor methods, combining data from all FlowSystems + with a 'case' dimension for faceting. + """ + + # Data-related kwargs for each method (everything else is plotly kwargs) + _DATA_KWARGS: dict[str, set[str]] = { + 'balance': {'select', 'include', 'exclude', 'unit'}, + 'carrier_balance': {'select', 'include', 'exclude', 'unit'}, + 'flows': {'start', 'end', 'component', 'select', 'unit'}, + 'storage': {'select', 'unit', 'charge_state_color'}, + 'charge_states': {'select'}, + 'duration_curve': {'select', 'normalize'}, + 'sizes': {'max_size', 'select'}, + 'effects': {'effect', 'by', 'select'}, + 'heatmap': {'select', 'reshape'}, + } + + def __init__(self, statistics: ComparisonStatistics) -> None: + self._stats = statistics + self._comp = statistics._comp + + def _split_kwargs(self, method_name: str, kwargs: dict) -> tuple[dict, dict]: + """Split kwargs into data kwargs and plotly kwargs.""" + data_keys = self._DATA_KWARGS.get(method_name, set()) + data_kwargs = {k: v for k, v in kwargs.items() if k in data_keys} + plotly_kwargs = {k: v for k, v in kwargs.items() if k not in data_keys} + return data_kwargs, plotly_kwargs + + def _combine_data(self, method_name: str, *args, **kwargs) -> tuple[xr.Dataset, str]: + """Call plot method on each system and combine data. Returns (combined_data, title).""" + datasets = [] + title = '' + kwargs = {**kwargs, 'show': False} # Don't mutate original + + for fs, case_name in zip(self._comp._systems, self._comp._names, strict=True): + try: + result = getattr(fs.statistics.plot, method_name)(*args, **kwargs) + datasets.append(result.data.expand_dims(case=[case_name])) + title = result.figure.layout.title.text or title + except (KeyError, ValueError): + continue + + if not datasets: + return xr.Dataset(), '' + + return xr.concat(datasets, dim='case', join='outer', fill_value=float('nan')), title + + def _finalize(self, ds: xr.Dataset, fig, show: bool | None) -> PlotResult: + """Handle show and return PlotResult.""" + import plotly.graph_objects as go + + if show is None: + show = CONFIG.Plotting.default_show + if show and fig: + fig.show() + return PlotResult(data=ds, figure=fig or go.Figure()) + + def balance( + self, + node: str, + *, + colors=None, + facet_col='auto', + facet_row='auto', + animation_frame='auto', + show: bool | None = None, + **kwargs, + ) -> PlotResult: + """Plot node balance comparison. See StatisticsPlotAccessor.balance.""" + data_kw, plotly_kw = self._split_kwargs('balance', kwargs) + ds, title = self._combine_data('balance', node, **data_kw) + if not ds.data_vars: + return self._finalize(ds, None, show) + fig = ds.fxplot.stacked_bar( + colors=colors, + title=title, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + **plotly_kw, + ) + return self._finalize(ds, fig, show) + + def carrier_balance( + self, + carrier: str, + *, + colors=None, + facet_col='auto', + facet_row='auto', + animation_frame='auto', + show: bool | None = None, + **kwargs, + ) -> PlotResult: + """Plot carrier balance comparison. See StatisticsPlotAccessor.carrier_balance.""" + data_kw, plotly_kw = self._split_kwargs('carrier_balance', kwargs) + ds, title = self._combine_data('carrier_balance', carrier, **data_kw) + if not ds.data_vars: + return self._finalize(ds, None, show) + fig = ds.fxplot.stacked_bar( + colors=colors, + title=title, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + **plotly_kw, + ) + return self._finalize(ds, fig, show) + + def flows( + self, + *, + colors=None, + facet_col='auto', + facet_row='auto', + animation_frame='auto', + show: bool | None = None, + **kwargs, + ) -> PlotResult: + """Plot flows comparison. See StatisticsPlotAccessor.flows.""" + data_kw, plotly_kw = self._split_kwargs('flows', kwargs) + ds, title = self._combine_data('flows', **data_kw) + if not ds.data_vars: + return self._finalize(ds, None, show) + fig = ds.fxplot.line( + colors=colors, + title=title, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + **plotly_kw, + ) + return self._finalize(ds, fig, show) + + def storage( + self, + storage: str, + *, + colors=None, + facet_col='auto', + facet_row='auto', + animation_frame='auto', + show: bool | None = None, + **kwargs, + ) -> PlotResult: + """Plot storage operation comparison. See StatisticsPlotAccessor.storage.""" + data_kw, plotly_kw = self._split_kwargs('storage', kwargs) + ds, title = self._combine_data('storage', storage, **data_kw) + if not ds.data_vars: + return self._finalize(ds, None, show) + fig = ds.fxplot.stacked_bar( + colors=colors, + title=title, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + **plotly_kw, + ) + return self._finalize(ds, fig, show) + + def charge_states( + self, + storages=None, + *, + colors=None, + facet_col='auto', + facet_row='auto', + animation_frame='auto', + show: bool | None = None, + **kwargs, + ) -> PlotResult: + """Plot charge states comparison. See StatisticsPlotAccessor.charge_states.""" + data_kw, plotly_kw = self._split_kwargs('charge_states', kwargs) + ds, title = self._combine_data('charge_states', storages, **data_kw) + if not ds.data_vars: + return self._finalize(ds, None, show) + fig = ds.fxplot.line( + colors=colors, + title=title, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + **plotly_kw, + ) + fig.update_yaxes(title_text='Charge State') + return self._finalize(ds, fig, show) + + def duration_curve( + self, + variables, + *, + normalize: bool = False, + colors=None, + facet_col='auto', + facet_row='auto', + animation_frame='auto', + show: bool | None = None, + **kwargs, + ) -> PlotResult: + """Plot duration curves comparison. See StatisticsPlotAccessor.duration_curve.""" + data_kw, plotly_kw = self._split_kwargs('duration_curve', kwargs) + ds, title = self._combine_data('duration_curve', variables, normalize=normalize, **data_kw) + if not ds.data_vars: + return self._finalize(ds, None, show) + fig = ds.fxplot.line( + colors=colors, + title=title, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + **plotly_kw, + ) + fig.update_xaxes(title_text='Duration [%]' if normalize else 'Timesteps') + return self._finalize(ds, fig, show) + + def sizes( + self, + *, + colors=None, + facet_col='auto', + facet_row='auto', + animation_frame='auto', + show: bool | None = None, + **kwargs, + ) -> PlotResult: + """Plot investment sizes comparison. See StatisticsPlotAccessor.sizes.""" + data_kw, plotly_kw = self._split_kwargs('sizes', kwargs) + ds, title = self._combine_data('sizes', **data_kw) + if not ds.data_vars: + return self._finalize(ds, None, show) + fig = ds.fxplot.bar( + x='variable', + color='variable', + colors=colors, + title=title, + ylabel='Size', + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + **plotly_kw, + ) + return self._finalize(ds, fig, show) + + def effects( + self, + aspect='total', + *, + colors=None, + facet_col='auto', + facet_row='auto', + animation_frame='auto', + show: bool | None = None, + **kwargs, + ) -> PlotResult: + """Plot effects comparison. See StatisticsPlotAccessor.effects.""" + data_kw, plotly_kw = self._split_kwargs('effects', kwargs) + ds, title = self._combine_data('effects', aspect, **data_kw) + if not ds.data_vars: + return self._finalize(ds, None, show) + + by = data_kw.get('by') + # After to_dataset(dim='effect'), effects become variables -> 'variable' column + x_col = by if by else 'variable' + color_col = 'variable' if len(ds.data_vars) > 1 else x_col + + fig = ds.fxplot.bar( + x=x_col, + color=color_col, + colors=colors, + title=title, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, + **plotly_kw, + ) + fig.update_layout(bargap=0, bargroupgap=0) + fig.update_traces(marker_line_width=0) + return self._finalize(ds, fig, show) + + def heatmap( + self, variables, *, colors=None, facet_col='auto', animation_frame='auto', show: bool | None = None, **kwargs + ) -> PlotResult: + """Plot heatmap comparison. See StatisticsPlotAccessor.heatmap.""" + data_kw, plotly_kw = self._split_kwargs('heatmap', kwargs) + ds, _ = self._combine_data('heatmap', variables, **data_kw) + if not ds.data_vars: + return self._finalize(ds, None, show) + da = ds[next(iter(ds.data_vars))] + fig = da.fxplot.heatmap(colors=colors, facet_col=facet_col, animation_frame=animation_frame, **plotly_kw) + return self._finalize(ds, fig, show) diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index e3581e4e3..536c6beaf 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -25,7 +25,6 @@ import numpy as np import pandas as pd -import plotly.express as px import plotly.graph_objects as go import xarray as xr @@ -1867,113 +1866,67 @@ def effects( self._stats._require_solution() # Get the appropriate effects dataset based on aspect - if aspect == 'total': - effects_ds = self._stats.total_effects - elif aspect == 'temporal': - effects_ds = self._stats.temporal_effects - elif aspect == 'periodic': - effects_ds = self._stats.periodic_effects - else: + effects_ds = { + 'total': self._stats.total_effects, + 'temporal': self._stats.temporal_effects, + 'periodic': self._stats.periodic_effects, + }.get(aspect) + if effects_ds is None: raise ValueError(f"Aspect '{aspect}' not valid. Choose from 'total', 'temporal', 'periodic'.") - # Get available effects (data variables in the dataset) - available_effects = list(effects_ds.data_vars) - - # Filter to specific effect if requested + # Filter to specific effect(s) and apply selection if effect is not None: - if effect not in available_effects: - raise ValueError(f"Effect '{effect}' not found. Available: {available_effects}") - effects_to_plot = [effect] + if effect not in effects_ds: + raise ValueError(f"Effect '{effect}' not found. Available: {list(effects_ds.data_vars)}") + ds = effects_ds[[effect]] else: - effects_to_plot = available_effects - - # Build a combined DataArray with effect dimension - effect_arrays = [] - for eff in effects_to_plot: - da = effects_ds[eff] - if by == 'contributor': - # Keep individual contributors (flows) - no groupby - effect_arrays.append(da.expand_dims(effect=[eff])) - else: - # Group by component (sum over contributor within each component) - da_grouped = da.groupby('component').sum() - effect_arrays.append(da_grouped.expand_dims(effect=[eff])) + ds = effects_ds - combined = xr.concat(effect_arrays, dim='effect') + # Group by component (default) unless by='contributor' + if by != 'contributor' and 'contributor' in ds.dims: + ds = ds.groupby('component').sum() - # Apply selection - combined = _apply_selection(combined.to_dataset(name='value'), select)['value'] + ds = _apply_selection(ds, select) - # Group by the specified dimension + # Sum over dimensions based on 'by' parameter if by is None: - # Aggregate totals per effect - sum over all dimensions except effect - if 'time' in combined.dims: - combined = combined.sum(dim='time') - if 'component' in combined.dims: - combined = combined.sum(dim='component') - if 'contributor' in combined.dims: - combined = combined.sum(dim='contributor') - x_col = 'effect' - color_col = 'effect' + for dim in ['time', 'component', 'contributor']: + if dim in ds.dims: + ds = ds.sum(dim=dim) + x_col, color_col = 'variable', 'variable' elif by == 'component': - # Sum over time if present - if 'time' in combined.dims: - combined = combined.sum(dim='time') + if 'time' in ds.dims: + ds = ds.sum(dim='time') x_col = 'component' - color_col = 'effect' if len(effects_to_plot) > 1 else 'component' + color_col = 'variable' if len(ds.data_vars) > 1 else 'component' elif by == 'contributor': - # Sum over time if present - if 'time' in combined.dims: - combined = combined.sum(dim='time') + if 'time' in ds.dims: + ds = ds.sum(dim='time') x_col = 'contributor' - color_col = 'effect' if len(effects_to_plot) > 1 else 'contributor' + color_col = 'variable' if len(ds.data_vars) > 1 else 'contributor' elif by == 'time': - if 'time' not in combined.dims: + if 'time' not in ds.dims: raise ValueError(f"Cannot plot by 'time' for aspect '{aspect}' - no time dimension.") - # Sum over components or contributors - if 'component' in combined.dims: - combined = combined.sum(dim='component') - if 'contributor' in combined.dims: - combined = combined.sum(dim='contributor') + for dim in ['component', 'contributor']: + if dim in ds.dims: + ds = ds.sum(dim=dim) x_col = 'time' - color_col = 'effect' if len(effects_to_plot) > 1 else None + color_col = 'variable' if len(ds.data_vars) > 1 else None else: raise ValueError(f"'by' must be one of 'component', 'contributor', 'time', or None, got {by!r}") - # Convert to DataFrame for plotly express - df = combined.to_dataframe(name='value').reset_index() - - # Resolve facet/animation: 'auto' means None for DataFrames (no dimension priority) - resolved_facet_col = None if facet_col == 'auto' else facet_col - resolved_facet_row = None if facet_row == 'auto' else facet_row - resolved_animation = None if animation_frame == 'auto' else animation_frame - - # Build color map - if color_col and color_col in df.columns: - color_items = df[color_col].unique().tolist() - color_map = process_colors(colors, color_items) - else: - color_map = None + # Build title + effect_label = effect or 'Effects' + title = f'{effect_label} ({aspect})' if by is None else f'{effect_label} ({aspect}) by {by}' - # Build title with unit if single effect - effect_label = effect if effect else 'Effects' - if effect and effect in effects_ds: - unit_label = effects_ds[effect].attrs.get('unit', '') - title = f'{effect_label} [{unit_label}]' if unit_label else effect_label - else: - title = effect_label - title = f'{title} ({aspect})' if by is None else f'{title} ({aspect}) by {by}' - - fig = px.bar( - df, + fig = ds.fxplot.bar( x=x_col, - y='value', color=color_col, - color_discrete_map=color_map, - facet_col=resolved_facet_col, - facet_row=resolved_facet_row, - animation_frame=resolved_animation, + colors=colors, title=title, + facet_col=facet_col, + facet_row=facet_row, + animation_frame=animation_frame, **plotly_kwargs, ) fig.update_layout(bargap=0, bargroupgap=0) @@ -1984,7 +1937,7 @@ def effects( if show: fig.show() - return PlotResult(data=combined.to_dataset(name=aspect), figure=fig) + return PlotResult(data=ds, figure=fig) def charge_states( self,