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.