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",