From a85ce0fe89084c482cd949c8cb26cf17bb8c5a46 Mon Sep 17 00:00:00 2001
From: FBumann <117816358+FBumann@users.noreply.github.com>
Date: Tue, 9 Dec 2025 08:36:12 +0100
Subject: [PATCH 1/6] Add plotting methods to Piecewise Interfaces
---
flixopt/interface.py | 164 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 164 insertions(+)
diff --git a/flixopt/interface.py b/flixopt/interface.py
index 7995d5e78..1a396c459 100644
--- a/flixopt/interface.py
+++ b/flixopt/interface.py
@@ -10,6 +10,8 @@
import numpy as np
import pandas as pd
+import plotly.express as px
+import plotly.graph_objects as go
import xarray as xr
from .config import CONFIG
@@ -468,6 +470,93 @@ def transform_data(self, name_prefix: str = '') -> None:
for name, piecewise in self.piecewises.items():
piecewise.transform_data(f'{name_prefix}|{name}')
+ def plot(
+ self,
+ x_flow: str | None = None,
+ title: str = '',
+ ) -> go.Figure:
+ """Plot multi-flow piecewise conversion as X-Y scatter.
+
+ Visualizes the piecewise linear relationships between flows. One flow
+ is plotted on the X-axis, all others on the Y-axis. Each piece is shown
+ as a line segment. For data with periods/scenarios, uses faceting.
+
+ Note:
+ Requires FlowSystem to be connected and transformed (call
+ flow_system.connect_and_transform() first).
+
+ Args:
+ x_flow: Flow label to use for X-axis. Defaults to first flow in dict.
+ title: Plot title.
+
+ Returns:
+ Plotly Figure with X-Y scatter showing piecewise segments.
+
+ Examples:
+ >>> flow_system.connect_and_transform()
+ >>> chp.piecewise_conversion.plot(x_flow='Gas', title='CHP Curves')
+ """
+ if self._flow_system is None:
+ raise RuntimeError('Component must be part of a FlowSystem to plot.')
+ if not self._flow_system.connected_and_transformed:
+ logger.debug('Connecting flow_system for plotting PiecewiseConversion')
+ self.flow_system.connect_and_transform()
+
+ flow_labels = list(self.piecewises.keys())
+
+ # Use first flow as X-axis by default, or the specified one
+ x_label = x_flow if x_flow is not None else flow_labels[0]
+ if x_label not in flow_labels:
+ raise ValueError(f"x_flow '{x_label}' not found. Available: {flow_labels}")
+
+ y_flows = [label for label in flow_labels if label != x_label]
+ if not y_flows:
+ raise ValueError('Need at least two flows to plot')
+
+ x_piecewise = self.piecewises[x_label]
+
+ # Build xarray Dataset with all piece data, then convert to DataFrame for plotting
+ datasets = []
+ for y_label in y_flows:
+ y_piecewise = self.piecewises[y_label]
+ for i, (x_piece, y_piece) in enumerate(zip(x_piecewise, y_piecewise, strict=False)):
+ # Create Dataset with start and end points as a 'point' dimension
+ ds = xr.Dataset(
+ {
+ x_label: xr.concat([x_piece.start, x_piece.end], dim='point'),
+ 'output': xr.concat([y_piece.start, y_piece.end], dim='point'),
+ }
+ )
+ ds = ds.assign_coords(point=['start', 'end'])
+ ds['variable'] = y_label
+ ds['piece'] = i
+ datasets.append(ds)
+
+ combined = xr.concat(datasets, dim='trace')
+ combined['trace'] = range(len(datasets))
+
+ # Convert to DataFrame for plotting
+ df = combined.to_dataframe().reset_index()
+
+ # Determine faceting based on available dimensions
+ facet_col = 'scenario' if 'scenario' in df.columns and df['scenario'].nunique() > 1 else None
+ facet_row = 'period' if 'period' in df.columns and df['period'].nunique() > 1 else None
+
+ fig = px.line(
+ df,
+ x=x_label,
+ y='output',
+ color='variable',
+ line_group='trace',
+ facet_col=facet_col,
+ facet_row=facet_row,
+ title=title,
+ markers=True,
+ )
+
+ fig.update_layout(yaxis_title='Output' if len(y_flows) > 1 else y_flows[0])
+ return fig
+
@register_class_for_io
class PiecewiseEffects(Interface):
@@ -688,6 +777,81 @@ def transform_data(self, name_prefix: str = '') -> None:
for effect, piecewise in self.piecewise_shares.items():
piecewise.transform_data(f'{name_prefix}|PiecewiseEffects|{effect}')
+ def plot(self, title: str = '') -> go.Figure:
+ """Plot origin vs effect shares as X-Y scatter.
+
+ Visualizes the piecewise linear relationships between the origin variable
+ and its effect shares. Origin on X-axis, effect shares on Y-axis.
+ For data with periods/scenarios, uses faceting.
+
+ Note:
+ Requires FlowSystem to be connected and transformed (call
+ flow_system.connect_and_transform() first).
+
+ Args:
+ title: Plot title.
+
+ Returns:
+ Plotly Figure with X-Y scatter showing piecewise segments.
+
+ Examples:
+ >>> flow_system.connect_and_transform()
+ >>> invest_params.piecewise_effects_of_investment.plot(title='Investment Effects')
+ """
+ if self._flow_system is None:
+ raise RuntimeError('Component must be part of a FlowSystem to plot.')
+ if not self._flow_system.connected_and_transformed:
+ logger.debug('Connecting flow_system for plotting PiecewiseEffects')
+ self.flow_system.connect_and_transform()
+
+ effect_labels = list(self.piecewise_shares.keys())
+ if not effect_labels:
+ raise ValueError('Need at least one effect share to plot')
+
+ # Build xarray Dataset with all piece data, then convert to DataFrame for plotting
+ datasets = []
+ for effect_label in effect_labels:
+ y_piecewise = self.piecewise_shares[effect_label]
+ for i, (x_piece, y_piece) in enumerate(zip(self.piecewise_origin, y_piecewise, strict=False)):
+ ds = xr.Dataset(
+ {
+ 'origin': xr.concat([x_piece.start, x_piece.end], dim='point'),
+ 'share': xr.concat([y_piece.start, y_piece.end], dim='point'),
+ }
+ )
+ ds = ds.assign_coords(point=['start', 'end'])
+ ds['effect'] = effect_label
+ ds['piece'] = i
+ datasets.append(ds)
+
+ combined = xr.concat(datasets, dim='trace')
+ combined['trace'] = range(len(datasets))
+
+ # Convert to DataFrame for plotting
+ df = combined.to_dataframe().reset_index()
+
+ # Determine faceting based on available dimensions
+ facet_col = 'scenario' if 'scenario' in df.columns and df['scenario'].nunique() > 1 else None
+ facet_row = 'period' if 'period' in df.columns and df['period'].nunique() > 1 else None
+
+ fig = px.line(
+ df,
+ x='origin',
+ y='share',
+ color='effect',
+ line_group='trace',
+ facet_col=facet_col,
+ facet_row=facet_row,
+ title=title,
+ markers=True,
+ )
+
+ fig.update_layout(
+ xaxis_title='Origin',
+ yaxis_title='Effect Share' if len(effect_labels) > 1 else effect_labels[0],
+ )
+ return fig
+
@register_class_for_io
class InvestParameters(Interface):
From 854cc97e875b2331ba61eaa509b8aa4c905a8bee Mon Sep 17 00:00:00 2001
From: FBumann <117816358+FBumann@users.noreply.github.com>
Date: Tue, 9 Dec 2025 09:11:57 +0100
Subject: [PATCH 2/6] Add plotting methods to Piecewise Interfaces and
notebooks
---
docs/examples/02-complex-example.ipynb | 12 ++++++++
.../06-piecewise-efficiency.ipynb | 28 ++++++++++++++-----
2 files changed, 33 insertions(+), 7 deletions(-)
diff --git a/docs/examples/02-complex-example.ipynb b/docs/examples/02-complex-example.ipynb
index 32ae57b73..e64569cd1 100644
--- a/docs/examples/02-complex-example.ipynb
+++ b/docs/examples/02-complex-example.ipynb
@@ -392,6 +392,18 @@
"print(flow_system)"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Visualize the piecewise conversion curves (before optimization)\n",
+ "# Available as soon as the component is added to the FlowSystem\n",
+ "if use_chp_with_piecewise_conversion:\n",
+ " bhkw_2.piecewise_conversion.plot(x_flow='Q_fu', title='CHP: Fuel Input vs Outputs')"
+ ]
+ },
{
"cell_type": "markdown",
"metadata": {},
diff --git a/docs/examples_new/06-piecewise-efficiency.ipynb b/docs/examples_new/06-piecewise-efficiency.ipynb
index 29d9db143..34e8ade2e 100644
--- a/docs/examples_new/06-piecewise-efficiency.ipynb
+++ b/docs/examples_new/06-piecewise-efficiency.ipynb
@@ -352,7 +352,11 @@
"metadata": {},
"outputs": [],
"source": [
- "fs_piecewise.statistics.plot.balance('Electricity')"
+ "# Visualize the piecewise conversion curve (before optimization)\n",
+ "# The plot is available as soon as the component is added to a FlowSystem\n",
+ "fs_piecewise.components['GasEngine'].piecewise_conversion.plot(\n",
+ " x_flow='Fuel', title='Gas Engine: Fuel Input vs Electricity Output'\n",
+ ")"
]
},
{
@@ -362,7 +366,7 @@
"metadata": {},
"outputs": [],
"source": [
- "fs_piecewise.statistics.plot.balance('Gas')"
+ "fs_piecewise.statistics.plot.balance('Electricity')"
]
},
{
@@ -371,6 +375,16 @@
"id": "20",
"metadata": {},
"outputs": [],
+ "source": [
+ "fs_piecewise.statistics.plot.balance('Gas')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "21",
+ "metadata": {},
+ "outputs": [],
"source": [
"# Analyze actual efficiency\n",
"fuel_used = fs_piecewise.statistics.flow_rates['GasEngine(Fuel)'].values\n",
@@ -392,7 +406,7 @@
},
{
"cell_type": "markdown",
- "id": "21",
+ "id": "22",
"metadata": {},
"source": [
"## Approach 3: Piecewise Investment Costs\n",
@@ -403,7 +417,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "22",
+ "id": "23",
"metadata": {},
"outputs": [],
"source": [
@@ -445,7 +459,7 @@
},
{
"cell_type": "markdown",
- "id": "23",
+ "id": "24",
"metadata": {},
"source": [
"### Energy Flow Sankey (Heat Pump System)\n",
@@ -456,7 +470,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "24",
+ "id": "25",
"metadata": {},
"outputs": [],
"source": [
@@ -465,7 +479,7 @@
},
{
"cell_type": "markdown",
- "id": "25",
+ "id": "26",
"metadata": {},
"source": [
"markdown## Key Concepts\n",
From 93ec4b54d3bb3e9e749c75ad16efaf36e78a9382 Mon Sep 17 00:00:00 2001
From: FBumann <117816358+FBumann@users.noreply.github.com>
Date: Tue, 9 Dec 2025 10:25:31 +0100
Subject: [PATCH 3/6] Update piecewise notebook
---
.../06-piecewise-efficiency.ipynb | 392 ++++++++++++++----
1 file changed, 319 insertions(+), 73 deletions(-)
diff --git a/docs/examples_new/06-piecewise-efficiency.ipynb b/docs/examples_new/06-piecewise-efficiency.ipynb
index 34e8ade2e..f51474792 100644
--- a/docs/examples_new/06-piecewise-efficiency.ipynb
+++ b/docs/examples_new/06-piecewise-efficiency.ipynb
@@ -247,9 +247,13 @@
"id": "15",
"metadata": {},
"source": [
- "## Approach 2: Piecewise Linear Efficiency\n",
+ "## Approach 2: Simple vs Piecewise Efficiency - Model Refinement\n",
"\n",
- "For more complex efficiency curves (e.g., part-load efficiency), use `Piecewise`:"
+ "Real equipment often has non-linear efficiency curves. Let's compare:\n",
+ "1. **Simple model**: Constant average efficiency\n",
+ "2. **Refined model**: Piecewise linear efficiency that varies with load\n",
+ "\n",
+ "This demonstrates how to progressively refine a model for more accurate results."
]
},
{
@@ -259,19 +263,16 @@
"metadata": {},
"outputs": [],
"source": [
- "# Define piecewise efficiency for a gas engine\n",
- "# Efficiency varies with load: lower at part load, optimal at 75% load\n",
- "\n",
- "# Engine: 100 kW electrical capacity\n",
- "# Part-load efficiency curve:\n",
+ "# Gas engine efficiency varies with load:\n",
"# - 25% load: 32% efficiency\n",
"# - 50% load: 38% efficiency\n",
"# - 75% load: 42% efficiency (optimal)\n",
"# - 100% load: 40% efficiency\n",
"\n",
- "# In flixopt, PiecewiseConversion defines coordinated flow relationships\n",
- "# For engine: fuel_input → electrical_output\n",
+ "# For the SIMPLE model, we'll use average efficiency (~38%)\n",
+ "SIMPLE_EFFICIENCY = 0.38\n",
"\n",
+ "# For the PIECEWISE model, we define the actual curve:\n",
"# At various operating points (fuel in kW → elec out kW):\n",
"# 25% load: 78 kW fuel → 25 kW elec (η=32%)\n",
"# 50% load: 132 kW fuel → 50 kW elec (η=38%)\n",
@@ -282,9 +283,9 @@
" {\n",
" 'Fuel': fx.Piecewise(\n",
" [\n",
- " fx.Piece(start=78, end=132), # Segment 1\n",
- " fx.Piece(start=132, end=179), # Segment 2\n",
- " fx.Piece(start=179, end=250), # Segment 3\n",
+ " fx.Piece(start=78, end=132), # Segment 1: part load\n",
+ " fx.Piece(start=132, end=179), # Segment 2: mid load\n",
+ " fx.Piece(start=179, end=250), # Segment 3: high load\n",
" ]\n",
" ),\n",
" 'Elec': fx.Piecewise(\n",
@@ -306,7 +307,8 @@
" efficiency = elec_avg / fuel_avg * 100\n",
" print(\n",
" f' Segment {i + 1}: {fuel_piece.start}-{fuel_piece.end} kW fuel → {elec_piece.start}-{elec_piece.end} kW elec (~{efficiency:.1f}% eff)'\n",
- " )"
+ " )\n",
+ "print(f'\\nSimple model uses constant efficiency: {SIMPLE_EFFICIENCY * 100:.0f}%')"
]
},
{
@@ -316,7 +318,7 @@
"metadata": {},
"outputs": [],
"source": [
- "# Build system with piecewise efficiency\n",
+ "# Define demand profile\n",
"timesteps_simple = pd.date_range('2024-01-22', periods=48, freq='h') # 2 days\n",
"elec_demand_simple = np.concatenate(\n",
" [\n",
@@ -325,24 +327,29 @@
" ]\n",
")\n",
"\n",
- "fs_piecewise = fx.FlowSystem(timesteps_simple)\n",
+ "# ============================================\n",
+ "# MODEL 1: Simple constant efficiency\n",
+ "# ============================================\n",
+ "fs_simple = fx.FlowSystem(timesteps_simple)\n",
"\n",
- "fs_piecewise.add_elements(\n",
+ "fs_simple.add_elements(\n",
" fx.Bus('Gas', carrier='gas'),\n",
" fx.Bus('Electricity', carrier='electricity'),\n",
" fx.Effect('costs', '€', 'Costs', is_standard=True, is_objective=True),\n",
" fx.Source('GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=0.05)]),\n",
- " # Engine with piecewise efficiency\n",
+ " # Engine with SIMPLE constant efficiency\n",
" fx.LinearConverter(\n",
" 'GasEngine',\n",
- " inputs=[fx.Flow('Fuel', bus='Gas')],\n",
- " outputs=[fx.Flow('Elec', bus='Electricity')],\n",
- " piecewise_conversion=piecewise_efficiency,\n",
+ " inputs=[fx.Flow('Fuel', bus='Gas', size=300)],\n",
+ " outputs=[fx.Flow('Elec', bus='Electricity', size=100)],\n",
+ " conversion_factors=[{'Fuel': 1, 'Elec': SIMPLE_EFFICIENCY}], # constant 38% efficiency\n",
" ),\n",
" fx.Sink('Load', inputs=[fx.Flow('Elec', bus='Electricity', size=1, fixed_relative_profile=elec_demand_simple)]),\n",
")\n",
"\n",
- "fs_piecewise.optimize(fx.solvers.HighsSolver());"
+ "fs_simple.optimize(fx.solvers.HighsSolver())\n",
+ "cost_simple = fs_simple.solution['costs'].item()\n",
+ "print(f'Simple model cost: {cost_simple:.2f} €')"
]
},
{
@@ -352,11 +359,29 @@
"metadata": {},
"outputs": [],
"source": [
- "# Visualize the piecewise conversion curve (before optimization)\n",
- "# The plot is available as soon as the component is added to a FlowSystem\n",
- "fs_piecewise.components['GasEngine'].piecewise_conversion.plot(\n",
- " x_flow='Fuel', title='Gas Engine: Fuel Input vs Electricity Output'\n",
- ")"
+ "# ============================================\n",
+ "# MODEL 2: Piecewise efficiency (refined model)\n",
+ "# ============================================\n",
+ "fs_piecewise = fx.FlowSystem(timesteps_simple)\n",
+ "\n",
+ "fs_piecewise.add_elements(\n",
+ " fx.Bus('Gas', carrier='gas'),\n",
+ " fx.Bus('Electricity', carrier='electricity'),\n",
+ " fx.Effect('costs', '€', 'Costs', is_standard=True, is_objective=True),\n",
+ " fx.Source('GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=0.05)]),\n",
+ " # Engine with PIECEWISE efficiency (load-dependent)\n",
+ " fx.LinearConverter(\n",
+ " 'GasEngine',\n",
+ " inputs=[fx.Flow('Fuel', bus='Gas')],\n",
+ " outputs=[fx.Flow('Elec', bus='Electricity')],\n",
+ " piecewise_conversion=piecewise_efficiency,\n",
+ " ),\n",
+ " fx.Sink('Load', inputs=[fx.Flow('Elec', bus='Electricity', size=1, fixed_relative_profile=elec_demand_simple)]),\n",
+ ")\n",
+ "\n",
+ "fs_piecewise.optimize(fx.solvers.HighsSolver())\n",
+ "cost_piecewise = fs_piecewise.solution['costs'].item()\n",
+ "print(f'Piecewise model cost: {cost_piecewise:.2f} €')"
]
},
{
@@ -366,7 +391,10 @@
"metadata": {},
"outputs": [],
"source": [
- "fs_piecewise.statistics.plot.balance('Electricity')"
+ "# Visualize the piecewise conversion curve\n",
+ "fs_piecewise.components['GasEngine'].piecewise_conversion.plot(\n",
+ " x_flow='Fuel', title='Gas Engine: Fuel Input vs Electricity Output'\n",
+ ")"
]
},
{
@@ -376,42 +404,45 @@
"metadata": {},
"outputs": [],
"source": [
- "fs_piecewise.statistics.plot.balance('Gas')"
+ "# ============================================\n",
+ "# Compare Results: Simple vs Piecewise\n",
+ "# ============================================\n",
+ "print('=== Efficiency Model Comparison ===')\n",
+ "print(f'Simple model (constant {SIMPLE_EFFICIENCY * 100:.0f}% eff): {cost_simple:.2f} €')\n",
+ "print(f'Piecewise model (load-dependent eff): {cost_piecewise:.2f} €')\n",
+ "print(f'Difference: {cost_piecewise - cost_simple:.2f} € ({(cost_piecewise - cost_simple) / cost_simple * 100:+.1f}%)')\n",
+ "\n",
+ "# Compare fuel consumption\n",
+ "fuel_simple = fs_simple.statistics.flow_rates['GasEngine(Fuel)'].sum().item()\n",
+ "fuel_piecewise = fs_piecewise.statistics.flow_rates['GasEngine(Fuel)'].sum().item()\n",
+ "print('\\nTotal fuel consumption:')\n",
+ "print(f' Simple: {fuel_simple:.1f} kWh')\n",
+ "print(f' Piecewise: {fuel_piecewise:.1f} kWh')\n",
+ "print(\n",
+ " f' Difference: {fuel_piecewise - fuel_simple:.1f} kWh ({(fuel_piecewise - fuel_simple) / fuel_simple * 100:+.1f}%)'\n",
+ ")"
]
},
{
- "cell_type": "code",
- "execution_count": null,
+ "cell_type": "markdown",
"id": "21",
"metadata": {},
- "outputs": [],
"source": [
- "# Analyze actual efficiency\n",
- "fuel_used = fs_piecewise.statistics.flow_rates['GasEngine(Fuel)'].values\n",
- "elec_produced = fs_piecewise.statistics.flow_rates['GasEngine(Elec)'].values\n",
+ "## Approach 3: Simple vs Piecewise Investment Costs\n",
"\n",
- "actual_efficiency = np.where(fuel_used > 0, elec_produced / fuel_used * 100, 0)\n",
- "\n",
- "# Plot using plotly\n",
- "fig = px.line(\n",
- " x=timesteps_simple,\n",
- " y=actual_efficiency,\n",
- " title='Gas Engine Operating Efficiency',\n",
- " labels={'x': 'Time', 'y': 'Efficiency [%]'},\n",
- ")\n",
- "fig.update_traces(mode='lines+markers', marker=dict(size=4))\n",
- "fig.update_yaxes(range=[30, 45])\n",
- "fig"
+ "Investment costs often have economies of scale - larger systems cost less per unit. Let's compare:\n",
+ "1. **Simple model**: Constant cost per kWh (average: 800 €/kWh)\n",
+ "2. **Refined model**: Piecewise costs that decrease with size"
]
},
{
- "cell_type": "markdown",
+ "cell_type": "code",
+ "execution_count": null,
"id": "22",
"metadata": {},
+ "outputs": [],
"source": [
- "## Approach 3: Piecewise Investment Costs\n",
- "\n",
- "Piecewise functions can also model economies of scale in investment:"
+ "fs_piecewise.statistics.plot.balance('Gas')"
]
},
{
@@ -422,45 +453,252 @@
"outputs": [],
"source": [
"# Investment cost curve with economies of scale:\n",
- "# - Small system (0-100 kW): 1000 €/kW\n",
- "# - Medium system (100-300 kW): 800 €/kW\n",
- "# - Large system (300-500 kW): 600 €/kW\n",
+ "# These represent the daily cost allocation of storage investment\n",
+ "#\n",
+ "# For SIMPLE model: constant marginal cost\n",
+ "SIMPLE_INVEST_COST = 0.20 # €/kWh (daily amortized)\n",
"\n",
- "# Cumulative costs at breakpoints:\n",
- "# 100 kW: 100,000 €\n",
- "# 300 kW: 100,000 + 200*800 = 260,000 €\n",
- "# 500 kW: 260,000 + 200*600 = 380,000 €\n",
+ "# For PIECEWISE model: decreasing marginal cost (economies of scale)\n",
+ "# - Small storage (0-100 kWh): 0.20 €/kWh - same as simple\n",
+ "# - Medium storage (100-300 kWh): 0.15 €/kWh - better value\n",
+ "# - Large storage (300-600 kWh): 0.10 €/kWh - bulk discount\n",
"\n",
- "# PiecewiseEffects maps a size variable to cost effects\n",
"piecewise_invest_costs = fx.PiecewiseEffects(\n",
" piecewise_origin=fx.Piecewise(\n",
" [\n",
- " fx.Piece(start=0, end=100), # Size range 0-100 kW\n",
- " fx.Piece(start=100, end=300), # Size range 100-300 kW\n",
- " fx.Piece(start=300, end=500), # Size range 300-500 kW\n",
+ " fx.Piece(start=0, end=100),\n",
+ " fx.Piece(start=100, end=300),\n",
+ " fx.Piece(start=300, end=600),\n",
" ]\n",
" ),\n",
" piecewise_shares={\n",
" 'costs': fx.Piecewise(\n",
" [\n",
- " fx.Piece(start=0, end=100_000), # 0-100 kW → 0-100k €\n",
- " fx.Piece(start=100_000, end=260_000), # 100-300 kW → 100k-260k €\n",
- " fx.Piece(start=260_000, end=380_000), # 300-500 kW → 260k-380k €\n",
+ " fx.Piece(start=0, end=20), # 0.20 €/kWh\n",
+ " fx.Piece(start=20, end=50), # 0.15 €/kWh\n",
+ " fx.Piece(start=50, end=80), # 0.10 €/kWh\n",
" ]\n",
" )\n",
" },\n",
")\n",
"\n",
- "print('Investment cost curve:')\n",
- "print(' 0-100 kW: 1000 €/kW')\n",
- "print(' 100-300 kW: 800 €/kW')\n",
- "print(' 300-500 kW: 600 €/kW')"
+ "print('Investment cost structure (daily amortized):')\n",
+ "print(f' Simple model: constant {SIMPLE_INVEST_COST:.2f} €/kWh')\n",
+ "print(' Piecewise model (economies of scale):')\n",
+ "print(' 0-100 kWh: 0.20 €/kWh')\n",
+ "print(' 100-300 kWh: 0.15 €/kWh')\n",
+ "print(' 300-600 kWh: 0.10 €/kWh')"
]
},
{
- "cell_type": "markdown",
+ "cell_type": "code",
+ "execution_count": null,
"id": "24",
"metadata": {},
+ "outputs": [],
+ "source": [
+ "# Create base system for storage investment comparison\n",
+ "# Scenario: Price arbitrage - charge when cheap, discharge when expensive\n",
+ "# 3-day horizon to make storage investment meaningful\n",
+ "\n",
+ "timesteps_invest = pd.date_range('2024-01-22', periods=72, freq='h')\n",
+ "\n",
+ "# Steady demand pattern repeated over 3 days\n",
+ "heat_demand_invest = np.tile(\n",
+ " np.concatenate(\n",
+ " [\n",
+ " np.full(8, 100), # Night\n",
+ " np.full(4, 120), # Morning\n",
+ " np.full(4, 110), # Midday\n",
+ " np.full(4, 130), # Evening peak\n",
+ " np.full(4, 105), # Late evening\n",
+ " ]\n",
+ " ),\n",
+ " 3,\n",
+ ")\n",
+ "\n",
+ "# Strong price signal for arbitrage\n",
+ "energy_price_invest = np.tile(\n",
+ " np.concatenate(\n",
+ " [\n",
+ " np.full(8, 0.08), # Night: cheap - perfect for charging\n",
+ " np.full(4, 0.20), # Morning: expensive\n",
+ " np.full(4, 0.12), # Midday: medium\n",
+ " np.full(4, 0.25), # Evening: very expensive - best time to discharge\n",
+ " np.full(4, 0.10), # Late evening\n",
+ " ]\n",
+ " ),\n",
+ " 3,\n",
+ ")\n",
+ "\n",
+ "fs_base = fx.FlowSystem(timesteps_invest)\n",
+ "\n",
+ "fs_base.add_elements(\n",
+ " fx.Bus('Heat', carrier='heat'),\n",
+ " fx.Effect('costs', '€', 'Costs', is_standard=True, is_objective=True),\n",
+ " fx.Source('HeatSource', outputs=[fx.Flow('Heat', bus='Heat', size=300, effects_per_flow_hour=energy_price_invest)]),\n",
+ " fx.Sink('HeatSink', inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=heat_demand_invest)]),\n",
+ ")\n",
+ "\n",
+ "print('Time horizon: 72 hours (3 days)')\n",
+ "print(f'Demand range: {heat_demand_invest.min():.0f} - {heat_demand_invest.max():.0f} kW')\n",
+ "print(f'Price range: {energy_price_invest.min():.2f} - {energy_price_invest.max():.2f} €/kWh')\n",
+ "print(f'Price spread: {energy_price_invest.max() - energy_price_invest.min():.2f} €/kWh')\n",
+ "print('=> Storage value comes from charging cheap, discharging expensive')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "25",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# ============================================\n",
+ "# MODEL 1: Simple linear investment costs\n",
+ "# ============================================\n",
+ "fs_simple_invest = fs_base.copy()\n",
+ "\n",
+ "fs_simple_invest.add_elements(\n",
+ " fx.Storage(\n",
+ " 'ThermalStorage',\n",
+ " charging=fx.Flow('charge', bus='Heat', size=200),\n",
+ " discharging=fx.Flow('discharge', bus='Heat', size=200),\n",
+ " capacity_in_flow_hours=fx.InvestParameters(\n",
+ " effects_of_investment_per_size=SIMPLE_INVEST_COST, # constant 0.20 €/kWh\n",
+ " minimum_size=0,\n",
+ " maximum_size=600,\n",
+ " ),\n",
+ " initial_charge_state=0,\n",
+ " eta_charge=0.95,\n",
+ " eta_discharge=0.95,\n",
+ " relative_loss_per_hour=0.005,\n",
+ " ),\n",
+ ")\n",
+ "\n",
+ "fs_simple_invest.optimize(fx.solvers.HighsSolver())\n",
+ "cost_simple_invest = fs_simple_invest.solution['costs'].item()\n",
+ "size_simple = fs_simple_invest.solution['ThermalStorage|size'].item()\n",
+ "print('Simple investment model:')\n",
+ "print(f' Total cost: {cost_simple_invest:,.2f} €')\n",
+ "print(f' Storage size: {size_simple:.1f} kWh')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "26",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# ============================================\n",
+ "# MODEL 2: Piecewise investment costs (economies of scale)\n",
+ "# ============================================\n",
+ "fs_piecewise_invest = fs_base.copy()\n",
+ "\n",
+ "fs_piecewise_invest.add_elements(\n",
+ " fx.Storage(\n",
+ " 'ThermalStorage',\n",
+ " charging=fx.Flow('charge', bus='Heat', size=200),\n",
+ " discharging=fx.Flow('discharge', bus='Heat', size=200),\n",
+ " capacity_in_flow_hours=fx.InvestParameters(\n",
+ " piecewise_effects_of_investment=piecewise_invest_costs, # economies of scale\n",
+ " minimum_size=0,\n",
+ " maximum_size=600,\n",
+ " ),\n",
+ " initial_charge_state=0,\n",
+ " eta_charge=0.95,\n",
+ " eta_discharge=0.95,\n",
+ " relative_loss_per_hour=0.005,\n",
+ " ),\n",
+ ")\n",
+ "\n",
+ "fs_piecewise_invest.optimize(fx.solvers.HighsSolver())\n",
+ "cost_piecewise_invest = fs_piecewise_invest.solution['costs'].item()\n",
+ "size_piecewise = fs_piecewise_invest.solution['ThermalStorage|size'].item()\n",
+ "print('Piecewise investment model:')\n",
+ "print(f' Total cost: {cost_piecewise_invest:,.2f} €')\n",
+ "print(f' Storage size: {size_piecewise:.1f} kWh')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "27",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Visualize the piecewise investment cost curve\n",
+ "piecewise_invest_costs.plot(title='Storage Investment Costs (Economies of Scale)')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "28",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# ============================================\n",
+ "# Compare Results: Simple vs Piecewise Investment\n",
+ "# ============================================\n",
+ "\n",
+ "# Calculate operating cost baseline (without storage) - suppress solver output\n",
+ "import contextlib\n",
+ "import os\n",
+ "\n",
+ "with open(os.devnull, 'w') as devnull:\n",
+ " with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):\n",
+ " fs_no_storage = fs_base.copy()\n",
+ " fs_no_storage.optimize(fx.solvers.HighsSolver())\n",
+ " cost_baseline = fs_no_storage.solution['costs'].item()\n",
+ "\n",
+ "print('=' * 60)\n",
+ "print('INVESTMENT MODEL COMPARISON')\n",
+ "print('=' * 60)\n",
+ "\n",
+ "print(f'\\nBaseline (no storage): {cost_baseline:.2f} €')\n",
+ "\n",
+ "print(f'\\nSimple model (constant {SIMPLE_INVEST_COST:.2f} €/kWh):')\n",
+ "print(f' Storage size: {size_simple:.1f} kWh')\n",
+ "print(f' Total cost: {cost_simple_invest:,.2f} €')\n",
+ "invest_simple = size_simple * SIMPLE_INVEST_COST\n",
+ "print(f' - Investment: {invest_simple:.2f} € ({size_simple:.0f} kWh × {SIMPLE_INVEST_COST:.2f} €/kWh)')\n",
+ "print(f' - Operating: {cost_simple_invest - invest_simple:.2f} €')\n",
+ "print(f' - Savings: {cost_baseline - cost_simple_invest:.2f} € vs no storage')\n",
+ "\n",
+ "print('\\nPiecewise model (economies of scale):')\n",
+ "print(f' Storage size: {size_piecewise:.1f} kWh')\n",
+ "print(f' Total cost: {cost_piecewise_invest:,.2f} €')\n",
+ "\n",
+ "# Calculate piecewise investment cost\n",
+ "if size_piecewise <= 100:\n",
+ " invest_piecewise = size_piecewise * 0.20\n",
+ "elif size_piecewise <= 300:\n",
+ " invest_piecewise = 20 + (size_piecewise - 100) * 0.15\n",
+ "else:\n",
+ " invest_piecewise = 50 + (size_piecewise - 300) * 0.10\n",
+ "\n",
+ "avg_cost = invest_piecewise / size_piecewise if size_piecewise > 0 else 0\n",
+ "print(f' - Investment: {invest_piecewise:.2f} € (avg: {avg_cost:.2f} €/kWh)')\n",
+ "print(f' - Operating: {cost_piecewise_invest - invest_piecewise:.2f} €')\n",
+ "print(f' - Savings: {cost_baseline - cost_piecewise_invest:.2f} € vs no storage')\n",
+ "\n",
+ "print('\\n' + '-' * 60)\n",
+ "print('KEY INSIGHT:')\n",
+ "if size_piecewise > size_simple:\n",
+ " print(f' Economies of scale result in {size_piecewise - size_simple:.0f} kWh MORE storage')\n",
+ " print(f' with {cost_simple_invest - cost_piecewise_invest:.2f} € LOWER total cost.')\n",
+ "else:\n",
+ " print(f' Both models choose similar storage size (~{size_simple:.0f} kWh)')\n",
+ " print(f' but piecewise saves {cost_simple_invest - cost_piecewise_invest:.2f} € through economies of scale.')\n",
+ " print(f' Average cost: {SIMPLE_INVEST_COST:.2f} €/kWh (simple) vs {avg_cost:.2f} €/kWh (piecewise)')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "29",
+ "metadata": {},
"source": [
"### Energy Flow Sankey (Heat Pump System)\n",
"\n",
@@ -470,16 +708,16 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "25",
+ "id": "30",
"metadata": {},
"outputs": [],
"source": [
- "flow_system.statistics.plot.sankey()"
+ "fs_piecewise_invest.statistics.plot.sankey()"
]
},
{
"cell_type": "markdown",
- "id": "26",
+ "id": "31",
"metadata": {},
"source": [
"markdown## Key Concepts\n",
@@ -544,8 +782,16 @@
"name": "python3"
},
"language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
"name": "python",
- "version": "3.11"
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.11.11"
}
},
"nbformat": 4,
From 0c96aff92cecca2a35cd9274ff091f52685f78ea Mon Sep 17 00:00:00 2001
From: FBumann <117816358+FBumann@users.noreply.github.com>
Date: Tue, 9 Dec 2025 11:07:50 +0100
Subject: [PATCH 4/6] Improve notebook
---
.../06-piecewise-efficiency.ipynb | 306 +++++-------------
1 file changed, 75 insertions(+), 231 deletions(-)
diff --git a/docs/examples_new/06-piecewise-efficiency.ipynb b/docs/examples_new/06-piecewise-efficiency.ipynb
index f51474792..c8165e73e 100644
--- a/docs/examples_new/06-piecewise-efficiency.ipynb
+++ b/docs/examples_new/06-piecewise-efficiency.ipynb
@@ -87,9 +87,7 @@
"# Add day-to-day variation\n",
"np.random.seed(789)\n",
"daily_offset = np.repeat(np.random.uniform(-3, 3, 7), 24)\n",
- "outdoor_temp = outdoor_temp + daily_offset\n",
- "\n",
- "print(f'Temperature range: {outdoor_temp.min():.1f}°C to {outdoor_temp.max():.1f}°C')"
+ "outdoor_temp = outdoor_temp + daily_offset"
]
},
{
@@ -99,12 +97,9 @@
"metadata": {},
"outputs": [],
"source": [
- "# Heat demand: inversely related to outdoor temp\n",
- "# Higher demand when colder\n",
- "heat_demand = 200 - 8 * outdoor_temp # Simple linear relationship\n",
- "heat_demand = np.clip(heat_demand, 100, 300)\n",
- "\n",
- "print(f'Heat demand range: {heat_demand.min():.0f} - {heat_demand.max():.0f} kW')"
+ "# Heat demand: inversely related to outdoor temp (higher demand when colder)\n",
+ "heat_demand = 200 - 8 * outdoor_temp\n",
+ "heat_demand = np.clip(heat_demand, 100, 300)"
]
},
{
@@ -146,16 +141,13 @@
"metadata": {},
"outputs": [],
"source": [
- "# COP calculation (simplified)\n",
- "# Real COP ≈ 0.4 * Carnot COP\n",
+ "# COP calculation (simplified): Real COP ≈ 0.45 * Carnot COP\n",
"T_supply = 45 + 273.15 # Supply temperature 45°C in Kelvin\n",
"T_source = outdoor_temp + 273.15 # Outdoor temp in Kelvin\n",
"\n",
"carnot_cop = T_supply / (T_supply - T_source)\n",
- "real_cop = 0.45 * carnot_cop # Real efficiency factor\n",
- "real_cop = np.clip(real_cop, 2.0, 5.0) # Physical limits\n",
- "\n",
- "print(f'COP range: {real_cop.min():.2f} - {real_cop.max():.2f}')"
+ "real_cop = 0.45 * carnot_cop\n",
+ "real_cop = np.clip(real_cop, 2.0, 5.0) # Physical limits"
]
},
{
@@ -198,28 +190,17 @@
" fx.Bus('Electricity', carrier='electricity'),\n",
" fx.Bus('Heat', carrier='heat'),\n",
" fx.Effect('costs', '€', 'Operating Costs', is_standard=True, is_objective=True),\n",
- " # Electricity grid\n",
- " fx.Source(\n",
- " 'Grid',\n",
- " outputs=[fx.Flow('Elec', bus='Electricity', size=500, effects_per_flow_hour=0.30)],\n",
- " ),\n",
- " # Heat pump with time-varying COP\n",
+ " fx.Source('Grid', outputs=[fx.Flow('Elec', bus='Electricity', size=500, effects_per_flow_hour=0.30)]),\n",
" fx.LinearConverter(\n",
" 'HeatPump',\n",
" inputs=[fx.Flow('Elec', bus='Electricity', size=150)],\n",
" outputs=[fx.Flow('Heat', bus='Heat', size=500)],\n",
- " # Time-varying conversion factor: Elec * COP = Heat\n",
- " conversion_factors=[{'Elec': real_cop, 'Heat': 1}],\n",
- " ),\n",
- " # Building heat demand\n",
- " fx.Sink(\n",
- " 'Building',\n",
- " inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=heat_demand)],\n",
+ " conversion_factors=[{'Elec': real_cop, 'Heat': 1}], # Time-varying COP\n",
" ),\n",
+ " fx.Sink('Building', inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=heat_demand)]),\n",
")\n",
"\n",
- "flow_system.optimize(fx.solvers.HighsSolver())\n",
- "print(f'Total cost: {flow_system.solution[\"costs\"].item():.2f} €')"
+ "flow_system.optimize(fx.solvers.HighsSolver())"
]
},
{
@@ -264,51 +245,31 @@
"outputs": [],
"source": [
"# Gas engine efficiency varies with load:\n",
- "# - 25% load: 32% efficiency\n",
- "# - 50% load: 38% efficiency\n",
- "# - 75% load: 42% efficiency (optimal)\n",
- "# - 100% load: 40% efficiency\n",
- "\n",
- "# For the SIMPLE model, we'll use average efficiency (~38%)\n",
- "SIMPLE_EFFICIENCY = 0.38\n",
+ "# - Part load: lower efficiency\n",
+ "# - Mid load: optimal efficiency (~42%)\n",
+ "# - Full load: slightly lower efficiency\n",
"\n",
- "# For the PIECEWISE model, we define the actual curve:\n",
- "# At various operating points (fuel in kW → elec out kW):\n",
- "# 25% load: 78 kW fuel → 25 kW elec (η=32%)\n",
- "# 50% load: 132 kW fuel → 50 kW elec (η=38%)\n",
- "# 75% load: 179 kW fuel → 75 kW elec (η=42%)\n",
- "# 100% load: 250 kW fuel → 100 kW elec (η=40%)\n",
+ "SIMPLE_EFFICIENCY = 0.38 # Average efficiency for simple model\n",
"\n",
+ "# Piecewise model: efficiency varies by operating segment\n",
"piecewise_efficiency = fx.PiecewiseConversion(\n",
" {\n",
" 'Fuel': fx.Piecewise(\n",
" [\n",
- " fx.Piece(start=78, end=132), # Segment 1: part load\n",
- " fx.Piece(start=132, end=179), # Segment 2: mid load\n",
- " fx.Piece(start=179, end=250), # Segment 3: high load\n",
+ " fx.Piece(start=78, end=132), # Part load\n",
+ " fx.Piece(start=132, end=179), # Mid load (optimal)\n",
+ " fx.Piece(start=179, end=250), # High load\n",
" ]\n",
" ),\n",
" 'Elec': fx.Piecewise(\n",
" [\n",
- " fx.Piece(start=25, end=50), # Segment 1\n",
- " fx.Piece(start=50, end=75), # Segment 2\n",
- " fx.Piece(start=75, end=100), # Segment 3\n",
+ " fx.Piece(start=25, end=50),\n",
+ " fx.Piece(start=50, end=75),\n",
+ " fx.Piece(start=75, end=100),\n",
" ]\n",
" ),\n",
" }\n",
- ")\n",
- "\n",
- "print('Piecewise segments (Fuel → Elec):')\n",
- "fuel_pieces = piecewise_efficiency.piecewises['Fuel'].pieces\n",
- "elec_pieces = piecewise_efficiency.piecewises['Elec'].pieces\n",
- "for i, (fuel_piece, elec_piece) in enumerate(zip(fuel_pieces, elec_pieces, strict=False)):\n",
- " fuel_avg = (fuel_piece.start + fuel_piece.end) / 2\n",
- " elec_avg = (elec_piece.start + elec_piece.end) / 2\n",
- " efficiency = elec_avg / fuel_avg * 100\n",
- " print(\n",
- " f' Segment {i + 1}: {fuel_piece.start}-{fuel_piece.end} kW fuel → {elec_piece.start}-{elec_piece.end} kW elec (~{efficiency:.1f}% eff)'\n",
- " )\n",
- "print(f'\\nSimple model uses constant efficiency: {SIMPLE_EFFICIENCY * 100:.0f}%')"
+ ")"
]
},
{
@@ -318,38 +279,26 @@
"metadata": {},
"outputs": [],
"source": [
- "# Define demand profile\n",
- "timesteps_simple = pd.date_range('2024-01-22', periods=48, freq='h') # 2 days\n",
- "elec_demand_simple = np.concatenate(\n",
- " [\n",
- " np.linspace(30, 90, 24), # Day 1: ramp up\n",
- " np.linspace(90, 30, 24), # Day 2: ramp down\n",
- " ]\n",
- ")\n",
+ "# 2-day demand profile\n",
+ "timesteps_simple = pd.date_range('2024-01-22', periods=48, freq='h')\n",
+ "elec_demand_simple = np.concatenate([np.linspace(30, 90, 24), np.linspace(90, 30, 24)])\n",
"\n",
- "# ============================================\n",
"# MODEL 1: Simple constant efficiency\n",
- "# ============================================\n",
"fs_simple = fx.FlowSystem(timesteps_simple)\n",
- "\n",
"fs_simple.add_elements(\n",
" fx.Bus('Gas', carrier='gas'),\n",
" fx.Bus('Electricity', carrier='electricity'),\n",
" fx.Effect('costs', '€', 'Costs', is_standard=True, is_objective=True),\n",
" fx.Source('GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=0.05)]),\n",
- " # Engine with SIMPLE constant efficiency\n",
" fx.LinearConverter(\n",
" 'GasEngine',\n",
" inputs=[fx.Flow('Fuel', bus='Gas', size=300)],\n",
" outputs=[fx.Flow('Elec', bus='Electricity', size=100)],\n",
- " conversion_factors=[{'Fuel': 1, 'Elec': SIMPLE_EFFICIENCY}], # constant 38% efficiency\n",
+ " conversion_factors=[{'Fuel': 1, 'Elec': SIMPLE_EFFICIENCY}],\n",
" ),\n",
" fx.Sink('Load', inputs=[fx.Flow('Elec', bus='Electricity', size=1, fixed_relative_profile=elec_demand_simple)]),\n",
")\n",
- "\n",
- "fs_simple.optimize(fx.solvers.HighsSolver())\n",
- "cost_simple = fs_simple.solution['costs'].item()\n",
- "print(f'Simple model cost: {cost_simple:.2f} €')"
+ "fs_simple.optimize(fx.solvers.HighsSolver())"
]
},
{
@@ -359,17 +308,13 @@
"metadata": {},
"outputs": [],
"source": [
- "# ============================================\n",
- "# MODEL 2: Piecewise efficiency (refined model)\n",
- "# ============================================\n",
+ "# MODEL 2: Piecewise efficiency (load-dependent)\n",
"fs_piecewise = fx.FlowSystem(timesteps_simple)\n",
- "\n",
"fs_piecewise.add_elements(\n",
" fx.Bus('Gas', carrier='gas'),\n",
" fx.Bus('Electricity', carrier='electricity'),\n",
" fx.Effect('costs', '€', 'Costs', is_standard=True, is_objective=True),\n",
" fx.Source('GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=0.05)]),\n",
- " # Engine with PIECEWISE efficiency (load-dependent)\n",
" fx.LinearConverter(\n",
" 'GasEngine',\n",
" inputs=[fx.Flow('Fuel', bus='Gas')],\n",
@@ -378,10 +323,7 @@
" ),\n",
" fx.Sink('Load', inputs=[fx.Flow('Elec', bus='Electricity', size=1, fixed_relative_profile=elec_demand_simple)]),\n",
")\n",
- "\n",
- "fs_piecewise.optimize(fx.solvers.HighsSolver())\n",
- "cost_piecewise = fs_piecewise.solution['costs'].item()\n",
- "print(f'Piecewise model cost: {cost_piecewise:.2f} €')"
+ "fs_piecewise.optimize(fx.solvers.HighsSolver())"
]
},
{
@@ -404,23 +346,9 @@
"metadata": {},
"outputs": [],
"source": [
- "# ============================================\n",
- "# Compare Results: Simple vs Piecewise\n",
- "# ============================================\n",
- "print('=== Efficiency Model Comparison ===')\n",
- "print(f'Simple model (constant {SIMPLE_EFFICIENCY * 100:.0f}% eff): {cost_simple:.2f} €')\n",
- "print(f'Piecewise model (load-dependent eff): {cost_piecewise:.2f} €')\n",
- "print(f'Difference: {cost_piecewise - cost_simple:.2f} € ({(cost_piecewise - cost_simple) / cost_simple * 100:+.1f}%)')\n",
- "\n",
- "# Compare fuel consumption\n",
- "fuel_simple = fs_simple.statistics.flow_rates['GasEngine(Fuel)'].sum().item()\n",
- "fuel_piecewise = fs_piecewise.statistics.flow_rates['GasEngine(Fuel)'].sum().item()\n",
- "print('\\nTotal fuel consumption:')\n",
- "print(f' Simple: {fuel_simple:.1f} kWh')\n",
- "print(f' Piecewise: {fuel_piecewise:.1f} kWh')\n",
- "print(\n",
- " f' Difference: {fuel_piecewise - fuel_simple:.1f} kWh ({(fuel_piecewise - fuel_simple) / fuel_simple * 100:+.1f}%)'\n",
- ")"
+ "# Compare: Simple vs Piecewise efficiency\n",
+ "print(f'Simple model: {fs_simple.solution[\"costs\"].item():.2f} €')\n",
+ "print(f'Piecewise model: {fs_piecewise.solution[\"costs\"].item():.2f} €')"
]
},
{
@@ -428,11 +356,9 @@
"id": "21",
"metadata": {},
"source": [
- "## Approach 3: Simple vs Piecewise Investment Costs\n",
+ "markdown## Approach 3: Simple vs Piecewise Investment Costs\n",
"\n",
- "Investment costs often have economies of scale - larger systems cost less per unit. Let's compare:\n",
- "1. **Simple model**: Constant cost per kWh (average: 800 €/kWh)\n",
- "2. **Refined model**: Piecewise costs that decrease with size"
+ "Investment costs often have economies of scale - larger systems cost less per unit."
]
},
{
@@ -452,17 +378,10 @@
"metadata": {},
"outputs": [],
"source": [
- "# Investment cost curve with economies of scale:\n",
- "# These represent the daily cost allocation of storage investment\n",
- "#\n",
- "# For SIMPLE model: constant marginal cost\n",
- "SIMPLE_INVEST_COST = 0.20 # €/kWh (daily amortized)\n",
- "\n",
- "# For PIECEWISE model: decreasing marginal cost (economies of scale)\n",
- "# - Small storage (0-100 kWh): 0.20 €/kWh - same as simple\n",
- "# - Medium storage (100-300 kWh): 0.15 €/kWh - better value\n",
- "# - Large storage (300-600 kWh): 0.10 €/kWh - bulk discount\n",
+ "# Investment costs (daily amortized)\n",
+ "SIMPLE_INVEST_COST = 0.20 # €/kWh constant\n",
"\n",
+ "# Piecewise: economies of scale (0.20 → 0.15 → 0.10 €/kWh)\n",
"piecewise_invest_costs = fx.PiecewiseEffects(\n",
" piecewise_origin=fx.Piecewise(\n",
" [\n",
@@ -480,14 +399,7 @@
" ]\n",
" )\n",
" },\n",
- ")\n",
- "\n",
- "print('Investment cost structure (daily amortized):')\n",
- "print(f' Simple model: constant {SIMPLE_INVEST_COST:.2f} €/kWh')\n",
- "print(' Piecewise model (economies of scale):')\n",
- "print(' 0-100 kWh: 0.20 €/kWh')\n",
- "print(' 100-300 kWh: 0.15 €/kWh')\n",
- "print(' 300-600 kWh: 0.10 €/kWh')"
+ ")"
]
},
{
@@ -497,54 +409,42 @@
"metadata": {},
"outputs": [],
"source": [
- "# Create base system for storage investment comparison\n",
- "# Scenario: Price arbitrage - charge when cheap, discharge when expensive\n",
- "# 3-day horizon to make storage investment meaningful\n",
- "\n",
+ "# Base system: Price arbitrage scenario (3 days)\n",
"timesteps_invest = pd.date_range('2024-01-22', periods=72, freq='h')\n",
"\n",
- "# Steady demand pattern repeated over 3 days\n",
"heat_demand_invest = np.tile(\n",
" np.concatenate(\n",
" [\n",
- " np.full(8, 100), # Night\n",
- " np.full(4, 120), # Morning\n",
- " np.full(4, 110), # Midday\n",
- " np.full(4, 130), # Evening peak\n",
- " np.full(4, 105), # Late evening\n",
+ " np.full(8, 100),\n",
+ " np.full(4, 120),\n",
+ " np.full(4, 110),\n",
+ " np.full(4, 130),\n",
+ " np.full(4, 105),\n",
" ]\n",
" ),\n",
" 3,\n",
")\n",
"\n",
- "# Strong price signal for arbitrage\n",
"energy_price_invest = np.tile(\n",
" np.concatenate(\n",
" [\n",
- " np.full(8, 0.08), # Night: cheap - perfect for charging\n",
- " np.full(4, 0.20), # Morning: expensive\n",
- " np.full(4, 0.12), # Midday: medium\n",
- " np.full(4, 0.25), # Evening: very expensive - best time to discharge\n",
- " np.full(4, 0.10), # Late evening\n",
+ " np.full(8, 0.08),\n",
+ " np.full(4, 0.20),\n",
+ " np.full(4, 0.12),\n",
+ " np.full(4, 0.25),\n",
+ " np.full(4, 0.10),\n",
" ]\n",
" ),\n",
" 3,\n",
")\n",
"\n",
"fs_base = fx.FlowSystem(timesteps_invest)\n",
- "\n",
"fs_base.add_elements(\n",
" fx.Bus('Heat', carrier='heat'),\n",
" fx.Effect('costs', '€', 'Costs', is_standard=True, is_objective=True),\n",
" fx.Source('HeatSource', outputs=[fx.Flow('Heat', bus='Heat', size=300, effects_per_flow_hour=energy_price_invest)]),\n",
" fx.Sink('HeatSink', inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=heat_demand_invest)]),\n",
- ")\n",
- "\n",
- "print('Time horizon: 72 hours (3 days)')\n",
- "print(f'Demand range: {heat_demand_invest.min():.0f} - {heat_demand_invest.max():.0f} kW')\n",
- "print(f'Price range: {energy_price_invest.min():.2f} - {energy_price_invest.max():.2f} €/kWh')\n",
- "print(f'Price spread: {energy_price_invest.max() - energy_price_invest.min():.2f} €/kWh')\n",
- "print('=> Storage value comes from charging cheap, discharging expensive')"
+ ")"
]
},
{
@@ -554,18 +454,15 @@
"metadata": {},
"outputs": [],
"source": [
- "# ============================================\n",
"# MODEL 1: Simple linear investment costs\n",
- "# ============================================\n",
"fs_simple_invest = fs_base.copy()\n",
- "\n",
"fs_simple_invest.add_elements(\n",
" fx.Storage(\n",
" 'ThermalStorage',\n",
" charging=fx.Flow('charge', bus='Heat', size=200),\n",
" discharging=fx.Flow('discharge', bus='Heat', size=200),\n",
" capacity_in_flow_hours=fx.InvestParameters(\n",
- " effects_of_investment_per_size=SIMPLE_INVEST_COST, # constant 0.20 €/kWh\n",
+ " effects_of_investment_per_size=SIMPLE_INVEST_COST,\n",
" minimum_size=0,\n",
" maximum_size=600,\n",
" ),\n",
@@ -575,13 +472,7 @@
" relative_loss_per_hour=0.005,\n",
" ),\n",
")\n",
- "\n",
- "fs_simple_invest.optimize(fx.solvers.HighsSolver())\n",
- "cost_simple_invest = fs_simple_invest.solution['costs'].item()\n",
- "size_simple = fs_simple_invest.solution['ThermalStorage|size'].item()\n",
- "print('Simple investment model:')\n",
- "print(f' Total cost: {cost_simple_invest:,.2f} €')\n",
- "print(f' Storage size: {size_simple:.1f} kWh')"
+ "fs_simple_invest.optimize(fx.solvers.HighsSolver())"
]
},
{
@@ -591,18 +482,15 @@
"metadata": {},
"outputs": [],
"source": [
- "# ============================================\n",
"# MODEL 2: Piecewise investment costs (economies of scale)\n",
- "# ============================================\n",
"fs_piecewise_invest = fs_base.copy()\n",
- "\n",
"fs_piecewise_invest.add_elements(\n",
" fx.Storage(\n",
" 'ThermalStorage',\n",
" charging=fx.Flow('charge', bus='Heat', size=200),\n",
" discharging=fx.Flow('discharge', bus='Heat', size=200),\n",
" capacity_in_flow_hours=fx.InvestParameters(\n",
- " piecewise_effects_of_investment=piecewise_invest_costs, # economies of scale\n",
+ " piecewise_effects_of_investment=piecewise_invest_costs,\n",
" minimum_size=0,\n",
" maximum_size=600,\n",
" ),\n",
@@ -612,13 +500,7 @@
" relative_loss_per_hour=0.005,\n",
" ),\n",
")\n",
- "\n",
- "fs_piecewise_invest.optimize(fx.solvers.HighsSolver())\n",
- "cost_piecewise_invest = fs_piecewise_invest.solution['costs'].item()\n",
- "size_piecewise = fs_piecewise_invest.solution['ThermalStorage|size'].item()\n",
- "print('Piecewise investment model:')\n",
- "print(f' Total cost: {cost_piecewise_invest:,.2f} €')\n",
- "print(f' Storage size: {size_piecewise:.1f} kWh')"
+ "fs_piecewise_invest.optimize(fx.solvers.HighsSolver())"
]
},
{
@@ -639,60 +521,13 @@
"metadata": {},
"outputs": [],
"source": [
- "# ============================================\n",
- "# Compare Results: Simple vs Piecewise Investment\n",
- "# ============================================\n",
- "\n",
- "# Calculate operating cost baseline (without storage) - suppress solver output\n",
- "import contextlib\n",
- "import os\n",
- "\n",
- "with open(os.devnull, 'w') as devnull:\n",
- " with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):\n",
- " fs_no_storage = fs_base.copy()\n",
- " fs_no_storage.optimize(fx.solvers.HighsSolver())\n",
- " cost_baseline = fs_no_storage.solution['costs'].item()\n",
- "\n",
- "print('=' * 60)\n",
- "print('INVESTMENT MODEL COMPARISON')\n",
- "print('=' * 60)\n",
- "\n",
- "print(f'\\nBaseline (no storage): {cost_baseline:.2f} €')\n",
- "\n",
- "print(f'\\nSimple model (constant {SIMPLE_INVEST_COST:.2f} €/kWh):')\n",
- "print(f' Storage size: {size_simple:.1f} kWh')\n",
- "print(f' Total cost: {cost_simple_invest:,.2f} €')\n",
- "invest_simple = size_simple * SIMPLE_INVEST_COST\n",
- "print(f' - Investment: {invest_simple:.2f} € ({size_simple:.0f} kWh × {SIMPLE_INVEST_COST:.2f} €/kWh)')\n",
- "print(f' - Operating: {cost_simple_invest - invest_simple:.2f} €')\n",
- "print(f' - Savings: {cost_baseline - cost_simple_invest:.2f} € vs no storage')\n",
- "\n",
- "print('\\nPiecewise model (economies of scale):')\n",
- "print(f' Storage size: {size_piecewise:.1f} kWh')\n",
- "print(f' Total cost: {cost_piecewise_invest:,.2f} €')\n",
- "\n",
- "# Calculate piecewise investment cost\n",
- "if size_piecewise <= 100:\n",
- " invest_piecewise = size_piecewise * 0.20\n",
- "elif size_piecewise <= 300:\n",
- " invest_piecewise = 20 + (size_piecewise - 100) * 0.15\n",
- "else:\n",
- " invest_piecewise = 50 + (size_piecewise - 300) * 0.10\n",
- "\n",
- "avg_cost = invest_piecewise / size_piecewise if size_piecewise > 0 else 0\n",
- "print(f' - Investment: {invest_piecewise:.2f} € (avg: {avg_cost:.2f} €/kWh)')\n",
- "print(f' - Operating: {cost_piecewise_invest - invest_piecewise:.2f} €')\n",
- "print(f' - Savings: {cost_baseline - cost_piecewise_invest:.2f} € vs no storage')\n",
- "\n",
- "print('\\n' + '-' * 60)\n",
- "print('KEY INSIGHT:')\n",
- "if size_piecewise > size_simple:\n",
- " print(f' Economies of scale result in {size_piecewise - size_simple:.0f} kWh MORE storage')\n",
- " print(f' with {cost_simple_invest - cost_piecewise_invest:.2f} € LOWER total cost.')\n",
- "else:\n",
- " print(f' Both models choose similar storage size (~{size_simple:.0f} kWh)')\n",
- " print(f' but piecewise saves {cost_simple_invest - cost_piecewise_invest:.2f} € through economies of scale.')\n",
- " print(f' Average cost: {SIMPLE_INVEST_COST:.2f} €/kWh (simple) vs {avg_cost:.2f} €/kWh (piecewise)')"
+ "# Compare: Simple vs Piecewise investment\n",
+ "print(\n",
+ " f'Simple model: {fs_simple_invest.solution[\"ThermalStorage|size\"].item():.0f} kWh, {fs_simple_invest.solution[\"costs\"].item():.2f} €'\n",
+ ")\n",
+ "print(\n",
+ " f'Piecewise model: {fs_piecewise_invest.solution[\"ThermalStorage|size\"].item():.0f} kWh, {fs_piecewise_invest.solution[\"costs\"].item():.2f} €'\n",
+ ")"
]
},
{
@@ -700,9 +535,7 @@
"id": "29",
"metadata": {},
"source": [
- "### Energy Flow Sankey (Heat Pump System)\n",
- "\n",
- "A Sankey diagram for the heat pump system:"
+ "markdown### Visualize Storage Operation and Energy Flows"
]
},
{
@@ -711,13 +544,24 @@
"id": "30",
"metadata": {},
"outputs": [],
+ "source": [
+ "# Storage operation visualization\n",
+ "fs_piecewise_invest.statistics.plot.heatmap('ThermalStorage')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "31",
+ "metadata": {},
+ "outputs": [],
"source": [
"fs_piecewise_invest.statistics.plot.sankey()"
]
},
{
"cell_type": "markdown",
- "id": "31",
+ "id": "32",
"metadata": {},
"source": [
"markdown## Key Concepts\n",
From bfbbb9cee49238586fd2a163f9ffae4636a99e93 Mon Sep 17 00:00:00 2001
From: FBumann <117816358+FBumann@users.noreply.github.com>
Date: Tue, 9 Dec 2025 21:23:27 +0100
Subject: [PATCH 5/6] Add notebook download button
---
docs/overrides/main.html | 11 +++++++++++
mkdocs.yml | 1 +
2 files changed, 12 insertions(+)
create mode 100644 docs/overrides/main.html
diff --git a/docs/overrides/main.html b/docs/overrides/main.html
new file mode 100644
index 000000000..b245acdaa
--- /dev/null
+++ b/docs/overrides/main.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+
+{% block content %}
+{% if page.nb_url %}
+
+ {% include ".icons/material/download.svg" %}
+
+{% endif %}
+
+{{ super() }}
+{% endblock content %}
diff --git a/mkdocs.yml b/mkdocs.yml
index 6fb836f3e..3a5ea6e6f 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -70,6 +70,7 @@ nav:
theme:
name: material
language: en
+ custom_dir: docs/overrides
palette:
# Palette toggle for automatic mode
From cee21764f259ac05c202a135c9dae3c3f096fedb Mon Sep 17 00:00:00 2001
From: FBumann <117816358+FBumann@users.noreply.github.com>
Date: Tue, 9 Dec 2025 22:18:03 +0100
Subject: [PATCH 6/6] Remove artifacts
---
docs/examples_new/06-piecewise-efficiency.ipynb | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/docs/examples_new/06-piecewise-efficiency.ipynb b/docs/examples_new/06-piecewise-efficiency.ipynb
index c8165e73e..ce87a93c6 100644
--- a/docs/examples_new/06-piecewise-efficiency.ipynb
+++ b/docs/examples_new/06-piecewise-efficiency.ipynb
@@ -356,7 +356,7 @@
"id": "21",
"metadata": {},
"source": [
- "markdown## Approach 3: Simple vs Piecewise Investment Costs\n",
+ "## Approach 3: Simple vs Piecewise Investment Costs\n",
"\n",
"Investment costs often have economies of scale - larger systems cost less per unit."
]
@@ -511,7 +511,10 @@
"outputs": [],
"source": [
"# Visualize the piecewise investment cost curve\n",
- "piecewise_invest_costs.plot(title='Storage Investment Costs (Economies of Scale)')"
+ "# Access through the storage component after it's part of the FlowSystem\n",
+ "fs_piecewise_invest.components['ThermalStorage'].capacity_in_flow_hours.piecewise_effects_of_investment.plot(\n",
+ " title='Storage Investment Costs (Economies of Scale)'\n",
+ ")"
]
},
{
@@ -535,7 +538,7 @@
"id": "29",
"metadata": {},
"source": [
- "markdown### Visualize Storage Operation and Energy Flows"
+ "### Visualize Storage Operation and Energy Flows"
]
},
{
@@ -564,7 +567,7 @@
"id": "32",
"metadata": {},
"source": [
- "markdown## Key Concepts\n",
+ "## Key Concepts\n",
"\n",
"### Time-Varying Conversion Factors\n",
"\n",