Solar — PV resource assessment from ERA5¶
Solar developers need long-term mean global horizontal irradiance (GHI) at a candidate site to forecast PV array energy yield. ERA5 exposes downward shortwave radiation at the surface as a flux — accumulated joules per square metre per timestep. Aggregating over a month and dividing by the seconds in the month gives the average power density in W/m².
Domain context. A simplified PV yield estimate:
$$ E_\text{PV} = \eta \cdot A \cdot \overline{\text{GHI}} \cdot t $$
where $\eta$ is module efficiency (~0.20 for crystalline silicon), $A$ is array area (m²), and $t$ is the period. Site assessment is really a question about $\overline{\text{GHI}}$ and its seasonal variability. ERA5 gives 40+ years of monthly fields to evaluate it.
Step 1 — the radiation variable¶
surface-solar-radiation-downwards is the total shortwave flux
reaching the surface (direct + diffuse). It's a flux, so op="auto"
→ sum.
from earthlens.ecmwf import Catalog
cat = Catalog()
spec = cat.get_variable(
"reanalysis-era5-single-levels", "surface-solar-radiation-downwards"
)
print(f"nc_variable: {spec.nc_variable}")
print(f"units: {spec.units}")
print(f"is_flux: {spec.is_flux} # auto -> sum")
nc_variable: ssrd units: J m**-2 is_flux: False # auto -> sum
Step 2 — pull two years monthly over a candidate site¶
Box: 1° around Seville, Spain (37°–38°N, 6°–5°W) — a region with well-known strong solar resource. 24 monthly values let us see the seasonal cycle and inter-annual variability.
from pathlib import Path
from earthlens import EarthLens, AggregationConfig
OUT = Path("data/era5-solar-seville")
OUT.mkdir(parents=True, exist_ok=True)
earthlens = EarthLens(
data_source="ecmwf",
temporal_resolution="monthly",
start="2021-01-01",
end="2022-12-01",
variables={
"reanalysis-era5-single-levels-monthly-means": [
"surface-solar-radiation-downwards",
],
},
lat_lim=[37.0, 38.0],
lon_lim=[-6.0, -5.0],
path=str(OUT),
)
earthlens.download(aggregate=AggregationConfig(freq="1MS", op="auto"))
2026-05-10 01:44:57.547 | INFO | earthlens.ecmwf.backend:download:536 - Download ECMWF reanalysis-era5-single-levels-monthly-means/surface-solar-radiation-downwards data for period 2021-01-01 00:00:00 till 2022-12-01 00:00:00
2026-05-10 01:44:58.267 | INFO | earthlens.ecmwf.backend:_api:724 - Requesting reanalysis-era5-single-levels-monthly-means from CDS; this may take several minutes
2026-05-10 01:44:58,514 INFO Request ID is a3f6fa48-9d89-446c-b5b3-ca2a7a568a16
2026-05-10 01:44:58,618 INFO status has been updated to accepted
2026-05-10 01:45:20,162 INFO status has been updated to running
2026-05-10 01:45:31,642 INFO status has been updated to successful
2026-05-10 01:45:32 | INFO | pyramids.base.config | Logging is configured.
2026-05-10 01:45:33.121 | INFO | earthlens.ecmwf.backend:download:575 - ECMWF download summary: all 1 variables succeeded ([('reanalysis-era5-single-levels-monthly-means', 'surface-solar-radiation-downwards')])
Step 3 — convert J/m² to W/m² and to kWh/m²/day¶
The monthly GeoTIFF carries the sum of per-step accumulations — total
Joules per square metre over the whole month. Divide by
(seconds-in-month × 1) to recover an average power density (W/m²), or
by (3.6e6 × days) to express in kWh/m²/day.
import numpy as np
import pandas as pd
from calendar import monthrange
from pyramids.dataset import Dataset
agg = OUT / "aggregated"
paths = sorted(agg.glob("surface_solar_radiation_downwards_1MS_*.tif"))
joules_per_m2 = np.stack([Dataset.read_file(str(p)).read_array() for p in paths])
months = pd.date_range("2021-01-01", periods=len(paths), freq="MS")
site_total_J = np.nanmean(joules_per_m2, axis=(1, 2))
secs = np.array([monthrange(m.year, m.month)[1] * 86400 for m in months])
days = secs / 86400
Wpm2 = site_total_J / secs # average power density
kWh_day = site_total_J / (3.6e6 * days) # daily energy yield per m²
df = pd.DataFrame(
{"GHI [W/m²]": Wpm2.round(1), "GHI [kWh/m²/day]": kWh_day.round(2)},
index=months,
)
df
| GHI [W/m²] | GHI [kWh/m²/day] | |
|---|---|---|
| 2021-01-01 | 3.1 | 0.07 |
| 2021-02-01 | 5.0 | 0.12 |
| 2021-03-01 | 7.1 | 0.17 |
| 2021-04-01 | 7.5 | 0.18 |
| 2021-05-01 | 9.9 | 0.24 |
| 2021-06-01 | 10.8 | 0.26 |
| 2021-07-01 | 10.9 | 0.26 |
| 2021-08-01 | 9.6 | 0.23 |
| 2021-09-01 | 7.3 | 0.18 |
| 2021-10-01 | 5.7 | 0.14 |
| 2021-11-01 | 4.2 | 0.10 |
| 2021-12-01 | 2.9 | 0.07 |
| 2022-01-01 | 3.9 | 0.09 |
| 2022-02-01 | 5.6 | 0.13 |
| 2022-03-01 | 5.2 | 0.12 |
| 2022-04-01 | 8.0 | 0.19 |
| 2022-05-01 | 10.1 | 0.24 |
| 2022-06-01 | 10.9 | 0.26 |
| 2022-07-01 | 10.9 | 0.26 |
| 2022-08-01 | 9.6 | 0.23 |
| 2022-09-01 | 7.8 | 0.19 |
| 2022-10-01 | 5.3 | 0.13 |
| 2022-11-01 | 3.7 | 0.09 |
| 2022-12-01 | 2.8 | 0.07 |
Step 4 — seasonal cycle plot¶
Seville's resource peaks in June–July (~7+ kWh/m²/day) and bottoms around December (~2.5 kWh/m²/day) — typical for a Mediterranean site. Inter-annual variability between 2021 and 2022 is small for a well-sited PV plant.
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(months, kWh_day, marker="o", lw=2, color="tab:orange")
ax.fill_between(months, 0, kWh_day, alpha=0.15, color="tab:orange")
ax.set_ylabel("GHI [kWh/m²/day]")
ax.set_title("Monthly mean global horizontal irradiance — Seville bbox, 2021–2022")
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
Notes¶
- GHI vs DNI. ERA5 gives global horizontal irradiance — the right metric for fixed-tilt PV. For concentrated solar power (CSP) you need direct normal irradiance, derived from GHI and a decomposition model (DIRINT, Erbs).
- Clear-sky. ERA5 also exposes
surface-solar-radiation-downward-clear-skyfor the cloud-free upper bound. Cloud-radiative-effect = total - clear-sky. - Inter-annual variability. A real bankability study uses a 20-year record and bootstrapped confidence intervals on the long-term mean. Two years here is illustrative only.
- Tilt correction. Real PV arrays tilt; converting GHI to in-plane irradiance for a tilted array uses the angle-of-incidence formulae from Duffie & Beckman.