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