#!/usr/bin/env python # coding: utf-8 # # Non-linear power flow after LOPF # # In this example, the dispatch of generators is optimised using the linear OPF, then a non-linear power flow is run on the resulting dispatch. # # ## Data sources # # **Grid:** based on [SciGRID](http://scigrid.de/) Version 0.2 which is based on [OpenStreetMap](http://www.openstreetmap.org/). # # Load size and location: based on Landkreise (NUTS 3) GDP and population. # # Load time series: from ENTSO-E hourly data, scaled up uniformly by factor 1.12 (a simplification of the methodology in Schumacher, Hirth (2015)). # # Conventional power plant capacities and locations: BNetzA list. # # Wind and solar capacities and locations: EEG Stammdaten, based on http://www.energymap.info/download.html, which represents capacities at the end of 2014. Units without PLZ are removed. # # Wind and solar time series: REatlas, Andresen et al, "Validation of Danish wind time series from a new global renewable energy atlas for energy system analysis," Energy 93 (2015) 1074 - 1088. # # Where SciGRID nodes have been split into 220kV and 380kV substations, all load and generation is attached to the 220kV substation. # # ## Warnings # # The data behind the notebook is no longer supported. See https://github.com/PyPSA/pypsa-eur for a newer model that covers the whole of Europe. # # This dataset is ONLY intended to demonstrate the capabilities of PyPSA and is NOT (yet) accurate enough to be used for research purposes. # # Known problems include: # # 1. Rough approximations have been made for missing grid data, e.g. 220kV-380kV transformers and connections between close sub-stations missing from OSM. # # 2. There appears to be some unexpected congestion in parts of the network, which may mean for example that the load attachment method (by Voronoi cell overlap with Landkreise) isn't working, particularly in regions with a high density of substations. # # 3. Attaching power plants to the nearest high voltage substation may not reflect reality. # # 4. There is no proper n-1 security in the calculations - this can either be simulated with a blanket e.g. 70% reduction in thermal limits (as done here) or a proper security constrained OPF (see e.g. ). # # 5. The borders and neighbouring countries are not represented. # # 6. Hydroelectric power stations are not modelled accurately. # # 7. The marginal costs are illustrative, not accurate. # # 8. Only the first day of 2011 is in the github dataset, which is not representative. The full year of 2011 can be downloaded at . # # 9. The ENTSO-E total load for Germany may not be scaled correctly; it is scaled up uniformly by factor 1.12 (a simplification of the methodology in Schumacher, Hirth (2015), which suggests monthly factors). # # 10. Biomass from the EEG Stammdaten are not read in at the moment. # # 11. Power plant start up costs, ramping limits/costs, minimum loading rates are not considered. # # In[ ]: import pypsa import numpy as np import pandas as pd import os import matplotlib.pyplot as plt import cartopy.crs as ccrs # In[ ]: network = pypsa.examples.scigrid_de(from_master=True) # Plot the distribution of the load and of generating tech # In[ ]: fig, ax = plt.subplots( 1, 1, subplot_kw={"projection": ccrs.EqualEarth()}, figsize=(8, 8) ) load_distribution = ( network.loads_t.p_set.loc[network.snapshots[0]].groupby(network.loads.bus).sum() ) network.plot(bus_sizes=1e-5 * load_distribution, ax=ax, title="Load distribution"); # In[ ]: network.generators.groupby("carrier")["p_nom"].sum() # In[ ]: network.storage_units.groupby("carrier")["p_nom"].sum() # In[ ]: techs = ["Gas", "Brown Coal", "Hard Coal", "Wind Offshore", "Wind Onshore", "Solar"] n_graphs = len(techs) n_cols = 3 if n_graphs % n_cols == 0: n_rows = n_graphs // n_cols else: n_rows = n_graphs // n_cols + 1 fig, axes = plt.subplots( nrows=n_rows, ncols=n_cols, subplot_kw={"projection": ccrs.EqualEarth()} ) size = 6 fig.set_size_inches(size * n_cols, size * n_rows) for i, tech in enumerate(techs): i_row = i // n_cols i_col = i % n_cols ax = axes[i_row, i_col] gens = network.generators[network.generators.carrier == tech] gen_distribution = ( gens.groupby("bus").sum()["p_nom"].reindex(network.buses.index, fill_value=0.0) ) network.plot(ax=ax, bus_sizes=2e-5 * gen_distribution) ax.set_title(tech) fig.tight_layout() # Run Linear Optimal Power Flow on the first day of 2011. # # To approximate n-1 security and allow room for reactive power flows, don't allow any line to be loaded above 70% of their thermal rating # In[ ]: contingency_factor = 0.7 network.lines.s_max_pu = contingency_factor # There are some infeasibilities without small extensions # In[ ]: network.lines.loc[["316", "527", "602"], "s_nom"] = 1715 # We performing a linear OPF for one day, 4 snapshots at a time. # In[ ]: group_size = 4 network.storage_units.state_of_charge_initial = 0.0 for i in range(int(24 / group_size)): # set the initial state of charge based on previous round if i: network.storage_units.state_of_charge_initial = ( network.storage_units_t.state_of_charge.loc[ network.snapshots[group_size * i - 1] ] ) network.optimize( network.snapshots[group_size * i : group_size * i + group_size], solver_name="cbc", ) # In[ ]: p_by_carrier = network.generators_t.p.groupby(network.generators.carrier, axis=1).sum() p_by_carrier.drop( (p_by_carrier.max()[p_by_carrier.max() < 1700.0]).index, axis=1, inplace=True ) p_by_carrier.columns # In[ ]: colors = { "Brown Coal": "brown", "Hard Coal": "k", "Nuclear": "r", "Run of River": "green", "Wind Onshore": "blue", "Solar": "yellow", "Wind Offshore": "cyan", "Waste": "orange", "Gas": "orange", } # reorder cols = [ "Nuclear", "Run of River", "Brown Coal", "Hard Coal", "Gas", "Wind Offshore", "Wind Onshore", "Solar", ] p_by_carrier = p_by_carrier[cols] # In[ ]: c = [colors[col] for col in p_by_carrier.columns] fig, ax = plt.subplots(figsize=(12, 6)) (p_by_carrier / 1e3).plot(kind="area", ax=ax, linewidth=4, color=c, alpha=0.7) ax.legend(ncol=4, loc="upper left") ax.set_ylabel("GW") ax.set_xlabel("") fig.tight_layout() # In[ ]: fig, ax = plt.subplots(figsize=(12, 6)) p_storage = network.storage_units_t.p.sum(axis=1) state_of_charge = network.storage_units_t.state_of_charge.sum(axis=1) p_storage.plot(label="Pumped hydro dispatch", ax=ax, linewidth=3) state_of_charge.plot(label="State of charge", ax=ax, linewidth=3) ax.legend() ax.grid() ax.set_ylabel("MWh") ax.set_xlabel("") fig.tight_layout() # In[ ]: now = network.snapshots[4] # With the linear load flow, there is the following per unit loading: # In[ ]: loading = network.lines_t.p0.loc[now] / network.lines.s_nom loading.describe() # In[ ]: fig, ax = plt.subplots(subplot_kw={"projection": ccrs.EqualEarth()}, figsize=(9, 9)) network.plot( ax=ax, line_colors=abs(loading), line_cmap=plt.cm.jet, title="Line loading", bus_sizes=1e-3, bus_alpha=0.7, ) fig.tight_layout(); # Let's have a look at the marginal prices # In[ ]: network.buses_t.marginal_price.loc[now].describe() # In[ ]: fig, ax = plt.subplots(subplot_kw={"projection": ccrs.PlateCarree()}, figsize=(8, 8)) plt.hexbin( network.buses.x, network.buses.y, gridsize=20, C=network.buses_t.marginal_price.loc[now], cmap=plt.cm.jet, zorder=3, ) network.plot(ax=ax, line_widths=pd.Series(0.5, network.lines.index), bus_sizes=0) cb = plt.colorbar(location="bottom") cb.set_label("Locational Marginal Price (EUR/MWh)") fig.tight_layout() # ## Curtailment variable # # By considering how much power is available and how much is generated, you can see what share is curtailed: # In[ ]: carrier = "Wind Onshore" capacity = network.generators.groupby("carrier").sum().at[carrier, "p_nom"] p_available = network.generators_t.p_max_pu.multiply(network.generators["p_nom"]) p_available_by_carrier = p_available.groupby(network.generators.carrier, axis=1).sum() p_curtailed_by_carrier = p_available_by_carrier - p_by_carrier # In[ ]: p_df = pd.DataFrame( { carrier + " available": p_available_by_carrier[carrier], carrier + " dispatched": p_by_carrier[carrier], carrier + " curtailed": p_curtailed_by_carrier[carrier], } ) p_df[carrier + " capacity"] = capacity # In[ ]: p_df["Wind Onshore curtailed"][p_df["Wind Onshore curtailed"] < 0.0] = 0.0 # In[ ]: fig, ax = plt.subplots(figsize=(10, 4)) p_df[[carrier + " dispatched", carrier + " curtailed"]].plot( kind="area", ax=ax, linewidth=3 ) p_df[[carrier + " available", carrier + " capacity"]].plot(ax=ax, linewidth=3) ax.set_xlabel("") ax.set_ylabel("Power [MW]") ax.set_ylim([0, 40000]) ax.legend() fig.tight_layout() # ## Non-Linear Power Flow # # Now perform a full Newton-Raphson power flow on the first hour. For the PF, set the P to the optimised P. # In[ ]: network.generators_t.p_set = network.generators_t.p network.storage_units_t.p_set = network.storage_units_t.p # Set all buses to PV, since we don't know what Q set points are # In[ ]: network.generators.control = "PV" # Need some PQ buses so that Jacobian doesn't break f = network.generators[network.generators.bus == "492"] network.generators.loc[f.index, "control"] = "PQ" # Now, perform the non-linear PF. # In[ ]: info = network.pf(); # Any failed to converge? # In[ ]: (~info.converged).any().any() # With the non-linear load flow, there is the following per unit loading of the full thermal rating. # In[ ]: (network.lines_t.p0.loc[now] / network.lines.s_nom).describe() # Let's inspect the voltage angle differences across the lines have (in degrees) # In[ ]: df = network.lines.copy() for b in ["bus0", "bus1"]: df = pd.merge( df, network.buses_t.v_ang.loc[[now]].T, how="left", left_on=b, right_index=True ) s = df[str(now) + "_x"] - df[str(now) + "_y"] (s * 180 / np.pi).describe() # Plot the reactive power # In[ ]: fig, ax = plt.subplots(subplot_kw={"projection": ccrs.EqualEarth()}, figsize=(9, 9)) q = network.buses_t.q.loc[now] bus_colors = pd.Series("r", network.buses.index) bus_colors[q < 0.0] = "b" network.plot( bus_sizes=1e-4 * abs(q), ax=ax, bus_colors=bus_colors, title="Reactive power feed-in (red=+ve, blue=-ve)", );