How to Download Solar Irradiance Data in Python

On this page

Problem statement

Irradiance data is free, global and one HTTP request away, which makes it easy to download the wrong thing. PVGIS returns satellite-derived hourly values with timestamps at ten past the hour. Open-Meteo returns reanalysis values stamped at the end of each hour. A "typical meteorological year" is stitched together from twelve different real years. All three are legitimate, and mixing them without aligning their conventions produces a solar farm whose peak output is an hour off and whose annual yield depends on which source was queried.

This guide downloads each of them for Toledo, puts them on one time axis, and checks how much they agree.

Quick answer

PVGIS for satellite-derived irradiance in Europe, Africa and much of Asia; Open-Meteo for anywhere, and for wind and temperature on the same grid:

import requests, pandas as pd

LAT, LON = 39.86, -4.02

def pvgis_hourly(lat, lon, start, end, **kw):
    j = requests.get("https://re.jrc.ec.europa.eu/api/v5_3/seriescalc", params={
        "lat": lat, "lon": lon, "startyear": start, "endyear": end,
        "components": 1, "outputformat": "json", **kw}, timeout=300).json()
    d = pd.DataFrame(j["outputs"]["hourly"])
    d.index = pd.to_datetime(d.pop("time"), format="%Y%m%d:%H%M", utc=True)
    d["ghi"] = d["Gb(i)"] + d["Gd(i)"] + d["Gr(i)"]      # horizontal plane by default
    return d, j["inputs"]["meteo_data"]

def openmeteo_hourly(lat, lon, start, end, variables):
    j = requests.get("https://archive-api.open-meteo.com/v1/archive", params={
        "latitude": lat, "longitude": lon, "start_date": start, "end_date": end,
        "hourly": ",".join(variables), "timezone": "UTC"}, timeout=300).json()
    d = pd.DataFrame(j["hourly"])
    d.index = pd.to_datetime(d.pop("time")).dt.tz_localize("UTC")
    return d

pv, meta = pvgis_hourly(LAT, LON, 2023, 2023)
om = openmeteo_hourly(LAT, LON, "2023-01-01", "2023-12-31",
                      ["shortwave_radiation", "diffuse_radiation", "temperature_2m"])
print(f"PVGIS: {len(pv):,} rows from {meta['radiation_db']}, first stamp {pv.index[0]}")
print(f"Open-Meteo: {len(om):,} rows, first stamp {om.index[0]}")
print(f"annual GHI: PVGIS {pv.ghi.sum() / 1000:,.0f}, "
      f"Open-Meteo {om.shortwave_radiation.sum() / 1000:,.0f} kWh/mΒ²")
PVGIS: 8,760 rows from PVGIS-SARAH3, first stamp 2023-01-01 00:10:00+00:00
Open-Meteo: 8,760 rows, first stamp 2023-01-01 00:00:00+00:00
annual GHI: PVGIS 1,850, Open-Meteo 1,788 kWh/mΒ²

Both are hourly means in W/mΒ², so each sums to Wh/mΒ². They disagree by 3.5% over the year, which is typical between a satellite product and a reanalysis.

Decide what you need, request it, parse times explicitly

1. Decide what the analysis needs

A single-site yield estimate wants a long satellite record or a TMY. A study that combines irradiance with wind or temperature wants them from one consistent source. A forecast back-test wants the actual year.

2. Ask PVGIS for the horizontal plane unless you need a tilted one

seriescalc returns plane-of-array components. With no angle it uses 0Β° (horizontal), so Gb(i) + Gd(i) + Gr(i) is GHI. Pass angle and aspect and the same call returns irradiance on a tilted panel, with the horizon from its DEM already applied.

3. Parse the timestamps explicitly

PVGIS uses YYYYMMDD:HHMM in UTC, with minutes of 10 for SARAH data. Open-Meteo uses ISO strings in the time zone you requested. Always request UTC and localise explicitly.

4. Put both sources on the same hour

The two sources label an hour differently. Align them by measurement, not by assumption: shift one and check which offset maximises the agreement.

5. Use more than one year for anything that gets built

PVGIS has hourly data from 2005 (SARAH-3). Nineteen years give the long-term mean and its spread; one year gives an anecdote.

6. Cache every response

The data does not change. Save the raw JSON or a Parquet file once per site rather than calling the API from every notebook run.

PVGIS hourly, PVGIS TMY and the Open-Meteo archive compared by source, coverage, period and time label. three free sourcessourcederived fromperiodtime labelPVGIS seriescalcSARAH-3 satellite2005–2023HH:10, centredPVGIS tmy12 typical months2005–2023 poolHH:00Open-Meteo archiveERA5 reanalysis1940–nowend of hourToledo 2023 GHI: PVGIS 1,850, Open-Meteo 1,788, TMY 1,807 kWh/mΒ²Request UTC from each and align by measurement, not by label spatialworkflow.io
Three free sources, three time conventions.

Aligning PVGIS and reanalysis hours

The data used on this page

Solar irradiance from two free services, requested by the page's own code: the EU's PVGIS hourly series and typical meteorological year, and the Open-Meteo historical weather API, which serves ERA5-based radiation.

import requests

for name, url in (("PVGIS", "https://re.jrc.ec.europa.eu/api/v5_3/tmy?lat=52.1&lon=5.1&outputformat=json"),
                  ("Open-Meteo", "https://archive-api.open-meteo.com/v1/archive?latitude=52.1&longitude=5.1"
                                 "&start_date=2024-06-01&end_date=2024-06-01&hourly=shortwave_radiation")):
    print(name, requests.get(url, timeout=120).status_code)
PVGIS 200
Open-Meteo 200

Example 1 β€” aligning the hours by measurement

pv_hour = pv.ghi.copy()
pv_hour.index = pv_hour.index.floor("h")           # 12:10 β†’ 12:00

print(f"{'shift Open-Meteo by':>20}{'correlation':>13}{'mean |diff| W/mΒ²':>18}")
for lag in (-2, -1, 0, 1):
    both = pd.concat([pv_hour, om.shortwave_radiation.shift(lag, freq="h")],
                     axis=1, sort=True).dropna()
    corr = both.corr().iloc[0, 1]
    mad = (both.iloc[:, 0] - both.iloc[:, 1]).abs().mean()
    print(f"{lag:>18} h{corr:>13.3f}{mad:>18.1f}")
 shift Open-Meteo by  correlation  mean |diff| W/mΒ²
                -2 h        0.895              82.8
                -1 h        0.972              34.0
                 0 h        0.954              50.5
                 1 h        0.851             100.6

Shifting Open-Meteo back by one hour is the best match. Its value stamped 13:00 is the mean over 12:00–13:00, centred on 12:30; PVGIS's values behave as if centred on their stamp, 12:10. Joined on the raw hour, each PVGIS value meets an Open-Meteo value centred 40 minutes earlier; after the shift, 20 minutes later β€” as close as whole-hour shifts get.

Example 2 β€” a typical meteorological year

j = requests.get("https://re.jrc.ec.europa.eu/api/v5_3/tmy", params={
    "lat": LAT, "lon": LON, "outputformat": "json"}, timeout=300).json()
tmy = pd.DataFrame(j["outputs"]["tmy_hourly"])
print(tmy.columns.tolist())
print(f"TMY GHI {tmy['G(h)'].sum() / 1000:,.0f} kWh/mΒ², built from:")
print(" ".join(f"{m['month']:>2}:{m['year']}" for m in j["outputs"]["months_selected"]))
['time(UTC)', 'T2m', 'RH', 'G(h)', 'Gb(n)', 'Gd(h)', 'IR(h)', 'WS10m', 'WD10m', 'SP']
TMY GHI 1,807 kWh/mΒ², built from:
 1:2015  2:2011  3:2009  4:2007  5:2012  6:2018  7:2007  8:2023  9:2013 10:2010 11:2011 12:2010

The TMY is twelve real months from twelve different years, each chosen as the most typical for its month. It is the right input for a yield estimate and the wrong one for anything that depends on sequences β€” a battery study, for example, sees a join at every month boundary.

Example 3 β€” nineteen years at one site

long, _ = pvgis_hourly(LAT, LON, 2005, 2023)
annual = long.ghi.groupby(long.index.year).sum() / 1000
print(f"{len(annual)} years: mean {annual.mean():,.0f} kWh/mΒ², "
      f"std {annual.std():.0f} ({annual.std() / annual.mean():.1%})")
print(f"lowest {annual.idxmin()} ({annual.min():,.0f}), "
      f"highest {annual.idxmax()} ({annual.max():,.0f})")
print(f"2023 against the mean: {annual.loc[2023] / annual.mean() - 1:+.1%}")
19 years: mean 1,805 kWh/mΒ², std 40 (2.2%)
lowest 2008 (1,736), highest 2017 (1,859)
2023 against the mean: +2.5%

A single year can sit a few percent from the long-term mean in either direction, and the spread is the number a lender asks for: the P90 yield β€” the one exceeded in nine years out of ten β€” is computed from it.

The same midday hour of irradiance stamped 12:10 by PVGIS and 13:00 by Open-Meteo. one hour around solar noon, UTC12:00hour begins12:10PVGIS stamp, value centred here12:20solar noon in Toledo12:30centre of the Open-Meteo hour13:00Open-Meteo stampShift Open-Meteo back one hour to pair it with PVGIS: correlation 0.954 β†’ 0.972 spatialworkflow.io
One hour of sunshine, two labels: PVGIS stamps its values near their centre, Open-Meteo at the end of the hour.

Satellite against reanalysis, and typical years

Why the sources disagree by a few percent

PVGIS-SARAH derives irradiance from geostationary satellite images of cloud; ERA5 models clouds inside a weather model at about 30 km. The satellite sees the actual clouds; the reanalysis sees a physically consistent approximation. For irradiance the satellite usually wins, which is why PVGIS is the default for European siting.

Why TMY data is not a real year

A TMY is built by picking, for each calendar month, the historical month whose statistics are closest to the long-term distribution. Annual totals come out close to the long-term mean. Extremes β€” the cloudy week, the heatwave β€” are deliberately under-represented, which is why a TMY is not a stress test.

Why caching matters more than it seems

PVGIS limits request rates, and a multi-year hourly request for one point is several megabytes. A grid of points for a region is hundreds of requests. The polite and fast approach is one request per point, saved, and never repeated.

Annual horizontal irradiation in Toledo from PVGIS: lowest year, 19-year mean, 2023 and highest year. nineteen years at one point, kWh/mΒ²lowest: 20081,736mean 2005–20231,805 (std 40, 2.2%)20231,850 (+2.5%)highest: 20171,85909501900kWh/mΒ²The P90 yield comes from this spread, not from a single year spatialworkflow.io
A single year sits a few percent from the long-term mean; say which one you used.

Coverage, rate limits and time zones

  • PVGIS coverage: SARAH-3 covers Europe, Africa and parts of Asia and South America; elsewhere PVGIS falls back to ERA5 β€” meteo_data.radiation_db says which.
  • Rate limits: PVGIS allows around 30 calls per second; Open-Meteo's free tier has daily call limits. Batch politely.
  • Leap years: 8,784 rows, and the TMY has none.
  • Int column: PVGIS marks reconstructed hours with 1; check how many before using a year for validation.
  • Time zones: request UTC and convert at the end; local time plus daylight saving produces a duplicated and a missing hour each year.

FAQ

Where can I download free solar irradiance data?

PVGIS (satellite-derived, Europe/Africa/Asia) and Open-Meteo's historical archive (ERA5, global) both provide hourly data through free APIs with no key.

What does the PVGIS timestamp mean?

It is UTC, and the values behave as if centred on it β€” the day's irradiance centre falls on solar noon with no shift β€” so evaluate the sun at the stamp itself.

Is a TMY better than a real year?

For long-term yield, yes. For anything that depends on day-to-day sequences, use real years.

How many years should I use?

As many as the source has β€” at least ten. Report the mean and the spread.

Why don't PVGIS and Open-Meteo agree?

One is satellite-derived, the other a reanalysis. A few percent is normal; much more usually means misaligned timestamps or a different plane.

Can I get irradiance on a tilted panel directly?

Yes: pass angle and aspect to PVGIS seriescalc. It applies the horizon too.