State estimation#
popo.estimation estimates the voltage at every bus of a feeder from the measurements that
exist, using pandapower’s weighted-least-squares estimator. It needs the estimation extra, and the
opendss extra to convert a feeder.
Converting a feeder#
to_pandapower() converts an OpenDSS feeder to a pandapower network and returns
it with the index of the substation’s low-voltage busbar, where the substation measurement is taken.
from pathlib import Path
from popo.estimation import check_conversion, to_pandapower
FEEDER = Path("tests/data/dss_uk_network_17_1_1149/master_network_17_1_1149.dss")
net, lv_bus = to_pandapower(FEEDER)
len(net.bus), len(net.load), lv_bus
(380, 188, 2)
The converter sets each transformer’s turns ratio from the voltage bases of its buses. to_pandapower
restores the nameplate ratio, 11/0.416 kV here, which would otherwise put every low-voltage bus about
0.04 pu too low.
check_conversion() solves both models at the same loading and compares them
bus by bus. Check this before relying on any estimate.
comparison = check_conversion(net)
comparison["difference_pu"].describe()
count 3.8000e+02
mean 1.7972e-03
std 1.6035e-04
min 2.2284e-07
25% 1.8063e-03
50% 1.8149e-03
75% 1.8318e-03
max 1.8434e-03
Name: difference_pu, dtype: float64
The two models agree within 0.0018 pu at every bus. The remaining difference comes from pandapower’s balanced model of a feeder that OpenDSS models phase by phase.
Counting equations#
An estimator solves for a voltage magnitude and angle at every bus, less one reference angle: 759
unknowns for this feeder’s 380 buses. Each measurement supplies one equation, and each bus with no load
supplies two. observability() counts both before an estimate is attempted.
from dataclasses import asdict
from popo.estimation import observability
customers = len(net.load)
pd.DataFrame(
[
{"metered customers": metered, **asdict(observability(net, lv_bus, metered=metered))}
for metered in (customers, customers - 1, customers - 2)
]
).set_index("metered customers")
| buses | required | available | |
|---|---|---|---|
| metered customers | |||
| 188 | 380 | 759 | 761 |
| 187 | 380 | 759 | 759 |
| 186 | 380 | 759 | 757 |
With every customer metered there are two equations to spare. With one customer unmetered the count is exact, because the substation measures the total and the missing customer is the difference. With two unmetered there are fewer equations than unknowns, and no estimate exists whatever the solver.
Estimating#
set_loading() sets each customer’s demand for one step, and
true_state() solves the power flow to give the true voltages and the
substation reading. The example takes evening demand from the household pool.
import numpy as np
from popo.estimation import set_loading, true_state
household_pool = pd.read_parquet("tests/data/uk_household_pu_96.parquet").to_numpy()
rng = np.random.default_rng(20260904)
active_mw = household_pool[36, rng.integers(0, household_pool.shape[1], customers)] * 1.3 / 1000
reactive_mvar = active_mw * np.tan(np.arccos(0.95))
set_loading(net, active_mw, reactive_mvar)
true_voltage, substation_mw, substation_mvar = true_state(net)
Real measurements carry error. MeasurementNoise holds the standard
deviations the estimator assumes: 0.002 pu for the busbar voltage, 2 % for the substation’s power and 10 %
for a smart meter. The readings below are drawn with that error.
from popo.estimation import MeasurementNoise, estimate_voltages, voltage_error
noise = MeasurementNoise()
r = np.random.default_rng(0)
def read(metered):
"""Noisy readings, and the estimate from them, with the given customers metered."""
return estimate_voltages(
net,
lv_bus,
voltage_pu=true_voltage[lv_bus] + r.normal(0, noise.voltage_pu),
active_mw=substation_mw * (1 + r.normal(0, noise.substation_pq_fraction)),
reactive_mvar=substation_mvar * (1 + r.normal(0, noise.substation_pq_fraction)),
customer_active_mw=active_mw * (1 + r.normal(0, noise.smart_meter_pq_fraction, customers)),
customer_reactive_mvar=reactive_mvar * (1 + r.normal(0, noise.smart_meter_pq_fraction, customers)),
metered=metered,
)
estimate = read(metered=list(range(customers)))
voltage_error(estimate, true_voltage)
{'mean_abs_pu': 0.00024024268427427715,
'max_abs_pu': 0.000986565306520082,
'at_worst_bus_pu': 0.00027665901753881617}
With every customer metered, the estimate is within 0.001 pu of the true voltage at every bus. With two
customers unmetered, estimate_voltages returns None, as the count predicted:
read(metered=list(range(customers - 2))) is None
True
Line sensors#
Where a customer has no meter, a power-flow sensor on a line can supply the missing equations. Pass the
sensors to estimate_voltages as line_flows, a mapping from line index to the measured active and
reactive power, and to observability as line_sensors. A sensor helps only if the flow it measures
separates an unmetered customer from the others; the
state estimation tutorial compares placements.
Forecasts as inputs#
run_comparison() tests whether the kind of substation forecast matters when it
feeds an estimator. It compares a coherent forecast, whose active power, reactive power and voltage
describe a state the network can be in, with three forecasts of the same accuracy made separately.
Limitations#
pandapower’s estimator uses a balanced model. On an unbalanced feeder this sets a floor on accuracy, about 0.0018 pu on this feeder.
Smart-meter readings enter as measurements with an assumed 10 % error. If the real error is larger, the estimate is worse than the assumed weights suggest.
A converged estimate is not necessarily correct. Compare it with the true state on a simulated case, as above, before trusting it on a real feeder.