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.
Setup¶
Consolidate the imports up front. earthlens provides the unified
EarthLens entry point and the ecmwf.Catalog / AggregationConfig
helpers; pyramids provides Dataset for reading the downloaded
GeoTIFFs; numpy / pandas / matplotlib handle the arithmetic and
the plot.
from calendar import monthrange
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from pyramids.dataset import Dataset
from earthlens import AggregationConfig, EarthLens
from earthlens.ecmwf import Catalog
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.
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")
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.
Build the request¶
Pick an output directory and describe the request — source, monthly cadence, date window, ERA5 monthly-means dataset, the radiation variable, and the Seville bounding box.
OUT = Path("data/era5-solar-seville")
OUT.mkdir(parents=True, exist_ok=True)
earthlens = EarthLens(
data_source="ecmwf",
cadence="monthly",
start="2021-01-01",
end="2022-12-01",
dataset="reanalysis-era5-single-levels-monthly-means",
variables=[
"surface-solar-radiation-downwards",
],
aoi=[-6.0, 37.0, -5.0, 38.0],
path=str(OUT),
)
Download with monthly aggregation¶
download() requests the data from the CDS (this may take a few
minutes) and re-aggregates each field to a monthly sum via
AggregationConfig. The call is kept on its own line so the request
above stays readable.
earthlens.download(aggregate=AggregationConfig(freq="1MS", op="auto"))
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.
Read the aggregated monthly rasters¶
Glob the aggregated GeoTIFFs in date order and stack their arrays into a single cube of joules per square metre.
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])
Reduce to a site mean and convert units¶
Average over the spatial dimensions to get one monthly value for the
site, then divide the monthly Joule total by the seconds in the month
(for W/m²) and by 3.6e6 × days (for kWh/m²/day).
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²
Tabulate the monthly series¶
Collect the two derived series into a DataFrame indexed by month so
the seasonal pattern is easy to read.
df = pd.DataFrame(
{"GHI [W/m²]": Wpm2.round(1), "GHI [kWh/m²/day]": kWh_day.round(2)},
index=months,
)
df
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.
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.