Detailed SFR Study#

In this case, we will demonstrate how to use AMS and ANDES to mimic the system secondary frequency regulation, where system automatic generation control (AGC) is used to maintain the system frequency at the nominal value.

This demo is prepared by Jinning Wang.

Reference:

    1. Wang et al., “Electric Vehicles Charging Time Constrained Deliverable Provision of Secondary Frequency Regulation,” in IEEE Transactions on Smart Grid, doi: 10.1109/TSG.2024.3356948.

  1. “Standard BAL-001-2 – Real Power Balancing Control Performance.” [Online]. Available: https://www.nerc.com/pa/Stand/Reliability%20Standards/BAL-001-2.pdf

[1]:
from itertools import chain

import numpy as np
import scipy
import pandas as pd

import ams
import andes

import datetime

import matplotlib
import matplotlib.pyplot as plt

Reset matplotlib style to default.

[2]:
matplotlib.rcdefaults()

Ensure in-line plots.

[3]:
%matplotlib inline
[4]:
print("Last run time:", datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))

print(f'andes:{andes.__version__}')
print(f'ams:{ams.__version__}')
Last run time: 2024-04-21 17:31:10
andes:1.9.1
ams:0.9.6
[5]:
andes.config_logger(stream_level=40)
[6]:
ams.config_logger(stream_level=30)

Dispatch case#

We use the IEEE 39-bus system as an example.

[7]:
sp = ams.load(ams.get_case('ieee39/ieee39_uced.xlsx'),
              setup=True,
              no_output=True,)

In RTED documentation, we can see that Var rgu and rgd are the variables for RegUp/Dn reserve, and Constraint rbu and rbd are the equality constraints for RegUp/Dn reserve balance.

As for the RegUp/Dn reserve requirements, it is defined by parameter du and dd as percentage of the total load, and later dud and ddd are the actual reserve requirements.

[8]:
sp.RTED.dud.v
[8]:
array([2.34256, 0.     ])
[9]:
sp.RTED.du.v
[9]:
array([0.05, 0.05])

Dynamic case#

[10]:
sa = sp.to_andes(addfile=andes.get_case('ieee39/ieee39_full.xlsx'),
                 setup=True,
                 no_output=True,
                 default_config=True,
                 )
Generating code for 2 models on 8 processes.
Following PFlow models in addfile will be overwritten: <Bus>, <PQ>, <PV>, <Slack>, <Shunt>, <Line>, <Area>
/Users/jinningwang/Documents/work/mambaforge/envs/amsre/lib/python3.9/site-packages/ams/interop/andes.py:933: FutureWarning: Downcasting object dtype arrays on .fillna, .ffill, .bfill is deprecated and will change in a future version. Call result.infer_objects(copy=False) instead. To opt-in to the future behavior, set `pd.set_option('future.no_silent_downcasting', True)`
  ssa_key0 = ssa_key0.fillna(value=False)
AMS system 0x1440dab80 is linked to the ANDES system 0x106363c10.

Device ACEc is used to calculate the Area Control Error (ACE).

[11]:
sa.ACEc.as_df()
[11]:
idx u name bus bias busf
uid
0 1 1.0 ACE_1 1 300.0 BusFreq_2

Synthetic load#

ISO-NE provides various grid data, such as Five-Minute System Demand. In this example, we revise the March 02, 2024, 18 PM data.

[12]:
load_isone = np.array([
    11920.071, 11980.979, 12000.579, 12145.243, 12211.862, 12220.703,
    12191.051, 12241.546, 12285.719, 12312.626, 12364.102, 12336.354
])

# Normalize the load
load_min = load_isone.min()
load_max = load_isone.max()
load_mid = (load_max + load_min) / 2  # Midpoint of the range
# Set to desired range
load_range = 0.8 + 0.01 * ((load_isone - load_mid) / (load_max - load_min))

load_scale_base = np.repeat(load_range, 300)
# smooth load_scale_base
load_scale_smooth = scipy.signal.savgol_filter(load_scale_base, 600, 4)

np.random.seed(2024)  # Set random seed for reproducibility
random_bias = np.random.normal(loc=0, scale=0.0015,
                               size=len(load_scale_smooth))
random_smooth = scipy.signal.savgol_filter(random_bias, 20, 1)

# Add noise to the load as random ACE
load_coeff_base = load_scale_smooth + random_smooth
# smooth
load_coeff = scipy.signal.savgol_filter(load_coeff_base, 10, 2)

# NOTE: force the first 2 points to be the same as first interval average
load_coeff[0:2] = load_coeff[0:300].mean()

# average load every N points, for RTED dispatch
load_coeff_avg = load_coeff.reshape(-1, 300).mean(axis=1)
load_coeff_avg = np.repeat(load_coeff_avg, 300)

fig_load, ax_load = plt.subplots(figsize=(5, 3), dpi=100)

ax_load.plot(range(len(load_coeff)), load_coeff,
             label='Load Coefficient')
ax_load.plot(range(len(load_coeff_avg)), load_coeff_avg,
             label='Load Average')

ax_load.set_xlim([0, 3600])
ax_load.set_xlabel('Time [s]')
ax_load.set_ylabel('Ratio')
ax_load.set_title('Load Curve')
ax_load.grid(True)
ax_load.legend()
[12]:
<matplotlib.legend.Legend at 0x295838430>
../../_images/_examples_demo_demo_AGC_20_1.png

Co-simulation#

Define constants#

Here we assume the AGC interval is 4 seconds, and RTED interval is 300 seconds.

Between the interoperation of AMS and ANDES, there is a AC conversion step to convert the DC-based dispatch resutls to AC-based power flow results. For more details, check the reference paper and AMS source code - dc2ac.

AGC controller#

Since there is not an built-in AGC controller in ANDES, we can define a PI controller to calculate the control signal for the AGC:

AGC_raw = kp * ACE + ki * integral(ACE)

Note that, in the AGC interval, there is a cap operation to limit the AGC signal within procured reserve limits.

ANDES settings#

ANDES load needs to be set to constant load for effective load change.

[13]:
# --- time constants ---
total_time = 610

RTED_interval = 300
AGC_interval = 4

id_rted = -1  # RTED interval counter
id_agc = -1  # AGC interval counter

# --- AGC controller ---
kp = 0.1
ki = 0.05

ACE_integral = 0
ACE_raw = 0

# --- initialize output ---
# pd_andes: total load in ANDES
# pd_ams: total load in AMS; pg_ams: total generation in AMS
# pru_ams: total RegUp in AMS; prd_ams: total RegDn in AMS
out_cols = ['time', 'freq', 'ACE', 'AGC',
            'pd_andes', 'pd_ams', 'pg_ams',
            'pru_ams', 'prd_ams']
outdf = pd.DataFrame(data=np.zeros((total_time, len(out_cols))),
                     columns=out_cols)

# --- AMS settings ---
sp.SFR.set(src='du', attr='v', idx=sp.SFR.idx.v, value=0.0015*np.ones(sp.SFR.n))
sp.SFR.set(src='dd', attr='v', idx=sp.SFR.idx.v, value=0.0015*np.ones(sp.SFR.n))

# --- ANDES settings ---
sa.TDS.config.no_tqdm = True  # turn off ANDES progress bar
sa.TDS.config.criteria = 0  # turn off ANDES criteria check

# adjsut ANDES TDS settings to save memory
sa.TDS.config.save_every = 0

# adjust dynamic parameters
# NOTE: might run into error if there exists a TurbineGov model that does not have "VMAX"
tbgov_src = [mdl.idx.v for mdl in sa.TurbineGov.models.values()]
tbgov_idx = list(chain.from_iterable(tbgov_src))
sa.TurbineGov.set(src='VMAX', attr='v', idx=tbgov_idx,
                  value=9999 * np.ones(sa.TurbineGov.n),)
sa.TurbineGov.set(src='VMIN', attr='v', idx=tbgov_idx,
                  value=np.zeros(sa.TurbineGov.n),)
syg_src = [mdl.idx.v for mdl in sa.SynGen.models.values()]
syg_idx = list(chain.from_iterable(syg_src))
sa.SynGen.set(src='ra', attr='v', idx=syg_idx,
              value=np.zeros(sa.SynGen.n),)

# use constant power model for PQ
sa.PQ.config.p2p = 1
sa.PQ.config.q2q = 1
sa.PQ.config.p2z = 0
sa.PQ.config.q2z = 0
sa.PQ.pq2z = 0

# save the initial load values
p0_sp = sp.PQ.p0.v.copy()
q0_sp = sp.PQ.q0.v.copy()
p0_sa = sa.PQ.p0.v.copy()
q0_sa = sa.PQ.q0.v.copy()

# --- Co-Sim Variables ---
# save device index
pq_idx = sp.PQ.idx.v  # PQ index

# get a copy of link table to calculate AGC power
# pd.set_option('future.no_silent_downcasting', True)  # pandas setting
maptab = sp.dyn.link.copy().fillna(False)

# existence of each type of generator
maptab['has_gov'] = maptab['gov_idx'].fillna(0, inplace=False).astype(bool).astype(int)
maptab['has_dg'] = maptab['dg_idx'].fillna(0, inplace=False).astype(bool).astype(int)
maptab['has_rg'] = maptab['rg_idx'].fillna(0, inplace=False).astype(bool).astype(int)

# initialize columns for power output
# pg: StaticGen power reference; pru: RegUp power; prd: RegDn power
# pgov: TurbineGov power; prg: RenGen power; pdg: DG power
# bu: RegUp participation factor; bd: RegDown participation factor
# agov: TurbineGov AGC power; adg: DG AGC power; arg: RenGen AGC power

add_cols = ['pg', 'pru', 'prd', 'pgov',
            'prg', 'pdg', 'bu', 'bd',
            'agov', 'adg', 'arg']
maptab[add_cols] = 0

# output data of each unit's AGC power
# aout: delivered AGC power; aref: AGC reference
agc_cols = list(maptab['stg_idx'])
agc_ref = pd.DataFrame(data=np.zeros((total_time, len(list(['time']) + agc_cols))),
                       columns=list(['time']) + agc_cols)
agc_out = pd.DataFrame(data=np.zeros((total_time, len(list(['time']) + agc_cols))),
                       columns=list(['time']) + agc_cols)

# output data of each dispatch interval's results
gen_cols = list(maptab['stg_idx'])
n_dispatch = np.ceil(total_time / RTED_interval).astype(int)
pg_ref = pd.DataFrame(data=np.zeros((n_dispatch, len(list(['n_rted']) + gen_cols))),
                      columns=list(['n_rted']) + gen_cols)
pru_ref = pd.DataFrame(data=np.zeros((n_dispatch, len(list(['n_rted']) + gen_cols))),
                       columns=list(['n_rted']) + gen_cols)
prd_ref = pd.DataFrame(data=np.zeros((n_dispatch, len(list(['n_rted']) + gen_cols))),
                       columns=list(['n_rted']) + gen_cols)
/var/folders/06/z8ws9b2d733f7h6yc5qpn22w0000gn/T/ipykernel_10319/1576960110.py:70: FutureWarning: Downcasting object dtype arrays on .fillna, .ffill, .bfill is deprecated and will change in a future version. Call result.infer_objects(copy=False) instead. To opt-in to the future behavior, set `pd.set_option('future.no_silent_downcasting', True)`
  maptab = sp.dyn.link.copy().fillna(False)

Main loop#

[14]:
for t in range(0, total_time, 1):
    # --- Wathdog ---
    if (t % 200 == 0) and (t > 0):
        print(f"--Watchdog: t={t} sec.")

    # --- Dispatch interval ---
    if t % RTED_interval == 0:
        id_rted += 1  # update RTED interval counter
        id_agc = -1   # reset AGC interval counter
        print(f"====== RTED Interval <{id_rted}> ======")
        # use 5-min average load in dispatch solution
        load_avg = load_coeff[t:t+RTED_interval].mean()
        # set load in to AMS
        sp.PQ.set(src='p0', attr='v',
                  value=load_avg * p0_sp,
                  idx=pq_idx)
        sp.PQ.set(src='q0', attr='v',
                  value=load_avg * q0_sp,
                  idx=pq_idx)
        print(f"--AMS: update disaptch load with factor {load_avg:.6f}.")

        # get dynamic generator output from TDS
        if t > 0:
            _receive = sp.dyn.receive(adsys=sa, routine='RTED', no_update=True)
            if _receive:
                print("--AMS: received data from ANDES.")

        # update RTED parameters
        sp.RTED.update()
        # run RTED
        sp.RTED.run(solver='ECOS')
        # convert to AC
        flag_2ac = sp.RTED.dc2ac(kloss=1.02 if id_rted == 0 else 1)
        if flag_2ac:
            print(f"--AMS: AC conversion successful.")
        else:
            print(f"ERROR! AC conversion failed!")
            break

        if sp.RTED.exit_code == 0:
            print(f"--AMS: {sp.recent.class_name} optimized.")

            # update in mapping table
            maptab['pg'] = sp.RTED.get(src='pg', attr='v', idx=maptab['stg_idx'])
            maptab['pru'] = sp.RTED.get(src='pru', attr='v', idx=maptab['stg_idx'])
            maptab['prd'] = sp.RTED.get(src='prd', attr='v', idx=maptab['stg_idx'])
            maptab['bu'] = maptab['pru'] / maptab['pru'].sum()
            maptab['bd'] = maptab['prd'] / maptab['prd'].sum()

            # calculate power reference for dynamic generator
            maptab['pgov'] = maptab['pg'] * maptab['has_gov'] * maptab['gammap']
            maptab['pdg'] = maptab['pg'] * maptab['has_dg'] * maptab['gammap']
            maptab['prg'] = maptab['pg'] * maptab['has_rg'] * maptab['gammap']

            # set into governor, Exclude NaN values for governor index
            gov_to_set = {gov: pgov for gov, pgov in zip(maptab['gov_idx'], maptab['pgov']) if bool(gov)}
            sa.TurbineGov.set(src='pref0', attr='v',
                              idx=list(gov_to_set.keys()),
                              value=list(gov_to_set.values()))
            print(f"--ANDES: update TurbineGov reference.")

            # set into dg, Exclude NaN values for dg index
            dg_to_set = {dg: pdg for dg, pdg in zip(maptab['dg_idx'], maptab['pdg']) if bool(dg)}
            sa.DG.set(src='pref0', attr='v',
                      idx=list(dg_to_set.keys()),
                      value=list(dg_to_set.values()))
            print(f"--ANDES: update DG reference.")

            # set into rg, Exclude NaN values for rg index
            rg_to_set = {rg: prg for rg, prg in zip(maptab['rg_idx'], maptab['prg']) if bool(rg)}
            sa.RenGen.set(src='Pref', attr='v',
                          idx=list(rg_to_set.keys()),
                          value=list(rg_to_set.values()))
            print(f"--ANDES: update RenGen reference.")

            # record dispatch data
            pg_ref.loc[id_rted, 'n_rted'] = id_rted
            pg_ref.loc[id_rted, gen_cols] = maptab['pg'].values
            pru_ref.loc[id_rted, 'n_rted'] = id_rted
            pru_ref.loc[id_rted, gen_cols] = maptab['pru'].values
            prd_ref.loc[id_rted, 'n_rted'] = id_rted
            prd_ref.loc[id_rted, gen_cols] = maptab['prd'].values
        else:
            print(f"ERROR! {sp.recent.class_name} failed: {sp.RTED.om.prob.status}")
            break

    # --- AGC interval ---
    if t % AGC_interval == 0:
        id_agc += 1  # update AGC interval counter
        # cap ACE_raw with procured capacity as AGC response
        if ACE_raw >= 0:  # RegUp
            ACE_input = min([ACE_raw, maptab['pru'].sum()])
            b_factor = maptab['bu']
        else:            # RegDn
            ACE_input = -min([-ACE_raw, maptab['prd'].sum()])
            b_factor = maptab['bd']
        outdf.loc[t:t+AGC_interval, 'AGC'] = ACE_input

        maptab['agov'] = ACE_input * b_factor * maptab['has_gov'] * maptab['gammap']
        maptab['adg'] = ACE_input * b_factor * maptab['has_dg'] * maptab['gammap']
        maptab['arg'] = ACE_input * b_factor * maptab['has_rg'] * maptab['gammap']

        # set into governor, Exclude NaN values for governor index
        agov_to_set = {gov: agov for gov, agov in zip(maptab['gov_idx'], maptab['agov']) if bool(gov)}
        sa.TurbineGov.set(src='paux0', attr='v',
                          idx=list(agov_to_set.keys()),
                          value=list(agov_to_set.values()))

        # set into dg, Exclude NaN values for dg index
        adg_to_set = {dg: adg for dg, adg in zip(maptab['dg_idx'], maptab['adg']) if bool(dg)}
        sa.DG.set(src='Pext0', attr='v',
                  idx=list(adg_to_set.keys()),
                  value=list(adg_to_set.values()))

        # set into rg, Exclude NaN values for rg index
        arg_to_set = {rg: arg + prg for rg, arg,
                      prg in zip(maptab['rg_idx'], maptab['arg'], maptab['prg']) if bool(rg)}
        sa.RenGen.set(src='Pref', attr='v',
                      idx=list(arg_to_set.keys()),
                      value=list(arg_to_set.values()))

    # --- TDS interval ---
    if t > 0:  # --- run TDS ---
        # set laod into PQ.Ppf and PQ.Qpf
        sa.PQ.set(src='Ppf', attr='v', idx=pq_idx,
                  value=load_coeff[t] * p0_sa)
        sa.PQ.set(src='Qpf', attr='v', idx=pq_idx,
                  value=load_coeff[t] * q0_sa)
        sa.TDS.config.tf = t
        sa.TDS.run()
        # Update AGC PI controller
        ACE_raw = -(kp * sa.ACEc.ace.v.sum() + ki * ACE_integral)
        ACE_integral = ACE_integral + sa.ACEc.ace.v.sum()

        # record AGC data
        # agc reference
        agc_ref.loc[t, 'time'] = t
        agc_ref.loc[t, agc_cols] = maptab[['agov', 'adg', 'arg']].sum(axis=1).values
        # delivered AGC power
        sp.dyn.receive(adsys=sa, routine='RTED', no_update=True)
        pout = sp.recent.get(src='pg0', attr='v', idx=maptab['stg_idx'])
        pref = sp.recent.get(src='pg', attr='v', idx=maptab['stg_idx'])
        # agc output is the difference between output power and scheduled power
        agc_out.loc[t, 'time'] = t
        agc_out.loc[t, agc_cols] = pout - pref

        # check if to continue
        if sa.exit_code != 0:
            print(f"ERROR! t={t}, TDS error: {sa.exit_code}")
            break
    else:  # --- init TDS ---
        # set pg to StaticGen.p0
        sa.StaticGen.set(src='p0', attr='v', value=sp.RTED.pg.v,
                         idx=sp.RTED.pg.get_idx())
        # set Bus.v to StaticGen.v
        bus_stg = sp.StaticGen.get(src='bus', attr='v', idx=sp.StaticGen.get_idx())
        v_stg = sp.Bus.get(src='v', attr='v', idx=bus_stg)
        sa.StaticGen.set(src='v0', attr='v', value=v_stg,
                         idx=sp.StaticGen.get_idx())
        # set vBus to Bus
        sa.Bus.set(src='v0', attr='v',
                   value=sp.RTED.vBus.v,
                   idx=sp.RTED.vBus.get_idx())
        # set load into PQ.p0 and PQ.q0
        sa.PQ.set(src='p0', attr='v', idx=pq_idx,
                  value=load_coeff[t] * p0_sa)
        sa.PQ.set(src='q0', attr='v', idx=pq_idx,
                  value=load_coeff[t] * q0_sa)
        sa.PFlow.run()  # run power flow
        sa.TDS.init()  # initialize TDS

        if sa.exit_code != 0:
            print(f"ERROR! t={t}, TDS init error: {sa.exit_code}")
            break
        print(f"--ANDES: TDS initialized.")

    # --- record output ---
    outdf.loc[t, 'time'] = t
    outdf.loc[t, 'freq'] = sa.BusFreq.f.v[1]
    outdf.loc[t, 'ACE'] = sa.ACEc.ace.v.sum()
    outdf.loc[t, 'pd_andes'] = sa.PQ.Ppf.v.sum()
    outdf.loc[t, 'pd_ams'] = sp.RTED.pd.v.sum()
    outdf.loc[t, 'pg_ams'] = sp.RTED.pg.v.sum()
    outdf.loc[t, 'pru_ams'] = sp.RTED.pru.v.sum()
    outdf.loc[t, 'prd_ams'] = sp.RTED.prd.v.sum()

# crop the output with valid time
if t < total_time - 1:  # end early, means some error happened
    outdf = outdf[0:t-1]
<RTED> reinit OModel due to non-parametric change.
<RTED> solved as optimal in 0.0295 seconds, converged in 12 iterations with ECOS.
====== RTED Interval <0> ======
--AMS: update disaptch load with factor 0.795040.
<RTED> converted to AC.
--AMS: AC conversion successful.
--AMS: RTED optimized.
--ANDES: update TurbineGov reference.
--ANDES: update DG reference.
--ANDES: update RenGen reference.
--ANDES: TDS initialized.
--Watchdog: t=200 sec.
<RTED> reinit OModel due to non-parametric change.
<RTED> solved as optimal in 0.0158 seconds, converged in 11 iterations with ECOS.
====== RTED Interval <1> ======
--AMS: update disaptch load with factor 0.796370.
--AMS: received data from ANDES.
<RTED> converted to AC.
--AMS: AC conversion successful.
--AMS: RTED optimized.
--ANDES: update TurbineGov reference.
--ANDES: update DG reference.
--ANDES: update RenGen reference.
--Watchdog: t=400 sec.
<RTED> reinit OModel due to non-parametric change.
<RTED> solved as optimal in 0.0155 seconds, converged in 12 iterations with ECOS.
--Watchdog: t=600 sec.
====== RTED Interval <2> ======
--AMS: update disaptch load with factor 0.797038.
--AMS: received data from ANDES.
<RTED> converted to AC.
--AMS: AC conversion successful.
--AMS: RTED optimized.
--ANDES: update TurbineGov reference.
--ANDES: update DG reference.
--ANDES: update RenGen reference.

Results#

Data processing#

Scale to nominal value

[15]:
outdf_plt = outdf.copy()
agc_out_plt = agc_out.copy()
agc_ref_plt = agc_ref.copy()

pg_ref_plt = pg_ref.copy()
pru_ref_plt = pru_ref.copy()
prd_ref_plt = prd_ref.copy()

# scale to nominal values
outdf_plt['freq'] *= sa.config.freq
mva_cols = ['pd_andes', 'pd_ams', 'pg_ams',
            'pru_ams', 'prd_ams',
            'ACE', 'AGC']
outdf_plt['prd_ams'] *= -1
outdf_plt[mva_cols] *= sa.config.mva

agc_out_plt[agc_cols] *= sa.config.mva
agc_ref_plt[agc_cols] *= sa.config.mva

pg_ref_plt[gen_cols] *= sa.config.mva
pru_ref_plt[gen_cols] *= sa.config.mva
prd_ref_plt[gen_cols] *= sa.config.mva

# calculate frequency deviation
outdf_plt['fd'] = outdf_plt['freq'] - sa.config.freq

Dispatch results

[16]:
print('Dispatch generation')
print(pg_ref_plt)

print('Dispatch RegUp')
print(pru_ref_plt)

print('Dispatch RegDn')
print(prd_ref_plt)
Dispatch generation
   n_rted    Slack_39       PV_38       PV_37       PV_36       PV_35  \
0     0.0  386.970704  383.193198  380.621216  380.820996  382.533818
1     1.0  379.935381  376.269518  373.771154  373.934511  375.594240
2     2.0  380.257658  376.586455  374.084835  374.249840  375.911982

        PV_34       PV_33       PV_32       PV_31       PV_30
0  367.937512  379.728940  385.417788  387.455489  385.588322
1  361.574193  372.866232  378.381729  380.365036  378.582261
2  361.865837  373.180481  378.703752  380.689570  378.903137
Dispatch RegUp
   n_rted  Slack_39     PV_38     PV_37     PV_36     PV_35     PV_34  \
0     0.0  0.554354  0.549378  0.563513  0.571486  0.552794  0.567007
1     1.0  0.163473  0.376354  0.693599  0.765382  0.549332  0.707017
2     2.0  0.594354  0.567046  0.553054  0.557508  0.552482  0.553314

      PV_33     PV_32     PV_31     PV_30
0  0.573501  0.555676  0.550156  0.549425
1  0.781391  0.606215  0.505533  0.448340
2  0.558781  0.551451  0.554458  0.558878
Dispatch RegDn
   n_rted  Slack_39     PV_38     PV_37     PV_36     PV_35     PV_34  \
0     0.0  0.561130  0.564587  0.557659  0.558443  0.560408  0.562190
1     1.0  0.536721  0.457561  0.544993  0.625560  0.516914  0.680585
2     2.0  0.574896  0.582924  0.561022  0.548543  0.569388  0.551558

      PV_33     PV_32     PV_31     PV_30
0  0.557014  0.554632  0.548500  0.562725
1  0.611784  0.567777  0.565069  0.489670
2  0.547962  0.549328  0.539225  0.576479

AGC mileage in MWh

[17]:
agc_row_index = np.arange(0, total_time, AGC_interval)
agc_mileage = agc_out_plt.loc[agc_row_index, agc_cols].diff().abs().sum(axis=0) * AGC_interval / 3600

print(f"AGC milage in MWh:\n{agc_mileage}")
AGC milage in MWh:
Slack_39    0.193312
PV_38       0.018992
PV_37       0.014307
PV_36       0.014498
PV_35       0.013894
PV_34       0.012660
PV_33       0.015538
PV_32       0.013946
PV_31       0.014138
PV_30       0.014456
dtype: float64

CPS1 score

Following adjustments are made to calculate the CPS1 score: 1. eps is the constant derived from a targeted frequency bound, where we use the average frequency deviation as the reference 1. The \(CF_{clock-minute}\) is used when calcualting \(CF\) rather than \(CF_{12-month}\)

[18]:
def cm_avg(x):
    """
    Clock minute average

    Parameters
    ----------
    x : pd.Series
        Input time series, index is time in seconds.

    Returns
    -------
    float
        Clock minute average of the time series.
    """
    return np.sum(x) / len(x)

eps = outdf_plt['fd'].mean()
bias = sa.ACEc.bias.v[0]

df_cm = cm_avg(outdf_plt['fd'])
ace_cm = cm_avg(outdf_plt['ACE'] / (10 * bias))
cf_cm = df_cm * ace_cm
cf = cf_cm / np.square(eps)
cps1 = (2 - cf) * 100

print(f"CPS1 score: {cps1}")
CPS1 score: 99.99774733891043

Plot#

System dynamics

[19]:
fig, ax = plt.subplots(2, 2, figsize=(10, 10), dpi=100)

outdf_plt.plot(x='time', y='pd_andes', ax=ax[0, 0],
               title='Total Load [MW]',
               xlim=[0, total_time],
               legend=True, label='Load ANDES')
outdf_plt.plot(x='time', y='pd_ams', ax=ax[0, 0],
               legend=True, label='Load AMS')
outdf_plt.plot(x='time', y='pg_ams', ax=ax[0, 0],
               grid=True,
               legend=True, label='Gen AMS',
               xlabel='Time [s]')

outdf_plt.plot(x='time', y='freq', ax=ax[0, 1],
               title='Frequency [Hz]', grid=True,
               xlim=[0, total_time], legend=False,
               xlabel='Time [s]')

outdf_plt.plot(x='time', y='ACE', ax=ax[1, 0],
               title='ACE [MW]', grid=True,
               xlim=[0, total_time], legend=False,
               xlabel='Time [s]')

outdf_plt.plot(x='time', y='AGC', ax=ax[1, 1],
               title='AGC Power [MW]',
               xlim=[0, total_time],
               legend=True, label='AGC',
               xlabel='Time [s]')
outdf_plt.plot(x='time', y='pru_ams', ax=ax[1, 1],
               legend=True, label='RegUp',
               style='--', color='black')
outdf_plt.plot(x='time', y='prd_ams', ax=ax[1, 1],
               grid=True,
               legend=True, label='RegDn',
               style='--', color='black')
ax[1, 1].legend(loc='upper right')
[19]:
<matplotlib.legend.Legend at 0x29633f340>
../../_images/_examples_demo_demo_AGC_37_1.png

Frequency regulation performance

[20]:
fig_freq, ax_freq = plt.subplots(1, 2, figsize=(10, 5), dpi=100)

outdf_plt.plot(x='time', y='fd', kind='hist', alpha=1, bins=60, density=True,
               ax=ax_freq[0], xlabel='Frequency Deviation [Hz]', ylabel='Density',
               legend=False)
outdf_plt.plot(x='time', y='ACE', kind='hist', alpha=1, bins=60, density=True,
               ax=ax_freq[1], xlabel='ACE [MW]', ylabel='Density',
               legend=False)
[20]:
<Axes: xlabel='ACE [MW]', ylabel='Density'>
../../_images/_examples_demo_demo_AGC_39_1.png

Settings to Improve Performance#

Long-term dynamic simulation can be memory-consuming, as time-series data is updated by default. To reduce the memory burden, we can configure the TDS with save_every=0, discarding all data immediately after each simulation step. As a trade-off, a separate output array is utilized to store the data, with a resolution matching the co-simulation time step. More details about ANDES settings can be found in the ANDES Release notes - v1.7.0.

The case has been tested with a complete 3600s duration. However, for demonstration purposes and to conserve CI resources, the simulated time is truncated.

Limitations#

  1. Although the code is designed for generalization, the demo is implemented on the IEEE 39-bus case with generators set to synchronous machines, and its application to other cases is not fully tested.

  2. The load curve is synthetic, based on experience.

  3. Within each interval, generator setpoints are updated only once, without considering smooth action.

  4. In ANDES, certain dynamic parameters are adjusted to facilitate co-simulation, disregarding their actual physical implications.

  5. The used case comprises synchronous generators exclusively, necessitating further adaptation for the inclusion of renewable energy sources.

FAQ#

Q: Why ANDES TDS run into error?

A: Most likely, the error is due to power flow not converging. Possible reasons include: 1) load is too heavy, 2) step change is too large, 3) some devices run into limits.

Q: Why in AMS RTED, load and generation do not exactly match?

A: The RTED is converted using dc2ac, where the generation and bus voltage are adjusted using ACOPF.