07 — Solar components and energy closure
Learning objectives: distinguish GHI, DNI, DHI, and direct-horizontal irradiance; evaluate preceding-hour geometry; verify physical closure; and compare four primary solar paths. Automatic compilation also supports ERA5 and mixed sources.
Network: none. Expected runtime: under one minute. Most values are intentionally synthetic so each branch is controlled; the winter example preserves captured STRÅNG model values.
Instantaneously, GHI = DHI + DNI × cos(zenith). DNI describes a surface normal to the solar beam. For an hourly interval, closure uses mean horizontal beam: mean GHI = mean DHI + mean(DNI × cos(zenith)). Mean DNI times a single midpoint cosine is only an approximation.
Solar position uses pvlib’s NREL SPA nrel_numpy method and geometric (unrefracted) zenith. With delta_t=None, pvlib calculates the terrestrial-time correction for the UTC year/month. Extraterrestrial irradiance uses the asce method with a 1367 W/m² solar constant. The pipeline integrates these quantities at five-minute spacing over each preceding hour. The synthetic input below uses that same interval geometry.
The pipeline preserves usable supplied DNI at low sun. Dividing horizontal beam by a tiny cosine remains unstable, so the approximately 5° guard applies only when DNI must be inferred from GHI. This fallback uses the continuous Erbs-Driesse form; existing +erbs report labels identify the Erbs family. Historical DNI is used from 1999; horizontal beam before April 2017 is projected before averaging. A zero closure residual checks consistency, not the accuracy of modelled radiation.
[ ]:
from importlib.metadata import version
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from smhi2epw import processing, solar
print("pvlib version:", version("pvlib"))
LATITUDE, LONGITUDE = 57.7156, 11.9924
index = pd.date_range("2023-06-21", periods=24, freq="h", tz="UTC")
cosine_mean, etrh, etrn, dark = solar.interval_solar_geometry(
index, LATITUDE, LONGITUDE
)
midpoints = index - pd.Timedelta(minutes=30)
_, midpoint_cosine = solar.solar_zenith(midpoints, LATITUDE, LONGITUDE)
# This deliberate fixture margin controls synthetic daylight inputs.
# Supplied low-sun DNI in the captured winter example is preserved separately.
daylight = (cosine_mean > 0.087) & ~dark
ghi = np.where(daylight, 700 * cosine_mean, 0.0)
beam_horizontal = 0.65 * ghi
dni = np.divide(beam_horizontal, cosine_mean, out=np.zeros_like(ghi), where=daylight)
[ ]:
inputs = {
"strang": pd.DataFrame(
{"ghi": ghi, "dni": dni, "dirh": beam_horizontal}, index=index
),
"measured+strang_partition": pd.DataFrame(
{"ghi": ghi, "dni": dni, "dirh": beam_horizontal, "ghi_measured": 0.9 * ghi},
index=index,
),
"strang_ghi+erbs": pd.DataFrame({"ghi": ghi}, index=index),
"measured+erbs": pd.DataFrame({"ghi": ghi, "ghi_measured": 0.9 * ghi}, index=index),
}
rows = []
outputs = {}
for intended, frame in inputs.items():
report = processing.ProcessingReport()
processing.apply_solar(frame, LATITUDE, LONGITUDE, report)
outputs[intended] = frame
rows.append(
{
"intended_path": intended,
"reported_path": report.solar_source,
"max_residual_W_m2": report.energy_balance_max_residual,
"clamped_hours": report.clamped_dni_hours,
}
)
pd.DataFrame(rows).set_index("intended_path")
[ ]:
frame = outputs["measured+strang_partition"]
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(index, frame.ghi, label="GHI")
ax.plot(index, frame.dhi, label="DHI")
ax.plot(index, frame.dni, label="DNI")
ax.set(ylabel="Irradiance (W/m²)", xlabel="UTC hour label")
ax.legend()
fig.autofmt_xdate()
fig.tight_layout()
[ ]:
# Close against the mean horizontal beam, which the pipeline preserves.
residual = frame.ghi.to_numpy() - (frame.dhi.to_numpy() + frame.dirh.to_numpy())
float(np.max(np.abs(residual)))
A low-sun STRÅNG model sample
At 65°N, 20°E on 1 December 2023, SMHI STRÅNG supplied positive DNI below 5° solar elevation. The values below are captured adjacent-hour means from parameters 117, 118 and 121, retrieved on 6 October 2026. They are model outputs, not independent pyranometer measurements. They demonstrate why supplied low-sun DNI must be preserved.
[ ]:
winter = pd.DataFrame(
{"ghi": [24.25, 23.30], "dni": [213.25, 342.25], "dirh": [12.05, 15.95]},
index=pd.to_datetime(["2023-12-01T11:00Z", "2023-12-01T12:00Z"]),
)
processing.apply_solar(winter, 65.0, 20.0)
# These supplied interval means survive; DHI closes against horizontal beam.
np.testing.assert_allclose(winter.dni, [213.25, 342.25])
np.testing.assert_allclose(winter.dhi, [12.20, 7.35])
winter[["ghi", "dni", "dirh", "dhi"]]
Key takeaways and exercise
Measured GHI scales the model component group; STRÅNG or continuous Erbs-Driesse supplies the partition. Supplied DNI and horizontal beam are separate interval means. Physical caps change both together, then DHI closes the horizontal balance. New EPW receipts record pvlib_version for reproducibility.
Try it: multiply measured GHI by 1.5 while leaving STRÅNG unchanged. Which components scale together, and when does the extraterrestrial DNI cap become active? Compare the horizontal-beam closure above with frame.ghi - frame.dhi - frame.dni * midpoint_cosine and explain why they can differ.