Microgrid with Wind, PV, battery and a dispatchable generator¶

Demo of main data structures and functions of Microgrids.py. Main steps are:

  1. Describe the Microgrid project and components
  2. Simulation the Microgrid
  3. Analyze simulation results (technical and economic)

Also, at the end of this notebook, there is an interactive simulation using ipywidgets.

In [1]:
try: # Install microgrids package in JupyterLite (if run in JupyterLite)
    import piplite
    await piplite.install(['microgrids', 'ipywidgets'])
except ImportError:
    pass
In [2]:
import numpy as np
from matplotlib import pyplot as plt

import microgrids as mgs

Load time series data¶

Read load and solar data:

  • Load: real consumption data at an hourly timestep from the Ushant island in 2016
  • Solar and wind data comes from PVGIS. See data/SOURCES.md.

Remarks:

  • do not hesitate to use your own data file(s). You need three vectors of 24×365 = 8760 points.
  • if you run this notebook from JupyterLite (i.e. the demo embedded in a web page), you can upload a data file from the file browser in the left sidebar.
In [3]:
from pathlib import Path
folder = Path('.')
datapath = folder / 'data' / 'Ouessant_data_2016.csv'
In [4]:
data = np.loadtxt(datapath, delimiter=',', skiprows=2, usecols=(1,2,4))

# Split load and solar data:
Pload = data[:,0] # kW
Ppv1k = data[:,1] / 1000; # convert to kW/kWp
wind_speed = data[:,2]; # m/s

# Calibrate wind speed data against a mast measurement 
ws_gain = 1.059 # ratio of Mast's mean /PVGIS' mean
wind_speed = ws_gain*wind_speed

Display load data

In [5]:
fig, ax = plt.subplots(1,1, figsize=(6,2.5))

td = np.arange(len(Pload))/24 # time in days
ax.plot(td, Pload, label="load")

ax.grid(True)
ax.set(
    title='Electricity consumption in Ushant island in 2016',
    ylabel='kW',
    xlabel='day of the year'
);
fig.tight_layout()
plt.show()

Generate wind power capacity factor time series from wind speed¶

We use the generic wind power curve model WindPower.capacity_from_wind to transforme wind speed time series into a capacity factor time series (normalized power). Its main parameter is the Turbine Specific Power (W/m²).

Wind turbine parameters fitted to an EWT 900 kW DW52:

In [6]:
S_D52 = np.pi * (52/2)**2 # rotor swept area m²
TSP_D52 = 900e3/S_D52 # W/m²
Cp_D52, α_D52 = 0.521, 3.1 # fitted or actual power curve.
In [7]:
cf_wind = mgs.WindPower.capacity_from_wind(wind_speed, TSP_D52, Cp_D52, 25, α_D52)
np.mean(cf_wind) # annual capacity factor
Out[7]:
0.39699594733634364

Microgrid description¶

Describe the Microgrid project and its components in data structure

Project parameters¶

Financial parameters like discount rate, as well as technical details like the timestep of input data.

In [8]:
lifetime = 25 # yr
discount_rate = 0.05
timestep = 1 # h

project = mgs.Project(lifetime, discount_rate, timestep)

Dispatchable generator (Diesel)¶

Used as last recourse when there is not enough solar production and the battery is empty

In [9]:
power_rated_gen = 1800.  # /2 to see some load shedding
fuel_intercept = 0.0 # fuel curve intercept (l/h/kW_max)
fuel_slope = 0.240 # fuel curve slope (l/h/kW)
fuel_price = 1. # fuel price ($/l)
investment_price_gen = 400. # initial investiment price ($/kW)
om_price_gen = 0.02 # operation & maintenance price ($/kW/h of operation)
lifetime_gen = 15000. # generator lifetime (h)

generator = mgs.DispatchableGenerator(power_rated_gen,
    fuel_intercept, fuel_slope, fuel_price,
    investment_price_gen, om_price_gen,
    lifetime_gen
)

Battery energy storage¶

Used as a buffer between the solar production and the consumption

In [10]:
energy_rated_sto = 5000. # rated energy capacity (kWh)
investment_price_sto = 350. # initial investiment price ($/kWh)
om_price_sto = 10. # operation and maintenance price ($/kWh/y)
lifetime_sto = 15. # calendar lifetime (y)
lifetime_cycles = 3000 # maximum number of cycles over life (1)
# Parameters with default values
charge_rate = 1.0 # max charge power for 1 kWh (kW/kWh = h^-1)
discharge_rate = 1.0 # max discharge power for 1 kWh (kW/kWh = h^-1)
loss_factor_sto = 0.05 # linear loss factor α (round-trip efficiency is about 1 − 2α) ∈ [0,1]

battery = mgs.Battery(energy_rated_sto,
    investment_price_sto, om_price_sto,
    lifetime_sto, lifetime_cycles,
    charge_rate, discharge_rate,
    loss_factor_sto)

Photovoltaic generation¶

Used in priority to feed the load. PV is proportional to the irradiance data the previous section

In [11]:
power_rated_pv = 3000. # rated power (kW)
irradiance = Ppv1k # global solar irradiance incident on the PV array (kW/m²)
investment_price_pv = 1200. # initial investiment price ($/kW)
om_price_pv = 20.# operation and maintenance price ($/kW)
lifetime_pv = 25. # lifetime (y)
# Parameters with default values
derating_factor_pv = 1.0 # derating factor (or performance ratio) ∈ [0,1]"

photovoltaic = mgs.Photovoltaic(power_rated_pv, irradiance,
    investment_price_pv, om_price_pv,
    lifetime_pv, derating_factor_pv)

Display PV production time series (which is proportional to the rated power of the plant power_rated_pv)

In [12]:
fig, ax = plt.subplots(1,1, figsize=(6,2.5))

ax.plot(td, photovoltaic.production(), "C1")

ax.grid(True)
ax.set(
    title=f'Production of a {power_rated_pv:.0f} kW PV plant in Ushant',
    ylabel='kW',
    xlabel='day of the year'
);
fig.tight_layout()
plt.show()

Wind power generation¶

Used in priority to feed the load along PV.

The simple wind power model use the fixed capacity factor model derived from wind speed in the previous section

In [13]:
power_rated_wind = 900. # rated power (kW)
investment_price_wind = 3000. # initial investiment price ($/kW)
om_price_wind = 60.# operation and maintenance price ($/kW)
lifetime_wind = 25. # lifetime (y)

windgen = mgs.WindPower(power_rated_wind, cf_wind,
    investment_price_wind, om_price_wind,
    lifetime_wind)

Display wind power time series:

In [14]:
fig, ax = plt.subplots(1,1, figsize=(6,2.5))

td = np.arange(len(Pload))/24 # time in days
ax.plot(td, windgen.production(), "C4")

ax.grid(True)
ax.set(
    title=f'Production of {power_rated_wind:.0f} kW of wind power in Ushant',
    ylabel='kW',
    xlabel='day of the year'
);
fig.tight_layout()
plt.show()

Microgrid data structure¶

the Microgrid data structure groups:

  • project parameters
  • load time series
  • all components
In [15]:
microgrid = mgs.Microgrid(project, Pload,
    generator, battery, {
        'Solar': photovoltaic,
        'Wind': windgen
    }
)

Display the microgrid structure and ratings

In [16]:
mgs.plotting.plot_ratings(microgrid, 'MW', xlim=(-3.5,2.5), ylim=(-2.5,2.5))
plt.show()

Simulate the microgrid¶

Simulation is done in two stages:

  1. simulate the operation, in particular the energy flow at an hourly timestep between components:
    • generates operation statistics as OperationStats data structure
    • records operation the trajectories of operation variables. Optional since it makes the simulation about 80% slower
  2. evaluate the economic cost of the project, based on its description and on the operation statistics:
    • generates cost data as MicrogridCosts data structure

First: Operation simulation (sim_operation)¶

Simulation without trajectories, just to get the operation statisics

In [17]:
oper_stats = mgs.sim_operation(microgrid)
In [18]:
%timeit mgs.sim_operation(microgrid) # 112 ms in JupyterLite, 21 ms with regular Jupyter/Python 3.11 kernel
27.5 ms ± 2.35 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Simulation with trajectories (about twice slower)

In [20]:
oper_traj = mgs.TrajRecorder()
oper_stats = mgs.sim_operation(microgrid, oper_traj)
In [21]:
%timeit mgs.sim_operation(microgrid, oper_traj) # 190 ms in JupyterLite, 41 ms with regular Jupyter/Python kernel
47.9 ms ± 4.41 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Second: Economic evaluation (sim_economics)¶

In [22]:
mg_costs = mgs.sim_economics(microgrid, oper_stats)
In [23]:
%timeit mgs.sim_economics(microgrid, oper_stats) # 140 µs in JupyterLite, 34 µs with regular Jupyter/Python kernel
55.3 µs ± 4.94 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)

Analyze Microgrid simulation results¶

Technical performance¶

Operation statistics are in oper_stats (OperationStats data structure)

In [24]:
print(f'Load shedding rate: {oper_stats.shed_rate:.1%}')
print(f'Renewable rate: {oper_stats.renew_rate:.1%}')
Load shedding rate: 0.0%
Renewable rate: 75.3%

The energy mix found in oper_stats can be displayed graphically:

In [25]:
mgs.plotting.plot_energy_mix(microgrid, oper_stats)
plt.show()

Economic performance¶

economic performance in mg_costs (MicrogridCosts data structure)

In [26]:
print(f'Levelized Cost of Electricity: {mg_costs.lcoe:.3f} $/kWh')
print(f'Net Present Cost: {mg_costs.npc/1e6:.2f} M$ (over {project.lifetime} years at {project.discount_rate:.0%} discount rate)')
Levelized Cost of Electricity: 0.219 $/kWh
Net Present Cost: 20.93 M$ (over 25 years at 5% discount rate)

The costs for all components and for all factors can be display in one compact table with costs_table. The lower left corner is the Net Present Cost.

In [27]:
cmat, cmat_rows, cmat_cols = mg_costs.costs_table()
print(f'Cost matrix. columns: {cmat_cols}')
print(f'  rows: {cmat_rows}')
print(np.round(cmat/1e6, 3)) # in M$
Cost matrix. columns: ['Investment', 'Replacement', 'O&M', 'Fuel', 'Salvage', 'Total by component']
  rows: ['Generator', 'Storage', 'Solar', 'Wind', 'All components']
[[ 0.72   1.946  1.679  5.659 -0.103  9.902]
 [ 1.75   0.842  0.705  0.    -0.172  3.124]
 [ 3.6    0.     0.846  0.    -0.     4.446]
 [ 2.7    0.     0.761  0.    -0.     3.461]
 [ 8.77   2.788  3.991  5.659 -0.275 20.933]]

Display operation trajectories¶

Zoom to first week of January: high load, wind at maximum, few solar → battery often empty

In [28]:
mgs.plotting.plot_oper_traj(microgrid, oper_traj)
plt.xlim(0,7) # 
plt.show()

Zoom to one week in summer: much solar → battery often full → spillage

In [29]:
mgs.plotting.plot_oper_traj(microgrid, oper_traj)
plt.xlim(150,157) 
plt.show()

Interactive simulation displays¶

Witness the effect of changing the power of the Solar & Wind plants, the Generator and Battery energy capacity (needs ipywidgets).

In [30]:
from ipywidgets import interactive, fixed

Interactive energy mix¶

In [31]:
def interactive_mg(power_rated_gen, power_rated_pv, power_rated_wind, energy_rated_sto):
    """Create Microgrid which includes Generator,
    PV plant and Battery with given ratings"""
    generator = mgs.DispatchableGenerator(power_rated_gen,
        fuel_intercept, fuel_slope, fuel_price,
        investment_price_gen, om_price_gen,
        lifetime_gen
    )
    
    battery = mgs.Battery(energy_rated_sto,
        investment_price_sto, om_price_sto,
        lifetime_sto, lifetime_cycles,
        charge_rate, discharge_rate,
        loss_factor_sto)
    
    photovoltaic = mgs.Photovoltaic(power_rated_pv, irradiance,
        investment_price_pv, om_price_pv,
        lifetime_pv, derating_factor_pv)

    windgen = mgs.WindPower(power_rated_wind, cf_wind,
        investment_price_wind, om_price_wind,
        lifetime_wind)
    
    microgrid = mgs.Microgrid(project, Pload,
        generator, battery, {
        'Solar PV': photovoltaic,
        'Wind': windgen
    }
    )
    return microgrid

from functools import lru_cache

@lru_cache(maxsize=1000)
def cached_oper_costs(power_rated_gen, power_rated_pv, power_rated_wind, energy_rated_sto):
    microgrid = interactive_mg(power_rated_gen, power_rated_pv, power_rated_wind, energy_rated_sto)
    oper_stats = mgs.sim_operation(microgrid)
    mg_costs = mgs.sim_economics(microgrid, oper_stats)
    return oper_stats, mg_costs

def interactive_energy_mix(Solar_power=0., Wind_power=0., Batt_energy=0.):
    """display energy mix with given ratings"""
    microgrid = interactive_mg(power_rated_gen, Solar_power, Wind_power, Batt_energy)
    # Simulate
    oper_stats, mg_costs = cached_oper_costs(power_rated_gen, Solar_power, Wind_power, Batt_energy)
    # Show some performance stats:
    print(f'Load shedding: {oper_stats.shed_rate:.1%}')
    print(f'Renewable: {oper_stats.renew_rate:.1%}')
    print(f'(Spilled renewable: {oper_stats.spilled_rate:.1%})')
    print(f'Levelized Cost of Electricity: {mg_costs.lcoe:.3f} $/kWh')
    
    # Display energy mix
    fig, (ax1, ax2) = plt.subplots(2,1, num=1, figsize=(6,6),
                                  gridspec_kw=dict(height_ratios=(2,1)))
    mgs.plotting.plot_ratings(microgrid, xlim=(-3.5,2.5), ylim=(-2.5,2.5), ax=ax1)
    mgs.plotting.plot_energy_mix(microgrid, oper_stats, ax=ax2)
    fig.tight_layout()
    plt.show()

Experiment starting from zero Solar power, Wind power and zero Battery:

  1. Start by first increasing Solar or Wind power alone
    • at first this reduce the usage of the Generator
    • but beyond ~2000 kW (for Solar) or 1500 kW (for Wind), there is more and more spilled energy
  2. Then, for 2000 kW of Solar and 2000 kW of Wind, increase Battery capacity to reduce spilled energy
    • at first, this reduces spilled energy and therefore reduces further generator usage
    • but beyond ~5000 kWh, it requires a higher and higher capacity to get an effect

With appropriate settings, you should find a Levelized Cost of Electricity (LCOE) below 0.20 \$/kWh (while it is 0.35 \\$/kWh without Solar, Wind and Battery)

  • You can also get a quite low LCOE with Wind or Solar (along with Storage). However, it's not as low as when Wind and Solar are used together
In [32]:
interactive(interactive_energy_mix, Solar_power=(0.0, 8e3, 500), Wind_power=(0.0, 3e3, 250), Batt_energy=(0.0, 10e3, 500))
Out[32]:
interactive(children=(FloatSlider(value=0.0, description='Solar_power', max=8000.0, step=500.0), FloatSlider(v…

Interactive trajectories¶

Experiment starting from undersize generator, zero PV and zero battery:

  • see how increasing the generator power can easily increase the quality of service (reduce load shedding, in pink)
  • by using a slightly undersized generator (1400 kW), you can still get 0 load shedding thanks to PV and battery. And now the cost of electricity can be even a bit smaller (0.188 \$/kWh) than with a full size generator (0.196 \\$/kWh for 1800 kW).
In [33]:
def interactive_trajectories(Gen_power = 500., Solar_power=0., Wind_power=0., Batt_energy=0., t_plot=60):
    """display trajectories with given ratings, zoomed at `t_plot`"""
    microgrid = interactive_mg(Gen_power, Solar_power, Wind_power, Batt_energy)
    # Simulate with trajectory recording
    oper_traj = mgs.TrajRecorder()
    oper_stats = mgs.sim_operation(microgrid, oper_traj)
    mg_costs = mgs.sim_economics(microgrid, oper_stats)
    # Show some performance stats:
    print(f'Load shedding: {oper_stats.shed_rate:.1%}')
    print(f'Renewable: {oper_stats.renew_rate:.1%}')
    print(f'(Spilled renewable: {oper_stats.spilled_rate:.1%})')
    print(f'Levelized Cost of Electricity: {mg_costs.lcoe:.3f} $/kWh')
    # Display trajectories
    mgs.plotting.plot_oper_traj(microgrid, oper_traj)
    plt.xlim(t_plot, t_plot+7)
    plt.show()

interactive(interactive_trajectories, Gen_power = (0.0, 2e3, 100),
            Solar_power=(0.0, 8e3, 500), Wind_power=(0.0, 3e3, 250), Batt_energy=(0.0, 10e3, 500),
            t_plot=(0.0, 365.-7, 1))
Out[33]:
interactive(children=(FloatSlider(value=500.0, description='Gen_power', max=2000.0, step=100.0), FloatSlider(v…