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.

Toledo 2023 through the pvlib chain: horizontal irradiation, plane of array, reflection, temperature, DC losses and inverter. from GHI to AC energy, 1 kWp at 36°GHI, kWh/m²1,788tilt +19.3%plane of array2,132IAM −1.4%after reflection2,102−5.4%after temperature1,987−10.0%after DC losses1,789inverter −4.2%AC, kWh/kWp1,713PVGIS for the same site and tilt: 1,633 kWh/kWp (−4.9%) spatialworkflow.io
Each stage of the chain takes its share; the tilt is the only one that adds.

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.

Monthly temperature loss of a fixed PV array in Toledo in 2023, weighted by irradiance. temperature loss by month, ToledoJanuary−0.3% (cells below 25 °C)March3.8% · cells 38 °C in sunMay5.1% · 42 °CJuly9.6% · 54 °CAugust10.0% · 55 °COctober5.5% · 45 °CDecember0.6%06%12%Faiman cell temperature, −0.37%/°C; the annual loss is 5.4% spatialworkflow.io
Heat costs almost nothing in January and a tenth of the output in July.

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.

Plane-of-array irradiation in Toledo from three sky models. the sky model changes the diffuse partmodelPOA kWh/m²diffuse partisotropic2,035490Hay-Davies2,090545Perez2,132587the beam is identical in all three; only the sky differsIsotropic underestimates by 4.5% here, more in hazier climates spatialworkflow.io
The brighter sky around the sun is what the isotropic model leaves out.

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.singleaxis gives 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.

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.