Solar Irradiance Explained
On this page
Problem statement
Every solar dataset offers a column called something like shortwave_radiation, GHI or G(h), and it is tempting to treat it as "how sunny it is" and multiply by a panel area. That misses three things. The number is a rate (watts per square metre) and the energy is its integral over time. It is measured on a horizontal surface, and panels are tilted. And it is the sum of two very different components — a beam from the sun's disc and diffuse light from the whole sky — which respond to tilt, cloud and shading in opposite ways.
Toledo in central Spain receives 61% more horizontal irradiation over a year than Hamburg in northern Germany, but the difference is not uniform: Hamburg's light is 41% diffuse, Toledo's 28%, and that share decides how much a tilted panel or a tracker gains in each place.
Quick answer
Irradiance is W/m² at an instant or averaged over an interval; irradiation is the energy, Wh/m² or kWh/m², summed over time. Three components describe it:
- GHI — global horizontal irradiance, everything arriving on a flat surface.
- DNI — direct normal irradiance, the beam measured perpendicular to the sun.
- DHI — diffuse horizontal irradiance, the sky without the sun's disc.
They are tied by the solar zenith angle
import requests, pandas as pd
om = requests.get("https://archive-api.open-meteo.com/v1/archive", params={
"latitude": 39.86, "longitude": -4.02,
"start_date": "2023-01-01", "end_date": "2023-12-31",
"hourly": "shortwave_radiation,direct_normal_irradiance,diffuse_radiation",
"timezone": "UTC"}, timeout=120).json()
df = pd.DataFrame(om["hourly"]).set_index("time")
print(om["hourly_units"])
# Each value is an hourly mean in W/m², so the sum over hours is Wh/m².
annual = df.sum() / 1000
print(f"GHI {annual.shortwave_radiation:,.0f} kWh/m² per year")
print(f"DHI {annual.diffuse_radiation:,.0f} kWh/m² per year "
f"({annual.diffuse_radiation / annual.shortwave_radiation:.0%} of GHI)")
print(f"DNI {annual.direct_normal_irradiance:,.0f} kWh/m² per year")
{'time': 'iso8601', 'shortwave_radiation': 'W/m²', 'direct_normal_irradiance': 'W/m²', 'diffuse_radiation': 'W/m²'}
GHI 1,788 kWh/m² per year
DHI 494 kWh/m² per year (28% of GHI)
DNI 2,243 kWh/m² per year
DNI is larger than GHI over a year. It is not a mistake: DNI is measured on a surface that always faces the sun, which is exactly what a two-axis tracker is.
Rate or total, which surface, beam and diffuse
1. Separate the rate from the total
W/m² is power; kWh/m² is energy. A dataset of hourly means sums directly to Wh/m², but only because each value represents one hour. For 15-minute data the same sum is four times too large unless each value is multiplied by 0.25 h.
2. Know which surface the number is on
GHI is horizontal. DNI is normal to the beam. Plane-of-array (POA) irradiance is on the tilted panel and is what a PV model actually needs; it has to be computed from the other three.
3. Split the global value into beam and diffuse
The split decides almost everything downstream. Beam light can be focused, tracked and shaded by a single pole; diffuse light comes from the whole sky and barely cares which way the panel faces.
4. Compare against a clear-sky model
A clear-sky model gives the irradiance with no cloud at all. The ratio of measured to clear-sky irradiation — the clear-sky index — is a far better description of a climate than GHI alone, because it removes the effect of latitude.
5. Check the time convention
Every hourly value is an average over some hour. Open-Meteo labels the hour by its end; PVGIS labels it at ten past. A one-hour misalignment moves the sun by 15° and shows up as a lopsided daily curve.
6. Use enough years
A single year can be 5–10% off the long-term mean. For siting, use a typical meteorological year or a multi-year average, and report which.
Closing the irradiance equation on real data
Example 1 — the closure equation, checked on real data
import numpy as np, pvlib
times = pd.to_datetime(df.index).tz_localize("UTC")
# Open-Meteo values are means over the preceding hour: evaluate the sun at mid-hour.
solpos = pvlib.solarposition.get_solarposition(
times - pd.Timedelta("30min"), 39.86, -4.02)
cosz = np.cos(np.radians(solpos["zenith"].to_numpy()))
day = cosz > 0.1
ghi = df.shortwave_radiation.to_numpy()
rebuilt = df.direct_normal_irradiance.to_numpy() * cosz + df.diffuse_radiation.to_numpy()
err = rebuilt[day] - ghi[day]
print(f"daytime hours: {day.sum():,}")
print(f"DNI·cos(z) + DHI − GHI: median {np.median(err):.1f} W/m², "
f"95th pct of |error| {np.percentile(abs(err), 95):.1f} W/m²")
daytime hours: 4,001
DNI·cos(z) + DHI − GHI: median 0.9 W/m², 95th pct of |error| 2.2 W/m²
The three columns close to within a few W/m² once the sun is evaluated at the middle of each averaging hour. Evaluate it at the label instead and the 95th-percentile error grows from 2 to 59 W/m² — that test is the quickest way to find out which convention a dataset uses.
Example 2 — two climates, one number each
def year(lat, lon):
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,diffuse_radiation",
"timezone": "UTC"}, timeout=120).json()
d = pd.DataFrame(j["hourly"])
d.index = pd.to_datetime(d.pop("time")).dt.tz_localize("UTC")
return d, j["elevation"]
print(f"{'site':<9}{'GHI':>7}{'diffuse':>9}{'clear-sky':>11}{'index':>7}")
for name, (lat, lon) in {"Toledo": (39.86, -4.02), "Hamburg": (53.55, 9.99)}.items():
d, elev = year(lat, lon)
cs = pvlib.location.Location(lat, lon, altitude=elev).get_clearsky(
d.index - pd.Timedelta("30min"))
ghi = d.shortwave_radiation.sum() / 1000
print(f"{name:<9}{ghi:>7,.0f}{d.diffuse_radiation.sum() / d.shortwave_radiation.sum():>9.0%}"
f"{cs.ghi.sum() / 1000:>11,.0f}{ghi / (cs.ghi.sum() / 1000):>7.2f}")
site GHI diffuse clear-sky index
Toledo 1,788 28% 1,971 0.91
Hamburg 1,108 41% 1,478 0.75
Toledo receives 680 kWh/m² more than Hamburg, but most of that gap is not cloud: even under permanently clear skies Hamburg would get 493 kWh/m² less, because the sun sits lower. The clear-sky index separates the two — 0.91 against 0.75 — and it is the cloud part that a reader usually means by "sunnier".
Example 3 — the month-by-month shape
d, _ = year(39.86, -4.02)
h, _ = year(53.55, 9.99)
monthly = pd.DataFrame({
"Toledo": d.shortwave_radiation.resample("ME").sum() / 1000,
"Hamburg": h.shortwave_radiation.resample("ME").sum() / 1000})
monthly.index = monthly.index.strftime("%b")
monthly["ratio"] = monthly.Toledo / monthly.Hamburg
print(monthly.round(1).to_string())
Toledo Hamburg ratio
time
Jan 69.7 18.8 3.7
Feb 98.0 36.1 2.7
Mar 149.5 70.4 2.1
Apr 199.2 124.6 1.6
May 205.7 172.5 1.2
Jun 211.4 195.8 1.1
Jul 245.8 162.7 1.5
Aug 218.6 136.5 1.6
Sep 148.5 112.3 1.3
Oct 111.2 45.7 2.4
Nov 68.1 20.7 3.3
Dec 62.3 11.5 5.4
In June the two sites are within 8% of each other; in December Toledo gets more than five times as much. A solar farm's winter output is where latitude really bites, and an annual total hides it.
Example 4 — satellite against reanalysis for the same year
print(f"{'site':<9}{'ERA5':>7}{'SARAH-3':>9}{'difference':>12}")
for name, (lat, lon) in {"Toledo": (39.86, -4.02), "Hamburg": (53.55, 9.99)}.items():
era5 = year(lat, lon)[0].shortwave_radiation.sum() / 1000
j = requests.get("https://re.jrc.ec.europa.eu/api/v5_3/seriescalc", params={
"lat": lat, "lon": lon, "startyear": 2023, "endyear": 2023,
"angle": 0, "aspect": 0, "components": 1, "outputformat": "json"}, timeout=120).json()
sarah = sum(h["Gb(i)"] + h["Gd(i)"] + h["Gr(i)"] for h in j["outputs"]["hourly"]) / 1000
print(f"{name:<9}{era5:>7,.0f}{sarah:>9,.0f}{sarah / era5 - 1:>+12.1%}")
site ERA5 SARAH-3 difference
Toledo 1,788 1,850 +3.5%
Hamburg 1,108 1,080 -2.5%
With a horizontal plane, PVGIS's series is the SARAH-3 satellite estimate of GHI for the same hours as Open-Meteo's ERA5 year.
Why DNI exceeds GHI, and why sources disagree
Why DNI exceeds GHI over a year
GHI is on a horizontal surface, which sees the sun at a slant for most of the day. DNI is on a surface that always faces the sun. At noon in June in Toledo the two are close; at 8 a.m. the beam on the flat ground is spread over twice the area. Summed over a year, the beam as a tracker sees it is larger than everything the flat ground receives, even though the flat ground also gets the diffuse sky.
Why diffuse fraction matters more than it looks
A tilted panel gains on beam light and loses a little on diffuse (it sees less of the sky). A tracker gains a lot on beam and nothing on diffuse. So in Toledo, where 72% of the energy is beam, tilt and tracking pay; in Hamburg, with 41% diffuse, they pay noticeably less. The same product brochure number is wrong in one of the two places.
Why a satellite and a reanalysis disagree
PVGIS uses SARAH-3, derived from geostationary satellite images; Open-Meteo's archive uses ERA5 reanalysis. For Toledo in 2023 PVGIS is 3.5% higher (1,850 against 1,788 kWh/m²); for Hamburg it is 2.6% lower (Example 4). Satellite-derived irradiance is usually the better of the two for siting; reanalysis is consistent everywhere and has wind and temperature on the same grid.
Night values, albedo and daily units
- Negative or tiny night values appear in some measured series from sensor offsets; clip to zero before summing.
- Albedo adds a reflected component to a tilted panel — a few percent over grass, more over snow or sand.
- Clear-sky models need a turbidity estimate; the default climatology in pvlib is adequate for a clearness index, not for forecasting.
- Station data (pyranometers) measure GHI directly; DNI and DHI at stations require a tracker and a shaded pyranometer, and are rarer.
- kWh/m²/day is a common alternative unit (a "peak sun hour" is 1 kWh/m²); divide an annual total by 365 to compare.
Internal links
- Capacity factor explained — how irradiance becomes a yield per installed kilowatt
- How to download solar irradiance data in Python — PVGIS and Open-Meteo in detail
- How to model PV output with pvlib — from GHI to plane-of-array to kWh
- Solar timestamps shifted by an hour — the time-convention fix
- How to estimate solar potential on roofs — the same physics on a building
- NetCDF and gridded data explained — what a reanalysis cell is
FAQ
What is the difference between irradiance and irradiation?
Irradiance is power per area (W/m²). Irradiation is energy per area (Wh/m² or kWh/m²), the irradiance integrated over time.
What are GHI, DNI and DHI?
Global horizontal, direct normal and diffuse horizontal irradiance. GHI equals DNI times the cosine of the solar zenith angle plus DHI.
Why is annual DNI larger than GHI?
DNI is measured facing the sun, GHI on flat ground that sees it at an angle. Only a sun-tracking surface receives DNI.
How do I convert hourly W/m² to kWh/m²?
Sum the hourly means and divide by 1,000. For sub-hourly data, multiply each value by its interval in hours first.
Is PVGIS or Open-Meteo more accurate?
PVGIS's satellite-derived SARAH data is usually better for irradiance in Europe and Africa; ERA5 via Open-Meteo is global and consistent with its wind and temperature fields.
What is the clear-sky index?
The ratio of measured to modelled cloud-free irradiance. It isolates cloudiness from latitude and season.