Scenarios#

popo.scenarios changes a circuit to represent a future one: customers with real demand profiles, and a share of them with rooftop solar, an EV charger or a battery. It needs the opendss extra.

Household demand#

assign_household_profiles() gives each customer its own demand shape, drawn from a pool of recorded household profiles. It returns the number of customers it assigned.

from pathlib import Path

import numpy as np

from popo.network import Circuit, residential_loads
from popo.scenarios import assign_household_profiles

FEEDER = Path("tests/data/dss_uk_network_17_1_1149/master_network_17_1_1149.dss")
household_pool = pd.read_parquet("tests/data/uk_household_pu_96.parquet").to_numpy()
pv_profile = pd.read_parquet("tests/data/uk_pv_unit_96.parquet").to_numpy().ravel()
ev_pool = pd.read_parquet("tests/data/uk_ev_kw_96.parquet").to_numpy()

circuit = Circuit.load(FEEDER)
customers = residential_loads(circuit.dss)
assign_household_profiles(circuit.dss, customers, household_pool, np.random.default_rng(1))
188

Each column of the pool is one household over 96 half-hours. The pool here has 400 households.

Rooftop solar#

attach_generation() fits a rooftop array to a fraction of customers, the penetration. Array sizes are drawn from ArraySizing, by default a mean of 3.5 kWp clipped to between 2 and 12 kWp, and every array follows the same per-unit generation profile.

from popo.scenarios import attach_generation

attach_generation(circuit.dss, customers, 0.6, pv_profile, np.random.default_rng(2), kv=0.23)
pd.DataFrame(circuit.pvsystems).head()
name bus1 kva pmpp pf phases
0 pv4 1_1149_171_217.1.0 2.799 2.545 1.0 1
1 pv165 1_1149_171_2633.3.0 4.240 3.854 1.0 1
2 pv97 1_1149_171_1530.1.0 2.200 2.000 1.0 1
3 pv23 1_1149_171_518.2.0 4.839 4.399 1.0 1
4 pv100 1_1149_171_1611.3.0 3.676 3.342 1.0 1

At 60 % penetration, 113 of the 188 customers have an array. The random generator decides which customers are chosen; with the same seed, the customers chosen at 40 % are also chosen at 60 %, so results at different penetrations compare the same households.

Each array is connected on its customer’s own phase. The bus suffix .1.0 means phase 1 to neutral. service_connection() returns the suffix for any customer, and the connection argument of every scenario function overrides it.

EV charging and storage#

attach_charging() gives a fraction of customers an EV charger. Each charger follows its own recorded session series, so the total at the substation reflects how often chargers actually coincide.

attach_storage() gives customers a battery that charges and discharges in fixed daily windows, set by StorageDispatch. The default is a 3 kW, 10 kWh battery that charges from 10:00 to 17:00, while rooftop generation is high, and discharges from 18:00 to 22:00.

Comparing scenarios#

The function below rebuilds the feeder for each case with the same seeds, so every case has the same households and the same 113 arrays. It solves two days and counts how many customers exceed the EN 50160 upper limit at least once.

from popo.scenarios import attach_charging, attach_storage
from popo.standards import VoltageLimits, VoltVarCurve, VoltWattCurve


def simulate(case):
    circuit = Circuit.load(FEEDER)
    dss = circuit.dss
    customers = residential_loads(dss)
    assign_household_profiles(dss, customers, household_pool, np.random.default_rng(1))
    control = {
        "Volt-VAr": {"volt_var": VoltVarCurve.ieee_1547_default()},
        "Volt-Watt": {"volt_watt": VoltWattCurve.ieee_1547_default()},
    }.get(case, {})
    attach_generation(dss, customers, 0.6, pv_profile, np.random.default_rng(2), kv=0.23, **control)
    if case == "Storage":
        pv_buses = {pv.bus1.split(".")[0] for pv in circuit.pvsystems}
        with_pv = [(load, bus) for load, bus in customers if bus in pv_buses]
        attach_storage(dss, with_pv, 1.0, np.random.default_rng(3), kv=0.23)
    if case == "EV charging":
        attach_charging(dss, customers, 0.3, ev_pool, np.random.default_rng(4), kv=0.23)

    report = circuit.solve_time_series(96, buses=[bus for _, bus in customers])
    traces = report.voltage_traces()
    events = VoltageLimits.en_50160().event_summary(traces)
    return {
        "case": case,
        "max voltage (pu)": events["v_max_pu"].max(),
        "min voltage (pu)": events["v_min_pu"].min(),
        "customers over limit": int((events["over_steps"] > 0).sum()),
        "peak transformer load (kVA)": report.records["s_kva"].max(),
        "converged": report.converged,
    }


cases = ["No control", "Volt-VAr", "Volt-Watt", "Storage", "EV charging"]
pd.DataFrame([simulate(case) for case in cases]).set_index("case")
max voltage (pu) min voltage (pu) customers over limit peak transformer load (kVA) converged
case
No control 1.051 1.015 0 77.677 True
Volt-VAr 1.038 1.013 0 81.845 True
Volt-Watt 1.051 1.015 0 77.677 True
Storage 1.109 0.928 76 380.238 True
EV charging 1.048 0.993 0 133.547 True

Without control, 142 customers exceed 1.10 pu. The cases differ in what they cost:

  • Volt-VAr absorbs reactive power as voltage rises. It keeps every customer within the limit but raises the transformer’s peak load, because the reactive power flows through it.

  • Volt-Watt curtails active power as voltage rises. It also keeps every customer within the limit, and halves the transformer’s peak load, at the cost of generation that is not exported.

  • Storage behind every array reduces the export but does not remove it. Charging at 3 kW, a 10 kWh battery is full after about 3.3 hours, while the arrays are still generating. Discharging in the evening also lowers the minimum voltage, from 1.015 to 0.969 pu.

  • EV charging at 30 % penetration barely changes the midday peak, because most charging happens in the evening.

Inverter control#

Volt-VAr and Volt-Watt are installed through attach_generation, with the volt_var or volt_watt argument, or added afterwards to every inverter with install_volt_var() and install_volt_watt(). The curves themselves are standards, described in Standards.

Limitations#

  • OpenDSS allows one control per inverter, so a case uses Volt-VAr or Volt-Watt, not both.

  • Every array follows the same generation profile. The same irradiance at every roof is reasonable on one feeder, but the profile carries no local cloud cover.

  • Battery dispatch follows fixed windows and does not respond to voltage. All batteries charge and discharge at the same times, which can move a voltage problem into the evening.

  • The penetration is a fraction of the customers passed in. Pass only eligible customers, for example houses with a suitable roof, if the study needs that distinction.