◀ Back
op_code.py

Implementation of dual optimisation and solution recovery strategies. Download.

""" IIB Project: SDP relaxation dual optimisation and solution recovery method implementation. """

import sys
import pickle

import datetime
import time

import numpy as np
import scipy as sp
import cvxpy as cp

import matplotlib.pyplot as plt

sys.path.append('../') # add parent folder to Python path to allow import
from Datasets.Networks.test_case import network_structure, node_data # import network definition
from Datasets.Networks.haversine import haversine # custom implementation of Haversine function



# =============================================================================
# Grab power data and initialise required objects
# =============================================================================
# grab list of nodes
nodes = list(node_data.keys()) # this list defines the node indexing (i.e. the first is node 0, etc.)
N = len(nodes) # number of nodes in the system

# import node power data
fpath = '../Datasets/Analysis Datasets/test_case_2020-10-17.pkl'
with open(fpath, 'rb') as pfile: # read byte pickle file
    power_data = pickle.load(pfile)

# create datetime objects of base samples for plotting
base_dt_objs = [datetime.datetime.strptime(d, '%Y-%m-%d %H:%M:%S') for d in power_data['timestamps']]

# specify initial fullness constants
alphas = 0.8*np.ones(N,dtype=float)

# define generation installed capacities and demand magnitude
wind_caps = {'lond':5, 'camb':10, 'bris':2.5, 'south':5, 'birm':0,
              'liv':5, 'leed':0, 'newc':10, 'edin':10, 'inv':2.5} # wind installed capacities - GW
total_wind_cap = sum(wind_caps.values())
solar_caps = {'lond':2.5, 'camb':2.5, 'bris':3, 'south':5, 'birm':4,
              'liv':1, 'leed':1, 'newc':1, 'edin':0, 'inv':0} # solar installed capacities - GW
total_solar_cap = sum(solar_caps.values())
assert [n for n in list(node_data.keys()) if node_data[n]['wind'] == [None]] == [n for n in list(wind_caps.keys()) if wind_caps[n] == 0]
peak_demand = 65 # peak national demand power - GW



# =============================================================================
# Produce node power deficit time series for analysis time window
# =============================================================================
start_time = '2018-03-01 09:00:00' # str time of start of analysis window
tstart_index = base_dt_objs.index(datetime.datetime.strptime(start_time, '%Y-%m-%d %H:%M:%S')) # index of window start
T = 6 # duration of window in time steps (of 1hr)

node_power_defs = np.zeros((N,T)) # initialise array to store node power deficit times series, (Pd - Pg)[t]
# time series are rows of the matrix, with the row index corresponding to the node number (row length = T)

# compute real power deficit series
for i in range(N):
    n = nodes[i]
    node_power_defs[i] += power_data[n]['demand'][tstart_index:tstart_index+T]*peak_demand
    node_power_defs[i] -= power_data[n]['wind'][tstart_index:tstart_index+T]*wind_caps[n]
    node_power_defs[i] -= power_data[n]['solar'][tstart_index:tstart_index+T]*solar_caps[n]

# if we are performing the analysis over a long duration, e.g. a year, at this stage we may
# wish to check the net energy deficit for the network over the time window
net_energy_deficit = node_power_defs.sum() # net energy deficit over time window - GWh

node_power_defs *= 1000 # convert powers to MW

# Note:
# The consistent numerical unit set to be used is (MVA,kV,kA,Ohms - hrs,MWh)

network_power_defs = np.sum(node_power_defs, axis=0) # net power deficit over network at each time instance - MWh



# =============================================================================
# Define parameters, matrices, and constraints required for the optimisation
# =============================================================================
Smax = 9e3 # line apparent power limit - MVA
Smax2 = Smax**2 # defined Smax-squared for convenience
# note, as some point we may wish to move to Smax being defined for each line

# for network node voltages, assume 400kV transmission level with \pm 10% operation tolerance
Vmin = 360 # minimum allowable node voltage - kV
Vmin2 = Vmin**2 # define Vmin-squared for convenience
Vmax = 440 # maximum allowable node voltage - kV
Vmax2 = Vmax**2 # define Vmax-squared for convenience

eta = 1 # round-trip efficiency of storage
dt = 1 # time increment - hrs
storage_flow_factor = np.sqrt(eta)/dt # define multiplier sqrt(eta)/dt

re_line_imp = 0.025 # real line impedance per unit length - Ohms/km
im_line_imp = 0.21 # imaginary line impedance per unit length - Ohms/km
pl_line_imp = re_line_imp + im_line_imp*1j # per unit length line impedance - Ohms/km


# construct the admittance matrix and set of lines
Y = np.zeros((N,N),dtype=complex) # initialise array for admittance matrix
lines = [] # initialise list to store connection node tuples
for k in range(N):
    n = nodes[k] # base node
    # note, in the model y_kk = 0, i.e. zero admittance-to-ground for the buses
    for l in range(N):
        m = nodes[l] # potentially connected node

        if m in network_structure[n]['cons']: # m connected to n
            lines.append((k,l)) # add connection to list of lines
            # compute distance between the nodes - km
            nm_dist = haversine(network_structure[n]['location'], network_structure[m]['location'])

            z_nm = nm_dist*pl_line_imp # complex impedance of line n -> m - Ohms
            y_nm = 1/z_nm # complex admittance of line n -> m - Siemens

            Y[k,k] += y_nm # add term to Y[k,k] sum
            Y[k,l] = -1*y_nm # set Y[k,l]

L = len(lines) # number of (directed) connections in the network
unique_lines = [p for p in lines if p[0]> 0]

# PSD constraints on A(lambda,R,t)
As = [] # initialise list to store A(lambda,R,t) matrices for later use
for t in range(T):
    A = 0 # initialise A(lambda,R,t)

    # construct A(lambda,R,t) matrix
    for n in range(N): # for each node
        A += lambda_P[n,t]*bYks[n] + (lambda_VU[n,t] - lambda_VL[n,t])*Mks[n]

    for l,m in lines: # for each line
        # note r^2 = R[0,1], r^3 = R[0,2]
        A += 2*Rs[(l,m,t)][0,1]*bYlms[l,m] + 2*Rs[(l,m,t)][0,2]*bYbarlms[l,m]

    constraints += [A >> 0]
    As.append(A) # save A matrix for later observation

# KKT stationarity conditions/constraints for S_n
for n in range(N): # for each node
    c = 1 - lambda_S[n] - alphas[n]*lambda_P[n,0]*storage_flow_factor
    # note lambda_P is zero indexed in t, so this is indeed using
    # lamdba_P(n,t=1) from the formulation expression
    for t in range(T):
        c += -1*lambda_eU[n,t]

    constraints += [c == 0]

# KKT stationarity conditions/constraints for e_n[t]
for n in range(N): # for each node
    for t in range(T-1): # for non-terminal time instances
        c = storage_flow_factor*(lambda_P[n,t] - lambda_P[n,t+1]) + lambda_eU[n,t] - lambda_eL[n,t]
        constraints += [c == 0]

    # for the terminal time instance
    c = storage_flow_factor*lambda_P[n,T-1] + lambda_eU[n,T-1] - lambda_eL[n,T-1]
    constraints += [c == 0]


# epsilon-modification constraints - 2-norm chosen as vector norm to retain convexity in a simple manner
# epsilon = 1e-9 # modification parameter
# recepsilon = 1/epsilon
# for t in range(T): # for each time instance
#     for n in range(N): # for each node
#         lambda_comb = cp.vstack([lambda_P[n,t],lambda_VU[n,t],lambda_VL[n,t],lambda_eU[n,t],lambda_eL[n,t]])
#         constraints += [cp.norm(lambda_comb,2) <= recepsilon]

#         #constraints += [epsilon <= lambda_P[n,t], lambda_P[n,t] <= recepsilon]

#     for l,m in lines: # for each line
#         r_comb = cp.vec(Rs[(l,m,t)])
#         constraints += [cp.norm(r_comb,2) <= recepsilon]
    


# Create dual problem objective function
# =============================================================================
obj = 0 # initialise objective

for t in range(T): # for each time instance

    for n in range(N):
        obj += lambda_P[n,t]*node_power_defs[n,t] + lambda_VL[n,t]*Vmin2 - lambda_VU[n,t]*Vmax2

    for l,m in lines:
        # note r^1 = R[0,0], r^4 = R[1,1], r^6 = R[2,2]
        obj += -1*Rs[(l,m,t)][0,0]*Smax2 + -1*Rs[(l,m,t)][1,1] + -1*Rs[(l,m,t)][2,2]


# Define CVXPY problem
# =============================================================================
problem = cp.Problem(cp.Maximize(obj), constraints)


# Solve dual problem
# =============================================================================
print("\nOptimisation start.")

start = time.time()
# problem.solve(solver=cp.SCS,verbose=True,
#               max_iters=int(5e4),eps=2e-4,
#               use_indirect=True,alpha=1.0) # SCS solver
problem.solve(solver=cp.CVXOPT, verbose=True, kktsolver='robust',
                refinement=5, reltol=1e-8, feastol=1e-7,
                max_iters=250) # CVXOPT solver
# problem.solve(solver=cp.MOSEK, verbose=True,
#               mosek_params={mosek.iparam.optimizer:mosek.optimizertype.conic,
#                             mosek.dparam.intpnt_co_tol_rel_gap:1e-12}) # MOSEK solver
end = time.time()
runtime = end-start
print("\n============================")
print("Op solve time: %ss %sms"%(int(runtime),int((runtime%1)*1000)))
print("============================\n")



# Verify conditions for exactness are satisfied
# =============================================================================
print("\nEvaluating exactness.")
failures = [] # initialise list to store faild time instances

rcond = 1e-4
# relative condition number - threshold for eigenvalue (really singular value)
# to be treated as zero - see https://docs.scipy.org/doc/scipy/reference/generated/scipy.linalg.null_space.html
# hopefully all the eigenvalues are positive real, so they conincide with the singular values

for t in range(T): # for each time instance
    eigvals = np.linalg.eigvals(As[t].value)
    mev = np.max(np.abs(eigvals)) # find largest eigenvalue
    numzeros = 0
    for ev in eigvals:
        if np.abs(ev) < mev*rcond:
            numzeros += 1
    if numzeros != 2: # criterion for exactness not satisfied
        print("Fail; %s,%s"%(t,numzeros))
        print(eigvals)
        print("Constraint violation: %s"%constraints[L*T+t].violation())
        failures.append(t) # store failed time index

def user_break(message):

    answer = input(message)

    if answer == 'y':
        return # continue
    elif answer == 'n':
        sys.exit() # terminate program
    else:
        print("Invalid answer - choose [y]/n")
        user_break(message) # ask again for a valid answer

if len(failures) != 0:
    print("\n===================================")
    print("Exactness condition failed at %s"%failures)
    print("===================================")
    user_break("Continue to solution recovery? [y]/n\n")
    print("\n")
else:
    print("\n==============================")
    print("Exactness conditions satisfied")
    print("==============================\n")



# =============================================================================
# Recover primal solution
# =============================================================================

Vopts = np.empty((T,N),dtype=np.complex) # initialise array to store optimised node voltage vectors
Vopts[:] = np.nan # set entries to nan to prevent confusion with unfilled time instances
Wopts = np.zeros((T,2*N,2*N)) # initialise array to store optimised W matrices


# Recover W matrices for time instances where exactness criterion is SATISFIED
# =============================================================================
# Method from Lavaei & Low 2012 / Gayme & Topcu 2012
print("Recovering voltage matrices for exact instances.")

for t in [h for h in range(T) if h not in failures]:
    print(t)

    ns_vecs = sp.linalg.null_space(As[t].value,rcond=rcond) # compute null space of A(lambda,R,t)
    assert ns_vecs.shape == (2*N,2) # verify dimension of null space

    # grab first null space basis vector and split into components
    v1 = ns_vecs[:,0][:N] # first N entries
    v2 = ns_vecs[:,0][N:] # last N entries

    # identify the first node which has an exactly active constraint (i.e. the
    # Lagrange multiplier is exactly zero), and set the boundary voltage
    # variable accordingly

    ### for SCS which gives true zero values for lambda_VL/VU
    # for k in range(N):
    #     if (lambda_VL.value[k,t] != 0) and (lambda_VU.value[k,t] == 0):
    #         Vbk2 = Vmin2
    #         break
    #     elif (lambda_VL.value[k,t] == 0) and (lambda_VU.value[k,t] != 0):
    #         Vbk2 = Vmax2
    #         break

    ### for CVXOPT which gives numerically zero values for lambda_VL/VU
    VLU_tol = 1e-6
    for k in range(N):
        if (lambda_VL.value[k,t] > VLU_tol) and (lambda_VU.value[k,t] < VLU_tol):
            Vbk2 = Vmin2
            break
        elif (lambda_VL.value[k,t] < VLU_tol) and (lambda_VU.value[k,t] > VLU_tol):
            Vbk2 = Vmax2
            break # note by breaking we retain the value of k at which this occurs

    # calculate constants for constructing Vopt[t]
    zeta1 = np.sqrt((Vbk2/(v1[k]**2 + v2[k]**2))/(1 + (v2[0]/v1[0])**2))
    zeta2 = -1*(v2[0]/v1[0])*zeta1

    # construct Vopt[t]
    Vopt_t = (zeta1 + zeta2*1j)*(v1 + v2*1j)
    # adjust sign of voltage vector to set phase angle of reference to 0 not 180
    Vopt_t *= np.sign(Vopt_t[0])

    Vopts[t] = Vopt_t # store constructed optimal voltage (row) vector

    print(Vopt_t)
    print(np.abs(Vopt_t))

    # construct Wopt[t] and store
    Xopt_t = np.hstack(( Vopt_t.real , Vopt_t.imag )) # extended voltage (row) vector
    Xopt_t = np.reshape(Xopt_t, (2*N,1)) # convert to column vector

    Wopt_t = Xopt_t@Xopt_t.T # produce Wopt[t] matrix
    Wopts[t] = Wopt_t # store

print('\n')


# Recover W matrices for time instances which FAIL exactness criterion
# =============================================================================
print("Recovering voltage matrices for non-exact instances.")

redeemed_times = [] # initialise list to store time instances which satisfy exactness following recovery

for t in failures:
    print(t)

    # Set up the CVXPY problem for recovering Wopt
    # =========================================================================

    # create the optimisation variable W, specify symmetry
    Wnamestr = "recW-%s"%t
    W = cp.Variable(shape=(2*N,2*N),name=Wnamestr,symmetric=True)

    rec_constraints = [] # initialise list of constraints for optimisation

    # constraint W to be psd
    rec_constraints += [W >> 0]

    # apply KKT condition constraint - Tr(A(t)W[t]) = 0
    rec_constraints += [cp.trace(As[t].value@W) == 0]

    # apply node voltage constraints (primal)
    for n in range(N):
        rec_constraints += [Vmin2 <= cp.trace(Mks[n]@W)]
        rec_constraints += [cp.trace(Mks[n]@W) <= Vmax2]

    # apply line current constraints (primal)
    for l,m in lines:

        rec_constr_mat = cp.vstack((
            cp.hstack(( -1*Smax2 , cp.trace(bYlms[l,m]@W) , cp.trace(bYbarlms[l,m]@W) )),
            cp.hstack(( cp.trace(bYlms[l,m]@W), -1, 0 )),
            cp.hstack(( cp.trace(bYbarlms[l,m]@W), 0 , -1 ))
            ))
        rec_constraints += [rec_constr_mat << 0]

    recovery_obj = cp.normNuc(W) # nuclear norm rank-regularisation objective
    recovery_op = cp.Problem(cp.Minimize(recovery_obj), rec_constraints)

    rec_max_iters = int(2e4) # solver max iterations for recovery
    rec_tol = 1e-6 # solver tolerance for recovery

    recovery_op.solve(solver=cp.SCS, verbose=False,
                      max_iters=rec_max_iters, eps=rec_tol,
                      use_indirect=True) # use solver SCS
    # recovery_op.solve(solver=cp.CVXOPT, verbose=True, kktsolver='robust',
    #                   refinement=5)

    Weigvals = np.linalg.eigvals(W.value)
    print(np.flip(sorted(Weigvals, key=lambda l:np.abs(l))))
    print("PSD violation: %s"%rec_constraints[0].violation())
    # note, the value of this violation must be considered relative to the eigenvalue size

    Wrank = np.linalg.matrix_rank(W.value, tol=np.max(Weigvals)*rcond)
    # note the same rcond as before is used for consistency
    print("rank - %s"%Wrank)

    if Wrank == 1:
        print("Time %s redeemed!"%t)
        redeemed_times.append(t)

    # Store recovered W matrix
    # =========================================================================
    Wopts[t] = W.value

assert False not in [W.any() for W in Wopts] # ensure all W matrices calculated


### Check if power limit constraints are binding
# for t in range(T):
#     for (l,m) in lines:
#         vol = Smax2 - (np.trace(bYlms[l,m]@Wopts[t])**2 + np.trace(bYbarlms[l,m]@Wopts[t])**2)
#         if vol < 1000:
#             print(vol, (l,m), t)
#         else:
#             print(vol)


# Recover energy storage levels
# =============================================================================
print("\nRecovering storage energy levels")

"""
Work through the energy storage levels for each node. Start at
e_n[t] = alpha*S_n, and solve forwards in time in terms of S_n while
lambda^{eU/eL} = 0. When a non-zero value is reached, e_n[t] has a
determined value, so S_n can be calculated, allowing the previous values
to be back calculated. Solution forwards in time then proceeds from here.
"""

# initialise array to store store energy levels, e_n[t]
storage_energy_levels = np.zeros((N,T))
storage_capacities = np.zeros(N) # array to store storage capacities

### check Lagrange multipliers eL/eU for violations
# ztol = 1e-3
# for n in range(N):
#     print(n)
#     for t in range(T):
#         fail = (lambda_eL.value[n,t] > ztol) and (lambda_eU.value[n,t] > ztol)
#         if fail:
#             print(t,lambda_eL.value[n,t],lambda_eU.value[n,t],"fail")
#         else:
#             print(t,lambda_eL.value[n,t],lambda_eU.value[n,t])
#     print('\n')

assert not np.any(lambda_eL.value < 0) # check Lagrage multipliers non-negative
assert not np.any(lambda_eU.value < 0)


for n in range(N): # solve energy levels independently for each node

    assert lambda_S.value[n] >= 0
    lambda_S_tol = 1e-3 # tolerance for declaring lambda_S_n to be non-zero

    if lambda_S.value[n] > lambda_S_tol: # if the storage Lagrange multiplier is non-zero
        storage_capacities[n] = 0 # set storage capacity to zero
        storage_energy_levels[n,:] = np.nan # declare energy levels undefined

    else:
        # set relatively small/effectively zero lambda_P values to zero
        lambda_P_tol = 1e-5 # tolerance to zero declaration
        lambdaPs = np.where(lambda_P.value[n] < lambda_P_tol, 0, lambda_P.value[n]) # screen lambda_Ps

        # Compute energy levels of storage
        # =====================================================================
        nz_tol = 1e-3 # tolerance for lambda_eL/lambda_eU to be non-zero


        ### Method 1 - KKT, completementary slackness based method
        ### This is the method from Gayme &  Topcu 2012
        # Perform an initial pass to compute Sn
        # =====================================================================
        delta_e = 0 # initialise variable to store net energy accumulation
        Sn_calculated = False # flag to show whether Sn have been calculated

        for t in range(T):

            if lambda_P.value[n,t] > nz_tol: # if lambda_P is non-zero

                # increment energy accumulation
                delta_e += -1*(1/storage_flow_factor)*(np.trace(bYks[n]@Wopts[t]) + node_power_defs[n,t])
                print(t,delta_e)

                if (lambda_eL.value[n,t] > nz_tol) and (lambda_eU.value[n,t] > nz_tol):
                    print("Warning! Invalid Lagrange multipliers encountered eL-eU before solution")
                    pass # ignore if both multipliers non-zero

                # if lambda_eL > 0, then e_n[t] = 0
                elif lambda_eL.value[n,t] > nz_tol:
                    S_n = -1*delta_e/alphas[n] # note delta_e should be negative
                    storage_capacities[n] = S_n # store determined storage capacity
                    print(S_n,n,t,1)
                    Sn_calculated = True # set flag
                    break # break to save computation

                # if lambda_eU > 0, then e_n[t] = Sn
                elif lambda_eU.value[n,t] > nz_tol:
                    if alphas[n] == 1:
                        pass # this level cannot be used to determine Sn
                    else:
                        S_n = delta_e/(1-alphas[n])
                        storage_capacities[n] = S_n # store determined storage capacity
                        Sn_calculated = True # set flag
                        print(S_n,n,t,2)
                        break

                else:
                    pass

            else: # if lambda_P is zero
                if lambda_eU.value[n,t] > nz_tol:
                    S_n = delta_e/(1-alphas[n])
                    storage_capacities[n] = S_n # approximate storage capacity
                    Sn_calculated = True # set flag
                    print("Warning - Sn solution inaccurate, lambda_P = 0 encountered.")
                    break
                else: # if lambda_P = 0 and e_n[t] not defined, we have a problem
                    raise ValueError("Indeterminate energy level")
            ### Is this is right way to deal with lambda_P = 0?

        if not Sn_calculated:
            raise ValueError("Storage capacity not calculated - no energy level constraints binding.")


        ### Method 2 - global consistency based method (more robust but less accurate)
        # Perform an initial pass to compute Sn
        # ====================================================================
        # delta_es = np.zeros(T+1) # array to store net energy accumulation - with initial zero (t=0 instance)

        # for t in range(T):

        #     if lambda_P.value[n,t] > nz_tol: # if lambda_P is non-zero
        #         delta_es[t+1] = delta_es[t] + -1*(1/storage_flow_factor)*(np.trace(bYks[n]@Wopts[t]) + node_power_defs[n,t])

        #     else: # if lambda_P is zero
        #         print("Warning - indeterminate energy level")
        #         delta_es[t+1] = delta_es[t] # approximate energy level
        #         #raise ValueError("Indeterminate energy level")

        # if alphas[n] != 1:
        #     S_n = np.max([np.max(delta_es)/(1-alphas[n]),np.abs(np.min(delta_es))/alphas[n],np.ptp(delta_es)])
        #     # consider storage level change diagram
        # else:
        #     if np.max(delta_es) <= 0:
        #         S_n = np.abs(np.min(delta_es)) # max energy extraction
        #     else:
        #         print("Warning - storage capacity violation")
        #         S_n = np.ptp(delta_es) # max energy extraction from excess peak

        # storage_capacities[n] = S_n


        # Perform a second pass to compute e_n[t]
        # =====================================================================

        for t in range(T):

            if lambda_P.value[n,t] > nz_tol: # if lambda_P is non-zero

                # compute storage levels using update rule
                if t == 0:
                    storage_energy_levels[n,t] = alphas[n]*S_n + -1*(1/storage_flow_factor)*(np.trace(bYks[n]@Wopts[t]) + node_power_defs[n,t])
                else:
                    storage_energy_levels[n,t] = storage_energy_levels[n,t-1] + -1*(1/storage_flow_factor)*(np.trace(bYks[n]@Wopts[t]) + node_power_defs[n,t])

            else: # if lambda_P is zero
                if lambda_eU.value[n,t] > nz_tol: # if storage capacity constraint active
                    storage_energy_levels[n,t] = S_n # set storage level to capacity

                else: # if lambda_P = 0 and e_n[t] not defined, we have a problem
                    print("Warning - indeterminate energy level")
                    storage_energy_levels[n,t] = storage_energy_levels[n,t-1]
                    #raise ValueError("Indeterminate energy level")


print("\n\n===================")
print("Storage capacities:")
print(storage_capacities)
print("Total capacity: %sMWh - Obj fn value: %sMWh"%(int(np.sum(storage_capacities)),int(problem.value)))
print("%% error: %s%%"%(round((np.sum(storage_capacities)-problem.value)*100/problem.value,3)))
print("===================\n")

# Check energy level feasibility violations
for n in range(N):
    if np.min(storage_energy_levels[n]) < 0:
        print(n,"min neg",np.min(storage_energy_levels[n]))
    if np.max(storage_energy_levels[n]) > storage_capacities[n]:
        print(n,"max excess",np.max(storage_energy_levels[n]),storage_capacities[n])


# Plot energy storage levels
fig = plt.figure()
ax = plt.gca()
for n in range(N):
    if storage_capacities[n] != 0:
        plt.plot(range(T), storage_energy_levels[n]/1000,label=n+1)
plt.xlabel("Time instance, $t$")
plt.ylabel("Energy storage level (GWh)")
ax.grid(True, which='major', alpha=0.75, ls='--', zorder=0)
ax.set_axisbelow(True)
plt.legend(title="Node/Bus")
plt.xlim(0,T-1)
plt.tight_layout()
plt.savefig('solution.png', dpi=300)
plt.show()