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..ce87a93c6 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())"
]
},
{
@@ -247,9 +228,13 @@
"id": "15",
"metadata": {},
"source": [
- "## Approach 2: Piecewise Linear Efficiency\n",
+ "## Approach 2: Simple vs Piecewise Efficiency - Model Refinement\n",
+ "\n",
+ "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",
- "For more complex efficiency curves (e.g., part-load efficiency), use `Piecewise`:"
+ "This demonstrates how to progressively refine a model for more accurate results."
]
},
{
@@ -259,54 +244,32 @@
"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",
- "# - 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",
+ "# Gas engine efficiency varies with load:\n",
+ "# - Part load: lower efficiency\n",
+ "# - Mid load: optimal efficiency (~42%)\n",
+ "# - Full load: slightly lower efficiency\n",
"\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\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), # 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",
- " )"
+ ")"
]
},
{
@@ -316,33 +279,26 @@
"metadata": {},
"outputs": [],
"source": [
- "# Build system with piecewise efficiency\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",
- "fs_piecewise = fx.FlowSystem(timesteps_simple)\n",
- "\n",
- "fs_piecewise.add_elements(\n",
+ "# MODEL 1: Simple constant efficiency\n",
+ "fs_simple = fx.FlowSystem(timesteps_simple)\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",
" 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}],\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())"
]
},
{
@@ -352,7 +308,22 @@
"metadata": {},
"outputs": [],
"source": [
- "fs_piecewise.statistics.plot.balance('Electricity')"
+ "# MODEL 2: Piecewise efficiency (load-dependent)\n",
+ "fs_piecewise = fx.FlowSystem(timesteps_simple)\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",
+ " 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",
+ "fs_piecewise.optimize(fx.solvers.HighsSolver())"
]
},
{
@@ -362,7 +333,10 @@
"metadata": {},
"outputs": [],
"source": [
- "fs_piecewise.statistics.plot.balance('Gas')"
+ "# 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",
+ ")"
]
},
{
@@ -372,22 +346,9 @@
"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",
- "\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"
+ "# 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} €')"
]
},
{
@@ -395,9 +356,9 @@
"id": "21",
"metadata": {},
"source": [
- "## Approach 3: Piecewise Investment Costs\n",
+ "## Approach 3: Simple vs Piecewise Investment Costs\n",
"\n",
- "Piecewise functions can also model economies of scale in investment:"
+ "Investment costs often have economies of scale - larger systems cost less per unit."
]
},
{
@@ -407,68 +368,206 @@
"metadata": {},
"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",
- "\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",
+ "fs_piecewise.statistics.plot.balance('Gas')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "23",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Investment costs (daily amortized)\n",
+ "SIMPLE_INVEST_COST = 0.20 # €/kWh constant\n",
"\n",
- "# PiecewiseEffects maps a size variable to cost effects\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",
- " 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",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "24",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Base system: Price arbitrage scenario (3 days)\n",
+ "timesteps_invest = pd.date_range('2024-01-22', periods=72, freq='h')\n",
+ "\n",
+ "heat_demand_invest = np.tile(\n",
+ " np.concatenate(\n",
+ " [\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",
+ "energy_price_invest = np.tile(\n",
+ " np.concatenate(\n",
+ " [\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",
- "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')"
+ "fs_base = fx.FlowSystem(timesteps_invest)\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",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "25",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# MODEL 1: Simple linear investment costs\n",
+ "fs_simple_invest = fs_base.copy()\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,\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",
+ "fs_simple_invest.optimize(fx.solvers.HighsSolver())"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "26",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# MODEL 2: Piecewise investment costs (economies of scale)\n",
+ "fs_piecewise_invest = fs_base.copy()\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,\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",
+ "fs_piecewise_invest.optimize(fx.solvers.HighsSolver())"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "27",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Visualize the piecewise investment cost curve\n",
+ "# 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",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "28",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# 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",
+ ")"
]
},
{
"cell_type": "markdown",
- "id": "23",
+ "id": "29",
"metadata": {},
"source": [
- "### Energy Flow Sankey (Heat Pump System)\n",
- "\n",
- "A Sankey diagram for the heat pump system:"
+ "### Visualize Storage Operation and Energy Flows"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "24",
+ "id": "30",
"metadata": {},
"outputs": [],
"source": [
- "flow_system.statistics.plot.sankey()"
+ "# 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": "25",
+ "id": "32",
"metadata": {},
"source": [
- "markdown## Key Concepts\n",
+ "## Key Concepts\n",
"\n",
"### Time-Varying Conversion Factors\n",
"\n",
@@ -530,8 +629,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,
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/flixopt/interface.py b/flixopt/interface.py
index 7787eec18..0a9e9424c 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) -> None:
for piecewise in self.piecewises.values():
piecewise.transform_data()
+ 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) -> None:
for piecewise in self.piecewise_shares.values():
piecewise.transform_data()
+ 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):
diff --git a/mkdocs.yml b/mkdocs.yml
index 9c0470903..4e9e48776 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -72,6 +72,7 @@ nav:
theme:
name: material
language: en
+ custom_dir: docs/overrides
palette:
# Palette toggle for automatic mode