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.
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.
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.
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_dbsays 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.
Intcolumn: 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.
Internal links
- Solar irradiance explained β GHI, DNI, DHI and their units
- How to model PV output with pvlib β using the downloaded data
- Solar timestamps shifted by an hour β the alignment bug in full
- How to find the optimal tilt and orientation β PVGIS's tilted-plane data put to work
- How to rate limit calls to a spatial API β polite batch downloading
- NetCDF and gridded data explained β the reanalysis grids behind Open-Meteo
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.