diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst index 622484c2c..30fb29ede 100644 --- a/docs/source/release_notes.rst +++ b/docs/source/release_notes.rst @@ -27,6 +27,7 @@ Upcoming Release - **Readme naming of examples/tutorials**: Slight changing of tutorial and example in the read me to make difference clearer and more consistent with the naming of the notebooks and to align with readthedocs. - **Align redispatch mechanism to latest PyPSA version release**: Updated the redispatch formulation to model cleared EOM generator dispatch for ``network.lpf()`` via ``generators_t.p_set`` and consistent generator bounds ``p_min_pu/p_max_pu``, replacing the previous load-based workaround. - **Grafana dashboard improvements**: Added button to automatically update the time range filter to the full simulation horizon. + - **Rework the redispatch use case in the DSU & flexibility tutorial**: The redispatch example in ``examples/notebooks/10_DSU_and_flexibility.ipynb`` now places the renewable surplus in the north and the load plus dispatchable plants in the south, so the redispatch is balanced (total upward volume equals total downward volume) and clearly demonstrates renewable curtailment on the congested side together with dispatchable ramp-up on the other. A summary table of the redispatch volumes per energy source was added below the redispatch plot, and the explanatory text was updated accordingly. **Bug Fixes:** - **Dependencies**: pin xarray and setuptools dependencies until upstream fixes are available diff --git a/examples/notebooks/10_DSU_and_flexibility.ipynb b/examples/notebooks/10_DSU_and_flexibility.ipynb index bd104bbd0..496a1321a 100644 --- a/examples/notebooks/10_DSU_and_flexibility.ipynb +++ b/examples/notebooks/10_DSU_and_flexibility.ipynb @@ -626,6 +626,7 @@ " \"KKW ISAR 2\",\n", " \"KKW BROKDORF\",\n", " \"KKW PHILIPPSBURG 2\",\n", + " \"Gas CCGT\",\n", " ],\n", " \"technology\": [\n", " \"wind_onshore\",\n", @@ -636,6 +637,7 @@ " \"nuclear\",\n", " \"nuclear\",\n", " \"nuclear\",\n", + " \"natural gas\",\n", " ],\n", " \"bidding_EOM\": [\n", " \"powerplant_energy_naive\",\n", @@ -646,6 +648,7 @@ " \"powerplant_energy_naive\",\n", " \"powerplant_energy_naive\",\n", " \"powerplant_energy_naive\",\n", + " \"powerplant_energy_naive\",\n", " ],\n", " \"fuel_type\": [\n", " \"renewable\",\n", @@ -656,15 +659,26 @@ " \"uranium\",\n", " \"uranium\",\n", " \"uranium\",\n", + " \"natural_gas\",\n", + " ],\n", + " \"emission_factor\": [0, 0, 0, 0, 0, 0, 0, 0, 0],\n", + " \"max_power\": [40, 10, 50, 30, 50, 100, 100, 100, 400],\n", + " \"min_power\": [0, 0, 0, 0, 0, 10, 10, 10, 0],\n", + " \"efficiency\": [1, 1, 1, 1, 1, 0.33, 0.33, 0.33, 0.5],\n", + " \"ramp_up\": [None, None, None, None, 30, 50, 50, 50, None],\n", + " \"ramp_down\": [None, None, None, None, 30, 50, 50, 50, None],\n", + " \"additional_cost\": [0, 0, 0, 0, 6, 10.3, 10.3, 10.3, 0],\n", + " \"node\": [\n", + " \"north\",\n", + " \"north\",\n", + " \"north\",\n", + " \"north\",\n", + " \"north\",\n", + " \"south\",\n", + " \"south\",\n", + " \"south\",\n", + " \"south\",\n", " ],\n", - " \"emission_factor\": [0, 0, 0, 0, 0, 0, 0, 0],\n", - " \"max_power\": [40, 10, 50, 30, 50, 100, 100, 100],\n", - " \"min_power\": [0, 0, 0, 0, 0, 10, 10, 10],\n", - " \"efficiency\": [1, 1, 1, 1, 1, 0.33, 0.33, 0.33],\n", - " \"ramp_up\": [None, None, None, None, 30, 50, 50, 50],\n", - " \"ramp_down\": [None, None, None, None, 30, 50, 50, 50],\n", - " \"additional_cost\": [0, 0, 0, 0, 6, 10.3, 10.3, 10.3],\n", - " \"node\": [\"north\", \"north\", \"north\", \"north\", \"north\", \"south\", \"south\", \"south\"],\n", " \"unit_operator\": [\n", " \"renewables_operator\",\n", " \"renewables_operator\",\n", @@ -674,6 +688,7 @@ " \"UNIPER\",\n", " \"UNIPER\",\n", " \"ENBW ENERGIE BADEN-WURTTEMBERG\",\n", + " \"gas_operator\",\n", " ],\n", "}\n", "\n", @@ -777,8 +792,16 @@ "\n", "# Simulate demand data for 'demand_EOM1' and 'demand_EOM2' (example demand pattern)\n", "# For simplicity, we'll create a fluctuating demand pattern using a sinusoidal function\n", - "demand_values1 = 450 + 100 * np.sin(np.linspace(0, 6 * np.pi, len(time_index)))\n", - "demand_values2 = 500 + 80 * np.sin(np.linspace(0, 6 * np.pi, len(time_index)))\n", + "# Diurnal demand: daily peak ~15:00, trough ~03:00. The *total* is kept below\n", + "# the cheap capacity (renewables + nuclear = 480 MW) so the steel plant's extra\n", + "# load can tip the system onto the gas peaker (Use Cases 1 & 2). Demand is\n", + "# concentrated at the *north* node (the load centre) while generation sits\n", + "# mostly in the *south*, so the north-south line congests at peak hours\n", + "# (needed for the Use Case 3 redispatch). Total = 150 + 300 * daily_shape either way.\n", + "hour_of_day = time_index.hour + time_index.minute / 60\n", + "daily_shape = 0.5 + 0.5 * np.sin(2 * np.pi * (hour_of_day - 9) / 24)\n", + "demand_values1 = 130 + 280 * daily_shape # north: load centre\n", + "demand_values2 = 20 + 20 * daily_shape # south: mostly generation\n", "\n", "# Create the DataFrame with both 'demand_EOM1' and 'demand_EOM2'\n", "demand_df = pd.DataFrame(\n", @@ -1202,8 +1225,8 @@ "# @title Setting Up Fuel Prices\n", "# Define fuel prices for the power plant units\n", "fuel_prices = {\n", - " \"fuel\": [\"uranium\", \"co2\"],\n", - " \"price\": [5, 25], # Example prices for uranium and CO2\n", + " \"fuel\": [\"uranium\", \"co2\", \"natural_gas\"],\n", + " \"price\": [5, 25, 35], # Prices for uranium, CO2 and natural gas\n", "}\n", "\n", "# Convert the dictionary to a DataFrame and save as CSV\n", @@ -1498,8 +1521,7 @@ "steel_plant_orders = steel_plant_orders.set_index(\"time\").reindex(full_time_range)\n", "\n", "# Fill NaN values in 'volume' and 'price' with 0 for the missing time steps\n", - "steel_plant_orders[\"volume\"].fillna(0, inplace=True)\n", - "steel_plant_orders[\"price\"].fillna(0, inplace=True)\n", + "steel_plant_orders = steel_plant_orders.fillna({\"volume\": 0, \"price\": 0})\n", "\n", "# Reset the index back to 'time'\n", "steel_plant_orders = steel_plant_orders.reset_index().rename(columns={\"index\": \"time\"})\n", @@ -1948,8 +1970,7 @@ "now = network.snapshots[2]\n", "loading = network.lines_t.p0.loc[now] / (network.lines.s_nom * network.lines.s_max_pu)\n", "congestion_threshold = 1\n", - "# TODO replace with \"red\" and \"blue\" when fixed in pypsa\n", - "line_colors = np.where(abs(loading) > congestion_threshold, 0, 5)\n", + "line_colors = np.where(abs(loading) > congestion_threshold, \"red\", \"blue\")\n", "\n", "# Create the figure and the axis using Cartopy's PlateCarree projection\n", "fig, ax = plt.subplots(figsize=(6, 6), subplot_kw={\"projection\": ccrs.PlateCarree()})\n", @@ -1958,11 +1979,9 @@ "network.plot(\n", " ax=ax,\n", " line_colors=line_colors,\n", - " line_cmap=None,\n", " title=\"Line Loading\",\n", " bus_sizes=1e-2, # Size of bus markers\n", " bus_alpha=0.7, # Transparency of bus markers\n", - " bus_colors=[0, 0], # TODO remove when fixed in pypsa\n", ")\n", "\n", "# Adjust layout and display the plot\n", @@ -2109,8 +2128,7 @@ ")\n", "\n", "# Fill NaN values in 'volume' and 'price' with 0 for the missing time steps\n", - "steel_plant_orders_uc2[\"volume\"].fillna(0, inplace=True)\n", - "steel_plant_orders_uc2[\"price\"].fillna(0, inplace=True)\n", + "steel_plant_orders_uc2 = steel_plant_orders_uc2.fillna({\"volume\": 0, \"price\": 0})\n", "\n", "# Reset the index back to 'time'\n", "steel_plant_orders_uc2 = steel_plant_orders_uc2.reset_index().rename(\n", @@ -2389,8 +2407,7 @@ "now = network.snapshots[10]\n", "loading = network.lines_t.p0.loc[now] / (network.lines.s_nom * network.lines.s_max_pu)\n", "congestion_threshold = 1\n", - "# TODO replace with \"red\" and \"blue\" when fixed in pypsa\n", - "line_colors = np.where(abs(loading) > congestion_threshold, 0, 5)\n", + "line_colors = np.where(abs(loading) > congestion_threshold, \"red\", \"blue\")\n", "\n", "# Create the figure and the axis using Cartopy's PlateCarree projection\n", "fig, ax = plt.subplots(figsize=(4, 4), subplot_kw={\"projection\": ccrs.PlateCarree()})\n", @@ -2399,11 +2416,9 @@ "network.plot(\n", " ax=ax,\n", " line_colors=line_colors,\n", - " line_cmap=None,\n", " title=\"Line Loading\",\n", " bus_sizes=5e-2, # Size of bus markers\n", " bus_alpha=1, # Transparency of bus markers\n", - " bus_colors=[0, 0], # TODO remove when fixed in pypsa\n", ")\n", "\n", "# Adjust layout and display the plot\n", @@ -2569,9 +2584,13 @@ "metadata": {}, "source": [ "**Clearing Price Comparison**\n", - "- **Use Case 1** (without flexibility) represents the market-clearing price under normal conditions, where the steel plant follows its original consumption pattern.\n", - "- **Use Case 2** (with flexibility) reflects how enabling flexibility in the plant’s operation can affect market-clearing prices, particularly during periods when the plant shifts its demand.\n", - "- The plot provides a clear comparison between the two use cases, showing how flexibility can reduce or increase price volatility based on the plant’s adjusted demand patterns." + "\n", + "The generation stack now has a clear merit order: cheap renewables (0-6 EUR/MWh) -> nuclear (25.45 EUR/MWh) -> a natural-gas peaker (~70 EUR/MWh). Base demand is kept below the combined renewables + nuclear capacity (480 MW), so the marginal technology - and therefore the clearing price - depends on how much *extra* load the steel plant adds.\n", + "\n", + "- **Use Case 1**: the steel plant runs at high load during peak-demand hours, pushing total demand above the cheap capacity so the gas peaker sets the price. The clearing price spikes to ~70 EUR/MWh in those hours.\n", + "- **Use Case 2**: the steel plant consumes less / reschedules, so demand stays within the cheaper capacity and the price stays at the nuclear level (~25 EUR/MWh) or drops to the renewable level (~6 EUR/MWh).\n", + "\n", + "The two price series therefore differ in the hours where the steel plant's consumption tips the system onto the peaker. Note that in this toy setup the difference reflects the plant's *different total consumption* between the two cases rather than a pure time-shift of a fixed load." ] }, { @@ -2617,7 +2636,9 @@ "source": [ "### **1. Prepare Input Files**\n", "\n", - "We'll reuse the DataFrames for powerplant units, demand units, and demand_df that were already created in Use Case 1. These files will be saved in the inputs/tutorial_10 folder for Use Case 3." + "For this redispatch use case we **reconfigure** the two-node grid so that the network congestion produces a clean, easy-to-read result. All renewables are placed in the **north** together with a small local load, while the **south** holds the load centre and all the dispatchable plants (nuclear and gas). The cheap northern renewables are dispatched in full in the day-ahead market, so more power than the 200 MW line can carry has to flow north -> south, congesting the line.\n", + "\n", + "These changes only affect Use Case 3 - Use Cases 1 and 2 have already been run and their results saved above. The updated input files are written to the `inputs/tutorial_10` folder." ] }, { @@ -2626,35 +2647,87 @@ "metadata": {}, "outputs": [], "source": [ - "# ---- Buses Units ----\n", - "# Load the Buses\n", + "# ---- Reconfigure the grid for the Use Case 3 redispatch demo ----\n", + "# We rebuild the two-node grid so that the redispatch market gives a clean,\n", + "# *balanced* result that also shows RENEWABLE CURTAILMENT:\n", + "# * north = cheap wind/solar surplus + a small local load\n", + "# * south = the load centre + all the dispatchable plants (nuclear, gas)\n", + "# The 380 MW of northern renewables are dispatched in full in the day-ahead\n", + "# market, so > 200 MW has to flow north -> south over the 200 MW line and the\n", + "# line congests. Redispatch then CURTAILS the northern renewables (downward) and\n", + "# ramps the southern dispatchable plants UP by exactly the same amount, so total\n", + "# upward volume == total downward volume (no hidden backup generator involved).\n", + "\n", + "# ---- Power plant units ----\n", "powerplant_units = pd.read_csv(f\"{scenario_path}/powerplant_units.csv\")\n", "\n", - "# Add the new 'bidding_redispatch' column based on the 'technology' column\n", - "# For simplicity, we're assigning 'powerplant_energy_naive_redispatch' for all rows (can be customized)\n", - "powerplant_units[\"bidding_redispatch\"] = \"powerplant_energy_naive_redispatch\"\n", + "# all renewables in the north, all dispatchable plants in the south\n", + "node_by_name = {\n", + " \"Wind onshore\": \"north\",\n", + " \"Wind offshore\": \"north\",\n", + " \"Solar\": \"north\",\n", + " \"Hydro\": \"north\",\n", + " \"Biomass\": \"north\",\n", + " \"KKW ISAR 2\": \"south\",\n", + " \"KKW BROKDORF\": \"south\",\n", + " \"KKW PHILIPPSBURG 2\": \"south\",\n", + " \"Gas CCGT\": \"south\",\n", + "}\n", + "# northern renewable capacity (380 MW) clearly exceeds the 200 MW line, so the\n", + "# surplus cannot be exported and has to be curtailed in redispatch\n", + "max_power_by_name = {\n", + " \"Wind onshore\": 150,\n", + " \"Wind offshore\": 100,\n", + " \"Solar\": 50,\n", + " \"Hydro\": 30,\n", + " \"Biomass\": 50,\n", + " \"KKW ISAR 2\": 100,\n", + " \"KKW BROKDORF\": 100,\n", + " \"KKW PHILIPPSBURG 2\": 100,\n", + " \"Gas CCGT\": 400,\n", + "}\n", + "powerplant_units[\"node\"] = powerplant_units[\"name\"].map(node_by_name)\n", + "powerplant_units[\"max_power\"] = powerplant_units[\"name\"].map(max_power_by_name)\n", "\n", - "# Save it to the new directory for Use Case 3\n", + "# every plant also bids into the redispatch market\n", + "powerplant_units[\"bidding_redispatch\"] = \"powerplant_energy_naive_redispatch\"\n", "powerplant_units.to_csv(f\"{scenario_path}/powerplant_units.csv\", index=False)\n", "\n", - "# ---- Demand Units ----\n", - "# Load the demand_units DataFrame from Use Case 1\n", + "# ---- Demand units ----\n", "demand_units = pd.read_csv(f\"{scenario_path}/demand_units.csv\")\n", "demand_units[\"bidding_redispatch\"] = \"demand_energy_naive_redispatch\"\n", - "\n", - "# Save it to the new directory for Use Case 3\n", "demand_units.to_csv(f\"{scenario_path}/demand_units.csv\", index=False)\n", "\n", + "# ---- Demand time series: small in the north, large in the south ----\n", + "uc3_index = pd.date_range(\n", + " start=\"2019-01-01 00:00\", end=\"2019-01-05 00:00\", freq=\"15min\"\n", + ")\n", + "hour_of_day = uc3_index.hour + uc3_index.minute / 60\n", + "daily_shape = 0.5 + 0.5 * np.sin(2 * np.pi * (hour_of_day - 9) / 24)\n", + "demand_df_uc3 = pd.DataFrame(\n", + " {\n", + " \"demand_EOM1\": 30\n", + " + 20 * daily_shape, # north: small local load -> exports its wind\n", + " \"demand_EOM2\": 380 + 80 * daily_shape, # south: the load centre\n", + " },\n", + " index=uc3_index,\n", + ")\n", + "demand_df_uc3.to_csv(f\"{scenario_path}/demand_df.csv\")\n", + "\n", "print(\n", - " f\"Input updated CSV files for powerplant units and demand units have been saved to '{scenario_path}'.\"\n", - ")" + " \"Use Case 3 grid reconfigured: wind/solar surplus in the north, \"\n", + " \"load + dispatchable plants in the south.\"\n", + ")\n", + "display(powerplant_units[[\"name\", \"node\", \"max_power\", \"additional_cost\"]])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### **2. Update Industrial DSM Unit**" + "### **2. Remove the Steel Plant from the Redispatch Case**\n", + "\n", + "The steel plant is a demand-side (DSM) unit that participates only in the day-ahead market - it does **not** bid into the redispatch market. Its consumption would therefore be present in the EOM dispatch but **absent** from the redispatch network, and that mismatch would force a spurious net-downward redispatch (generation that served the steel load would be backed down with no counterpart). For this clean redispatch demonstration we remove the steel plant from Use Case 3 entirely." ] }, { @@ -2663,23 +2736,14 @@ "metadata": {}, "outputs": [], "source": [ - "# Load industrial_dsm_units CSV\n", - "industrial_dsm_units = pd.read_csv(f\"{scenario_path}/industrial_dsm_units.csv\")\n", - "\n", - "# Update the 'flexibility_measure'\n", - "industrial_dsm_units.loc[\n", - " industrial_dsm_units[\"technology\"] == \"electrolyser\", \"flexibility_measure\"\n", - "] = \"None\"\n", - "\n", - "# Update the 'bidding strategy'\n", - "industrial_dsm_units.loc[\n", - " industrial_dsm_units[\"technology\"] == \"electrolyser\", \"bidding_EOM\"\n", - "] = \"industry_energy_optimization\"\n", - "\n", - "# Save the updated industrial_dsm_units CSV to the new folder\n", - "industrial_dsm_units.to_csv(f\"{scenario_path}/industrial_dsm_units.csv\", index=False)\n", - "\n", - "print(f\"Updated 'industrial_dsm_units.csv' saved to '{input_dir}'.\")" + "# Remove the steel plant (DSM unit) from the Use Case 3 scenario so that the\n", + "# redispatch network is energy-consistent with the day-ahead dispatch.\n", + "dsm_path = f\"{scenario_path}/industrial_dsm_units.csv\"\n", + "if os.path.exists(dsm_path):\n", + " os.remove(dsm_path)\n", + " print(\"Steel plant (DSM unit) removed from the Use Case 3 scenario.\")\n", + "else:\n", + " print(\"No DSM unit file present - nothing to remove.\")" ] }, { @@ -2880,11 +2944,70 @@ "# Display the chart\n", "plt.show()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# ---- Redispatch summary table (easier to read than the figure above) ----\n", + "# Split each unit's redispatch over the redispatch horizon into upward (ramp-up)\n", + "# and downward (curtailment) volumes, so the up/down balance is easy to see.\n", + "pp_info = pd.read_csv(f\"{scenario_path}/powerplant_units.csv\").set_index(\"name\")\n", + "\n", + "redispatch_summary = pd.DataFrame(\n", + " {\n", + " \"Node\": pp_info[\"node\"],\n", + " \"Technology\": pp_info[\"technology\"],\n", + " \"Upward / ramp-up (MWh)\": redisp_p_set.clip(lower=0).sum(),\n", + " \"Downward / curtailment (MWh)\": redisp_p_set.clip(upper=0).sum(),\n", + " }\n", + ").dropna(subset=[\"Node\"])\n", + "redispatch_summary[\"Net (MWh)\"] = (\n", + " redispatch_summary[\"Upward / ramp-up (MWh)\"]\n", + " + redispatch_summary[\"Downward / curtailment (MWh)\"]\n", + ")\n", + "redispatch_summary = redispatch_summary.round(1).sort_values(\"Net (MWh)\")\n", + "\n", + "# Totals row: total upward volume == total downward volume -> balanced, no backup\n", + "total_row = pd.DataFrame(\n", + " {\n", + " \"Node\": \"\",\n", + " \"Technology\": \"ALL UNITS\",\n", + " \"Upward / ramp-up (MWh)\": round(redisp_p_set.clip(lower=0).values.sum(), 1),\n", + " \"Downward / curtailment (MWh)\": round(\n", + " redisp_p_set.clip(upper=0).values.sum(), 1\n", + " ),\n", + " \"Net (MWh)\": round(redisp_p_set.values.sum(), 1) + 0.0,\n", + " },\n", + " index=[\"TOTAL\"],\n", + ")\n", + "redispatch_summary = pd.concat([redispatch_summary, total_row])\n", + "\n", + "print(\n", + " \"Redispatch volumes by energy source over the redispatch horizon \"\n", + " \"(downward = renewables curtailed, upward = plants ramped up):\"\n", + ")\n", + "display(redispatch_summary)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "> **Reading this redispatch plot.** The northern renewables are cheap, so the day-ahead market dispatches them in full - but that pushes more than the 200 MW line rating onto the north -> south line. The redispatch market resolves the congestion by:\n", + ">\n", + "> - **curtailing the northern renewables** (Solar, Wind, Hydro, Biomass) - the downward, *negative* lines, and\n", + "> - **ramping the southern nuclear units up** by exactly the same amount - the upward, *positive* lines (the gas peaker stays in reserve).\n", + ">\n", + "> Because every MW backed down in the north is matched by a MW ramped up in the south, **total upward volume = total downward volume** and no backup generator is needed. This is the textbook redispatch signature: renewable curtailment on the exporting (congested) side, dispatchable ramp-up on the importing side. The summary table just above lists the exact upward and downward volume for every unit; its **TOTAL** row shows that upward volume equals downward volume." + ] } ], "metadata": { "kernelspec": { - "display_name": "assume-framework", + "display_name": "assume", "language": "python", "name": "python3" }, @@ -2898,7 +3021,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.7" + "version": "3.10.19" } }, "nbformat": 4,