How to Model PV Output with pvlib
On this page
Problem statement
A yield figure from a web calculator is a black box: 1,633 kWh per kWp in Toledo, take it or leave it. Siting a portfolio needs the same calculation in code, so that it can run on a grid of candidate sites, with a chosen module and mounting, on the weather the rest of the analysis uses — and so that each loss in the chain can be seen and questioned.
pvlib implements every step of that chain. The work is in connecting the steps correctly: the right time convention for the sun position, the right transposition from horizontal to tilted, the cell temperature, and the losses that PVGIS rolls into a single 14%.
Quick answer
Five steps turn GHI, DNI and DHI into AC energy:
import requests, numpy as np, pandas as pd, pvlib
LAT, LON, TILT, AZIMUTH = 39.86, -4.02, 36, 180 # pvlib: 180 = south
j = requests.get("https://archive-api.open-meteo.com/v1/archive", params={
"latitude": LAT, "longitude": LON,
"start_date": "2023-01-01", "end_date": "2023-12-31",
"hourly": "shortwave_radiation,direct_normal_irradiance,diffuse_radiation,"
"temperature_2m,wind_speed_10m",
"wind_speed_unit": "ms", "timezone": "UTC"}, timeout=120).json()
w = pd.DataFrame(j["hourly"])
# Open-Meteo values are means over the hour *ending* at the stamp:
# label each one at the middle of its hour so the sun is in the right place.
w.index = pd.to_datetime(w.pop("time")).dt.tz_localize("UTC") - pd.Timedelta("30min")
w = w.rename(columns={"shortwave_radiation": "ghi", "direct_normal_irradiance": "dni",
"diffuse_radiation": "dhi", "temperature_2m": "temp_air",
"wind_speed_10m": "wind_speed"})
site = pvlib.location.Location(LAT, LON, altitude=j["elevation"])
sun = site.get_solarposition(w.index) # 1. sun
poa = pvlib.irradiance.get_total_irradiance( # 2. plane of array
TILT, AZIMUTH, sun.apparent_zenith, sun.azimuth, w.dni, w.ghi, w.dhi,
dni_extra=pvlib.irradiance.get_extra_radiation(w.index), model="perez")
aoi = pvlib.irradiance.aoi(TILT, AZIMUTH, sun.apparent_zenith, sun.azimuth)
iam = pvlib.iam.physical(aoi) # 3. reflection
effective = poa.poa_direct * iam + poa.poa_diffuse
cell = pvlib.temperature.faiman(poa.poa_global, w.temp_air, w.wind_speed) # 4. heat
dc = pvlib.pvsystem.pvwatts_dc(effective, cell, pdc0=1000, gamma_pdc=-0.0037)
dc = dc * (1 - 0.10) # soiling, mismatch, wiring, ageing
ac = pvlib.inverter.pvwatts(dc, pdc0=1000 / 0.96) # 5. inverter
print(f"plane-of-array irradiation: {poa.poa_global.sum() / 1000:,.0f} kWh/m²")
print(f"AC energy: {ac.clip(lower=0).sum() / 1000:,.0f} kWh per kWp "
f"(capacity factor {ac.clip(lower=0).sum() / 1000 / 8760:.1%})")
plane-of-array irradiation: 2,132 kWh/m²
AC energy: 1,713 kWh per kWp (capacity factor 19.6%)
That is 4.9% above PVGIS's 1,633 kWh/kWp for the same site and tilt — from a different weather source (ERA5 rather than satellite, and 3.4% less GHI than PVGIS's SARAH-3 for 2023) and a loss budget built step by step rather than PVGIS's flat 14%. A gap of that size is normal; the examples below show where each part of it comes from.
Sun position, transposition, reflection, temperature
1. Put the sun where the irradiance was measured
For hourly means, the sun position must be evaluated at the middle of the averaging interval. Evaluating it at the label moves the sun by 7.5° — enough to push sunrise and sunset hours through the transposition model at the wrong angle.
2. Transpose from horizontal to the plane of the array
The beam part follows geometry exactly. The diffuse part needs a sky model: isotropic assumes the sky is uniformly bright; Perez adds the brighter circumsolar region and horizon band. Perez is the standard for yield work.
3. Apply the incidence-angle modifier
Light hitting the glass at a grazing angle is partly reflected. The loss is small at noon and large near sunrise and sunset; over a year it is 1–3% for a well-oriented fixed array.
4. Compute the cell temperature
Modules lose around 0.35–0.45% of power per °C above 25 °C. A cell in a Spanish summer runs 25–35 °C above the air, which is where most of the difference between a Toledo and a Hamburg performance ratio comes from.
5. Apply the DC losses, then the inverter
Soiling, mismatch, wiring, light-induced degradation: typically 8–12% combined. The inverter converts at 96–98% and clips when DC exceeds its rating.
6. Check against an independent estimate
PVGIS, a nearby operating plant or a second weather source. A model that disagrees by more than about 5% has a bug or an assumption worth writing down.
The loss budget, stage by stage
Example 1 — the loss budget, stage by stage
stages = {
"GHI (horizontal)": w.ghi.sum(),
"plane of array": poa.poa_global.sum(),
"after reflection (IAM)": effective.sum(),
"after temperature": pvlib.pvsystem.pvwatts_dc(effective, cell, pdc0=1000,
gamma_pdc=-0.0037).sum(),
"after DC losses": dc.sum(),
"AC out": ac.clip(lower=0).sum(),
}
prev = None
for name, v in stages.items():
step = "" if prev is None else f"{v / prev - 1:+.1%}"
print(f"{name:<24}{v / 1000:>8,.0f}{step:>9}")
prev = v
print("\n(irradiance rows in kWh/m², energy rows in kWh per kWp — 1 kWh/m² of "
"effective irradiance ≈ 1 kWh/kWp at 25 °C)")
GHI (horizontal) 1,788
plane of array 2,132 +19.3%
after reflection (IAM) 2,102 -1.4%
after temperature 1,987 -5.4%
after DC losses 1,789 -10.0%
AC out 1,713 -4.2%
(irradiance rows in kWh/m², energy rows in kWh per kWp — 1 kWh/m² of effective irradiance ≈ 1 kWh/kWp at 25 °C)
The temperature step is the largest single loss after the fixed DC budget. It is also the step that most distinguishes one climate from another.
Example 2 — isotropic against Perez
for model in ("isotropic", "haydavies", "perez"):
p = pvlib.irradiance.get_total_irradiance(
TILT, AZIMUTH, sun.apparent_zenith, sun.azimuth, w.dni, w.ghi, w.dhi,
dni_extra=pvlib.irradiance.get_extra_radiation(w.index), model=model)
print(f"{model:<10} POA {p.poa_global.sum() / 1000:>7,.0f} kWh/m² "
f"(diffuse part {p.poa_diffuse.sum() / 1000:,.0f})")
isotropic POA 2,035 kWh/m² (diffuse part 490)
haydavies POA 2,090 kWh/m² (diffuse part 545)
perez POA 2,132 kWh/m² (diffuse part 587)
The isotropic sky underestimates the diffuse light on a south-facing tilt, because the brightest part of the sky is around the sun. The difference is modest here and larger in hazy or cloudy climates where diffuse is a bigger share.
Example 3 — temperature loss by month
t_loss = 1 - (pvlib.pvsystem.pvwatts_dc(effective, cell, pdc0=1000, gamma_pdc=-0.0037)
/ pvlib.pvsystem.pvwatts_dc(effective, 25, pdc0=1000, gamma_pdc=-0.0037))
energy = effective.groupby(effective.index.month).sum()
weighted = (t_loss * effective).groupby(effective.index.month).sum() / energy
hot = cell[effective > 600].groupby(cell[effective > 600].index.month).mean()
print(f"{'month':<7}{'temperature loss':>17}{'mean cell °C in sun':>21}")
for m in range(1, 13):
print(f"{m:<7}{weighted.loc[m]:>17.1%}{hot.get(m, float('nan')):>21.0f}")
month temperature loss mean cell °C in sun
1 -0.3% 29
2 0.8% 30
3 3.8% 38
4 5.9% 44
5 5.1% 42
6 7.5% 50
7 9.6% 54
8 10.0% 55
9 7.2% 49
10 5.5% 45
11 1.5% 33
12 0.6% 33
In winter the cells are near 25 °C in full sun and the loss is close to zero; in July it is around a tenth. A site with the same irradiance and a cooler, windier summer produces more.
Why PVWatts is enough for siting
Why PVWatts is enough for siting
PVWatts treats the array as one power rating with a temperature coefficient. A single-diode model with a specific module's parameters is more precise, and the difference in annual energy is usually a percent or two — far below the uncertainty in the weather. For comparing sites, the simpler model is the right one; for a bankable yield on a chosen module, switch to the detailed model.
Why the loss budget should be explicit
PVGIS's default 14% hides several different things. Writing them out — 2% soiling, 2% mismatch, 1.5% wiring, 2% degradation in year one, and so on — makes each one checkable and makes the difference between sites with dusty summers and those with rainy ones visible.
Why wind speed at 10 m is used for cooling
The empirical temperature models (Faiman, SAPM) were fitted with 10 m wind speeds, so that is what they expect. Using 100 m wind here would make the modules unrealistically cool.
Snow, bifacial modules and clipping
- Snow covers modules and is not in PVWatts; in mountain or northern sites, add a snow-loss model or a monthly derate.
- Bifacial modules need the rear-side irradiance (
pvlib.bifacial) and a ground albedo. - Trackers:
pvlib.tracking.singleaxisgives the time-varying tilt and azimuth; feed those into the same chain. - Spectral effects are a percent or two and usually ignored at siting stage.
- Clipping grows with DC/AC ratio; model the inverter explicitly if the ratio exceeds 1.2.
Internal links
- Solar irradiance explained — the inputs to the chain
- How to download solar irradiance data in Python — getting and aligning the weather
- How to find the optimal tilt and orientation — running the chain over many angles
- Capacity factor explained — reporting the result honestly
- PV yield off by a factor — the unit errors this chain is prone to
- Solar timestamps shifted by an hour — the step-1 bug
FAQ
What is pvlib?
An open-source Python library of solar energy models: sun position, transposition, temperature, module and inverter models.
Which transposition model should I use?
Perez for yield estimates. Isotropic is simpler and underestimates diffuse light on tilted planes.
Why does my pvlib yield differ from PVGIS?
Different weather source, different loss assumptions, and possibly a time-convention error. A few percent is normal.
How big is the temperature loss?
Around 4–6% a year in central Spain, concentrated in summer; less in cool, windy climates.
Do I need a specific module datasheet?
Not for siting. PVWatts with a typical temperature coefficient is enough to compare sites.
How do I model a tracker?
Compute the tracker angles with pvlib.tracking.singleaxis and use them as the tilt and azimuth in the same chain.