Source code for netallocation.flow

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Created on Wed Feb 21 12:14:49 2018

@author: fabian
"""

# This side-package is created for use as flow and cost allocation.

from .linalg import dot
from .grid import (self_consumption, power_demand, power_production,
                   network_injection, network_flow, Incidence,
                   PTDF, CISF, voltage, Ybus)
from .linalg import diag, inv, dedup_axis
from .utils import (upper, lower, as_sparse, check_branch_comps,
                    check_passive_branch_comps, check_snapshots)
import pandas as pd
import xarray as xr
import dask
from xarray import Dataset, DataArray
from numpy import real, conj, sign
import logging
from pathlib import Path
from dask.diagnostics import ProgressBar
# from progressbar import ProgressBar

logger = logging.getLogger(__name__)


[docs]def average_participation( n, snapshot, dims='all', branch_components=None, aggregated=True, downstream=True, include_self_consumption=True, sparse=False, round=None, dask=False, **kwargs): """ Perform a Flow Tracing allocation. Allocate the network flow in according to the method 'Average participation' or 'Flow tracing' firstly presented in [1,2]. The algorithm itself is derived from [3]. The general idea is to follow active power flow from source to sink (or sink to source) using the principle of proportional sharing and calculate the partial flows on each line, or to each bus where the power goes to (or comes from). This method provides two general options: Downstream: The flow of each nodal power injection is traced through the network and decomposed the to set of lines/buses on which is flows on/to. Upstream: The flow of each nodal power demand is traced (in reverse direction) through the network and decomposed to the set of lines/buses where it comes from. Note that only one snapshot can be calculated at a time, use `flow_allocation` to calculate multiple snapshots. [1] J. Bialek, “Tracing the flow of electricity,” IEE Proceedings - Generation, Transmission and Distribution, vol. 143, no. 4, p. 313, 1996. [2] D. Kirschen, R. Allan, G. Strbac, Contributions of individual generators to loads and flows, Power Systems, IEEE Transactions on 12 (1) (1997) 52–60. doi:10.1109/59.574923. [3] J. Hörsch, M. Schäfer, S. Becker, S. Schramm, and M. Greiner, “Flow tracing as a tool set for the analysis of networked large-scale renewable electricity systems,” International Journal of Electrical Power & Energy Systems, vol. 96, pp. 390–397, Mar. 2018. Parameters ---------- n : pypsa.Network Network object with valid flow data. snapshot : str, pd.Timestamp Specify snapshot which should be investigated. Must be in network.snapshots. branch_components : list Components for which the allocation should be calculated. The default is None, which results in n.branch_components. dims : list or string list of dimensions to be included, if set to "all", the full dimensions are calculated ['source', 'branch', 'sink'] downstream : Boolean, default True Whether to use downstream or upstream method for performing the flow-tracing. aggregated: boolean, defaut True Within the aggregated coupling scheme (obtained if set to True), power production and demand are 'aggregated' within the corresponding bus. Therefore only the net surplus or net deficit of a bus is allocated to other buses. Within the direct coupling scheme (if set to False), production and demand are considered independent of the bus, therefore the power production and demand are allocated to all buses at the same time. Even if a bus has net deficit, its power production can be allocated to other buses. include_self_consumption: boolean, default True Whether to include self consumption of each buses within the aggregated coupling scheme. sparse: boolean, default False Whether to compute the allocation with sparse arrays, this can save time for large networks round: float, default None Round the resulting allocation to a given precision in decimal digits. """ dims = ['source', 'branch', 'sink'] if dims == 'all' else dims f0 = network_flow(n, snapshot, branch_components) f1 = network_flow(n, snapshot, branch_components, ingoing=False) f_in = f0.where(f0 > 0, - f1) f_out = f0.where(f0 < 0, - f1) p = network_injection(n, snapshot, branch_components) p = p.chunk(-1) if dask else p if aggregated: # nodal inflow and nodal outflow p_in = upper(p).rename(bus='source') p_out = - lower(p).rename(bus='sink') else: p_in = power_production( n, [snapshot]).loc[snapshot].rename(bus='source') p_out = power_demand(n, [snapshot]).loc[snapshot].rename(bus='sink') K = Incidence(n, branch_components, sparse=sparse) K_dir = K * sign(f_in) newdims, newdims_r = ('source', 'sink'), ('sink', 'source') P_in = diag(p_in, newdims, sparse=sparse) P_out = diag(p_out, newdims, sparse=sparse) J = inv(dedup_axis(dot(lower(K_dir) * f_out, K.T), newdims) + P_in, True) Q = J * p_in J = inv(dedup_axis(dot(upper(K_dir) * f_in, K.T), newdims_r) + P_out, True) R = J * p_out if downstream: A, kind = Q * p_out, 'downstream' else: A, kind = R * p_in, 'upstream' if aggregated and include_self_consumption: selfcon = self_consumption(n, snapshot) if sparse: A += as_sparse(diag(selfcon, ('source', 'sink'))) else: A += diag(selfcon, ('source', 'sink')) A = A.round(round) if round is not None else A res = A.to_dataset(name='peer_to_peer')\ .assign_attrs(method='Average Participation', aggregated=aggregated) if 'branch' in dims: f = f_in if downstream else f_out T = dot(f * upper(K_dir.T), Q.fillna(0)) * \ dot(lower(K_dir.T), -R.fillna(0)) T = T.assign_coords(snapshot=snapshot).assign_attrs(kind=kind) T = T.round(round) if round is not None else T res = res.assign({'peer_on_branch_to_peer': T}) if round is not None: res = res.round(round).assign_attrs(res.attrs) return res
[docs]def marginal_participation(n, snapshot=None, q=0.5, branch_components=None, sparse=False, round=None, aggregated=True, dask=False, **kwargs): """ Perform a Marginal Participation allocation. Allocate line flows according to linear sensitvities of nodal power injection given by the changes in the power transfer distribution factors (PTDF)[1-3]. As the method is based on the DC-approximation, it works on subnetworks only as link flows are not taken into account. This method does not exclude counter flows. Note that only one snapshot can be calculated at a time, use `flow_allocation` to calculate multiple snapshots. [1] F. J. Rubio-Oderiz, I. J. Perez-Arriaga, Marginal pricing of transmission services: a comparative analysis of network cost allocation methods, IEEE Transactions on Power Systems 15 (1) (2000) 448–454. doi:10.1109/59.852158. [2] M. Schäfer, B. Tranberg, S. Hempel, S. Schramm, M. Greiner, Decompositions of injection patterns for nodal flow allocation in renewable electricity networks, The European Physical Journal B 90 (8) (2017) 144. [3] T. Brown, “Transmission network loading in Europe with high shares of renewables,” IET Renewable Power Generation, vol. 9, no. 1, pp. 57–65, Jan. 2015. Parameters ---------- n : pypsa.Network Network object with valid flow data. snapshot : str, pd.Timestamp Specify snapshot which should be investigated. Must be in network.snapshots. branch_components : list Components for which the allocation should be calculated. The default is None, which results in n.branch_components. q : float, default 0.5 split between net producers and net consumers. If q is zero, only the impact of net load is taken into account. If q is one, only net generators are taken into account. aggregated: boolean, defaut True Within the aggregated coupling scheme (obtained if set to True), power production and demand are 'aggregated' within the corresponding bus. Therefore only the net surplus or net deficit of a bus is allocated to other buses. Within the direct coupling scheme (if set to False), production and demand are considered independent of the bus, therefore the power production and demand are allocated to all buses at the same time. Even if a bus has net deficit, its power production can be allocated to other buses. round: float, default None Round the resulting allocation to a given precision in decimal digits. """ snapshot = n.snapshots[0] if snapshot is None else snapshot H = PTDF(n, branch_components=branch_components, snapshot=snapshot) K = Incidence(n, branch_components=branch_components) f = network_flow(n, [snapshot], branch_components) f = f.chunk(-1) if dask else f p = K @ f p_plus = upper(p) if aggregated else power_production(n, [snapshot]).T p_minus = lower(p) if aggregated else - power_demand(n, [snapshot]).T new_dims = ('bus', 'injection_pattern') P = diag(p.sel(snapshot=snapshot, drop=True), new_dims) s = 0.5 - abs(q - 0.5) gamma = float(p_plus.sum()) A = dedup_axis(dot(p_minus, p_plus.T) / gamma, new_dims) B = dedup_axis(dot(p_plus, p_minus.T) / gamma, new_dims) C = dedup_axis(dot(p_minus, p_minus.T) / gamma, new_dims) D = dedup_axis(dot(p_plus, p_plus.T) / gamma, new_dims) P = (q * (upper(P) + A) + (1 - q) * (lower(P) - B) + s * (P + C - D)).assign_coords(snapshot=snapshot) F = (H @ P).rename(injection_pattern='bus') attrs = { 'method': 'Marginal Participation', 'q': q, 'aggreated': aggregated} P = P.assign_attrs(attrs) F = F.assign_attrs(attrs) res = Dataset({'virtual_injection_pattern': P, 'virtual_flow_pattern': F}, attrs=attrs) if round is not None: res = res.round(round).assign_attrs(res.attrs) return as_sparse(res) if sparse else res
[docs]def equivalent_bilateral_exchanges(n, snapshot=None, branch_components=None, aggregated=True, q=0.5, sparse=False, round=None, dask=False, **kwargs): """ Perform a Equivalent Bilateral Exchanges allocation. Calculate the load flow induced by individual power sources in the network ignoring other sources and scaling down sinks. The sum of the resulting flow of those virtual injection patters is the total network flow. This method matches the 'Marginal participation' method for q = 1. Return either Virtual Injection Patterns if vip is set to True, or Virtual Flow Patterns. Note that only one snapshot can be calculated at a time, use `flow_allocation` to calculate multiple snapshots. Parameters ---------- n : pypsa.Network Network object with valid flow data. snapshot : str, pd.Timestamp Specify snapshot which should be investigated. Must be in network.snapshots. branch_components : list Components for which the allocation should be calculated. The default is None, which results in n.branch_components. aggregated: boolean, defaut True Within the aggregated coupling scheme (obtained if set to True), power production and demand are 'aggregated' within the corresponding bus. Therefore only the net surplus or net deficit of a bus is allocated to other buses. Within the direct coupling scheme (if set to False), production and demand are considered independent of the bus, therefore the power production and demand are allocated to all buses at the same time. Even if a bus has net deficit, its power production can be allocated to other buses. round: float, default None Round the resulting allocation to a given precision in decimal digits. """ snapshot = n.snapshots[0] if snapshot is None else snapshot H = PTDF(n, branch_components=branch_components, snapshot=snapshot) K = Incidence(n, branch_components=branch_components) f = network_flow(n, [snapshot], branch_components) f = f.chunk(-1) if dask else f p = K @ f p_plus = upper(p) if aggregated else power_production(n, [snapshot]).T p_minus = lower(p) if aggregated else - power_demand(n, [snapshot]).T p_pl = p_plus.sel(snapshot=snapshot, drop=True) # same as one-dimensional p_min = p_minus.sel(snapshot=snapshot, drop=True) new_dims = ('bus', 'injection_pattern') A = dedup_axis(dot(p_minus, p_plus.T) / float(p_pl.sum()), new_dims) B = dedup_axis(dot(p_plus, p_minus.T) / float(p_pl.sum()), new_dims) P = q * (A + diag(p_pl, new_dims)) + (q - 1) * (B - diag(p_min, new_dims)) P = P.assign_coords(snapshot=snapshot) F = (H @ P).rename(injection_pattern='bus') attrs = {'method': 'Eqivalent Bilateral Exchanges', 'q': q, 'aggregated': aggregated} P = P.assign_attrs(attrs) F = F.assign_attrs(attrs) res = Dataset({'virtual_injection_pattern': P, 'virtual_flow_pattern': F}, attrs=attrs) if round is not None: res = res.round(round).assign_attrs(res.attrs) return as_sparse(res) if sparse else res
[docs]def post_tracing_power_flow( n, snapshot, branch_components=None, aggregated=True, downstream=True, include_self_consumption=True, sparse=False, round=9, dask=False, **kwargs): """ Perform a allocation. Allocate the source and sinks in according to the method 'Average participation' or 'Flow tracing'. In a second step the flow in calculated by applying the PTDF to the pairs of sinks and sources This method provides two general options: Downstream: The flow of each nodal power injection is traced through the network and decomposed the to set of lines/buses on which is flows on/to. Upstream: The flow of each nodal power demand is traced (in reverse direction) through the network and decomposed to the set of lines/buses where it comes from. Note that only one snapshot can be calculated at a time, use `flow_allocation` to calculate multiple snapshots. Parameters ---------- n : pypsa.Network Network object with valid flow data. snapshot : str, pd.Timestamp Specify snapshot which should be investigated. Must be in network.snapshots. branch_components : list Components for which the allocation should be calculated. The default is None, which results in n.branch_components. downstream : Boolean, default True Whether to use downstream or upstream method for performing the flow-tracing. aggregated: boolean, defaut True Within the aggregated coupling scheme (obtained if set to True), power production and demand are 'aggregated' within the corresponding bus. Therefore only the net surplus or net deficit of a bus is allocated to other buses. Within the direct coupling scheme (if set to False), production and demand are considered independent of the bus, therefore the power production and demand are allocated to all buses at the same time. Even if a bus has net deficit, its power production can be allocated to other buses. include_self_consumption: boolean, default True Whether to include self consumption of each buses within the aggregated coupling scheme. sparse: boolean, default False Whether to compute the allocation with sparse arrays, this can save time for large networks round: float, default None Round the resulting allocation to a given precision in decimal digits. """ ds = average_participation(n, snapshot, dims=['source', 'sink'], branch_components=branch_components, aggregated=aggregated, downstream=downstream, include_self_consumption=False, sparse=sparse, round=round, dask=dask) ds.attrs.update({'method': 'post_tracing_power_flow'}) A = ds.peer_to_peer H = PTDF(n, branch_components, snapshot) T = (H.rename(bus='source') - H.rename(bus='sink')) * A ds = ds.assign(peer_on_branch_to_peer=T) if aggregated and include_self_consumption: selfcon = self_consumption(n, snapshot) if sparse: ds['peer_to_peer'] += as_sparse(diag(selfcon, ('source', 'sink'))) else: ds['peer_to_peer'] += diag(selfcon, ('source', 'sink')) return ds
[docs]def zbus_transmission(n, snapshot=None, linear=True, downstream=None, branch_components=None, **kwargs): r""" Perform a Zbus Transmission allocation. This allocation builds up on the method presented in [1]. However, we provide for non-linear power flow an additional DC-approximated modification, neglecting the series resistance r for lines. [1] A. J. Conejo, J. Contreras, D. A. Lima, and A. Padilha-Feltrin, “$Z_{\rm bus}$ Transmission Network Cost Allocation,” IEEE Transactions on Power Systems, vol. 22, no. 1, pp. 342–349, Feb. 2007. Parameters ---------- n : pypsa.Network Network object with valid flow data. snapshot : str, pd.Timestamp, list, pd.Index Specify snapshot(s) for which the allocation should be performed. Must be a suset of n.snapshots. branch_components : list Components for which the allocation should be calculated. The default is None, which results in n.branch_components. """ n.calculate_dependent_values() branch_components = check_passive_branch_comps(branch_components, n) snapshot = check_snapshots(snapshot, n) assert 'Link' not in branch_components, ( 'Component "Link" cannot be ' 'considered in Zbus flow allocation.') K = Incidence(n, branch_components=branch_components) Y = Ybus(n, branch_components, linear=linear) # Ybus matrix v = voltage(n, snapshot, linear=linear) H = PTDF(n, branch_components) if linear else CISF(n, branch_components) i = dot(Y, v) f = network_flow(n, snapshot, branch_components) if downstream is None: v_ = abs(K) @ v / 2 elif downstream: v_ = upper(K * sign(f)) @ v else: v_ = -lower(K * sign(f)) @ v if linear: # i == network_injection(n, snapshot, branch_components=branch_components) vif = H * i # which is the same as mp with q=0.5 else: # real(conj(i) * v) == n.buses_t.p.loc[snapshot].T vif = real(v_ * conj(H) * conj(i)) vif = vif.transpose(..., 'branch', 'bus') vip = K.dot(vif.rename(bus='injection_pattern'), 'branch')\ .transpose(..., 'bus', 'injection_pattern') ds = Dataset({'virtual_flow_pattern': vif, 'virtual_injection_pattern': vip}, attrs={'method': 'Zbus flow allocation'}) if isinstance(snapshot, pd.Index): return ds return ds.assign_coords(snapshot=snapshot)
[docs]def with_and_without_transit(n, snapshots=None, branch_components=None): """ Compute the with-and-without flows and losses. This function only works with the linear power so far and calculated the loss which *would* take place accoring to f²⋅r which is the loss for directed currents. If links are included their efficiency determines the loss. Parameters ---------- n : pypsa.Network Network object with valid flow data. snapshots : pd.Index or list Snapshots for which the flows and losses are calculated. Thye must be a subset of n.snapshots. The default is None, which results in n.snapshots. branch_components : list Components for which the allocation should be calculated. The default is None, which results in n.passive_branch_components. Returns ------- xarray.Dataset Resulting loss allocation of dimension {branch, country, snapshot} with variables [flow_with, loss_with, flow_without, loss_without]. """ branch_components = check_passive_branch_comps(branch_components, n) snapshots = check_snapshots(snapshots, n) regions = pd.Index(n.buses.country.unique(), name='country') branches = n.branches().loc[branch_components] f = network_flow(n, snapshots, branch_components) def regional_with_and_withtout_flow(region): in_region_buses = n.buses.query('country == @region').index region_branches = branches.query('bus0 in @in_region_buses ' 'or bus1 in @in_region_buses') buses_i = (pd.Index(region_branches.bus0.unique()) | pd.Index(region_branches.bus1.unique()) | in_region_buses) vicinity_buses = buses_i.difference(in_region_buses) branches_i = region_branches.index K = Incidence(n, branch_components).loc[buses_i] # create regional injection pattern with nodal injection at the border # accounting for the cross border flow p = (K @ f) # p.loc[in_region_buses] == # network_injection(n, snapshots).loc[snapshots, in_region_buses].T # modified injection pattern without transition im = upper(p.loc[vicinity_buses]) ex = lower(p.loc[vicinity_buses]) largerImport_b = im.sum('bus') > - ex.sum('bus') scaleImport = (im.sum('bus') + ex.sum('bus')) / im.sum('bus') scaleExport = (im.sum('bus') + ex.sum('bus')) / ex.sum('bus') netImOrEx = ( im * scaleImport).where( largerImport_b, (ex * scaleExport)) p_wo = xr.concat([p.loc[in_region_buses], netImOrEx], dim='bus')\ .reindex(bus=buses_i).fillna(0) if 'Link' in branch_components: H = xr.concat((PTDF(n, branch_components, snapshot=sn) for sn in snapshots), dim='snapshot')\ .sel(branch=branches_i) # f == H @ p else: H = PTDF(n, branch_components).sel(branch=branches_i) f_wo = H.reindex(bus=buses_i).dot(p_wo, 'bus') res = Dataset({'flow_with_transit': f.sel(branch=branches_i), 'flow_without_transit': f_wo})\ .assign_coords(country=region) return res.assign(transit_flow=res.flow_with_transit - res.flow_without_transit) progress = ProgressBar() flows = xr.concat((regional_with_and_withtout_flow(r) for r in progress(regions)), dim='country') comps = flows.get_index('branch').unique('component') loss = xr.concat( (flows.sel( component=c)**2 * DataArray( n.df(c).r_pu, dims='branch_i') if c in n.passive_branch_components else flows.sel( component=c) * DataArray( n.df(c).efficiency, dims='branch_i') for c in comps), dim=comps).stack( branch=[ 'component', 'branch_i']) .rename_vars( flow_with_transit='loss_with_transit', flow_without_transit='loss_without_transit', transit_flow='transit_flow_loss') return flows.merge(loss).assign_attrs( method='With-and-Without-Transit').fillna(0)
[docs]def marginal_welfare_contribution(n, snapshots=None, formulation='kirchhoff', return_networks=False): import pyomo.environ as pe from .opf import (extract_optimisation_results, define_passive_branch_flows_with_kirchhoff) def fmap(f, iterable): # mapper for inplace functions for x in iterable: f(x) def profit_by_gen(n): price_by_generator = (n.buses_t.marginal_price .reindex(columns=n.generators.bus) .set_axis(n.generators.index, axis=1, inplace=False)) revenue = price_by_generator * n.generators_t.p cost = n.generators_t.p.multiply(n.generators.marginal_cost, axis=1) return ((revenue - cost).rename_axis('profit') .rename_axis('generator', axis=1)) snapshots = check_snapshots(snapshots, n) n.lopf(snapshots, solver_name='gurobi_persistent', formulation=formulation) m = n.model networks = {} networks['orig_model'] = n if return_networks else profit_by_gen(n) m.zero_flow_con = pe.ConstraintList() for line in n.lines.index: # m.solutions.load_from(n.results) n_temp = n.copy() n_temp.model = m n_temp.mremove('Line', [line]) # set line flow to zero line_var = m.passive_branch_p['Line', line, :] fmap(lambda ln: m.zero_flow_con.add(ln == 0), line_var) fmap(n.opt.add_constraint, m.zero_flow_con.values()) # remove cycle constraint from persistent solver fmap(n.opt.remove_constraint, m.cycle_constraints.values()) # remove cycle constraint from model fmap(m.del_component, [c for c in dir(m) if 'cycle_constr' in c]) # add new cycle constraint to model define_passive_branch_flows_with_kirchhoff(n_temp, snapshots, True) # add cycle constraint to persistent solver fmap(n.opt.add_constraint, m.cycle_constraints.values()) # solve n_temp.results = n.opt.solve() m.solutions.load_from(n_temp.results) # extract results extract_optimisation_results(n_temp, snapshots, formulation='kirchhoff') if not return_networks: n_temp = profit_by_gen(n_temp) networks[line] = n_temp # reset model fmap(n.opt.remove_constraint, m.zero_flow_con.values()) m.zero_flow_con.clear() return (pd.Series(networks) .rename_axis('removed line') .rename('Network'))
_func_dict = {'Average participation': average_participation, 'ap': average_participation, 'Marginal participation': marginal_participation, 'mp': marginal_participation, 'Equivalent bilateral exchanges': equivalent_bilateral_exchanges, 'ebe': equivalent_bilateral_exchanges, 'Zbus transmission': zbus_transmission, 'zbus': zbus_transmission, 'Post tracing power flow': post_tracing_power_flow, 'ptpf': post_tracing_power_flow} _non_sequential_funcs = [zbus_transmission, with_and_without_transit]
[docs]def flow_allocation(n, snapshots=None, method='Average participation', to_netcdf=None, round_floats=8, **kwargs): """ Allocate or decompose the network flow with different methods. Available methods are 'Average participation' ('ap'), 'Marginal participation' ('mp'), 'Virtual injection pattern' ('vip'), 'Zbus transmission' ('zbus'). Parameters ---------- n : pypsa.Network Network object with valid flow data. snapshots : string or pandas.DatetimeIndex (Subset of) snapshots of the network. If None (dafault) all snapshots are taken. per_bus : Boolean, default is False Whether to allocate the flow in an peer-to-peeer manner, method : string Type of the allocation method. Should be one of - 'Average participation'/'ap': Trace the active power flow from source to sink (or sink to source) using the principle of proportional sharing and calculate the partial flows on each line, or to each bus where the power goes to (or comes from). - 'Marginal participation'/'mp': Allocate line flows according to linear sensitvities of nodal power injection given by the changes in the power transfer distribution factors (PTDF) - 'Equivalent bilateral exchanges'/'ebe' Sequentially calculate the load flow induced by individual power sources in the network ignoring other sources and scaling down sinks. - 'Zbus transmission'/'zbus' Returns ------- res : xr.Dataset Dataset with allocations depending on the method. """ snapshots = check_snapshots(snapshots, n) n.calculate_dependent_values() if all(c.pnl.p0.empty for c in n.iterate_components(n.branch_components)): raise ValueError('Flows are not given by the network, ' 'please solve the network flows first') if method not in _func_dict.keys(): raise(ValueError('Method not implemented, please choose one out of' f'{list(_func_dict.keys())}')) is_nonsequetial_func = _func_dict[method] in _non_sequential_funcs if isinstance(snapshots, (str, pd.Timestamp)) or is_nonsequetial_func: return _func_dict[method](n, snapshots, **kwargs) logger.info('Calculate allocations') func = _func_dict[method] res = [dask.delayed(func)(n, sn, **kwargs) for sn in snapshots] with ProgressBar(): res = xr.concat(dask.compute(*res), dim=snapshots.rename('snapshot')) return res