fix(weather): apply Beer-Lambert extinction to clear-sky model

The simple atmosphericCeiling*sin(elevation) model overestimates clear-sky
radiation at low sun angles because it ignores the longer atmospheric path
light travels near the horizon. At 5° elevation the path is ~11x vertical,
making the real clear-sky ceiling ~26 W/m² rather than the 83 W/m² the
naive model predicts — causing sunny low-angle readings to be misclassified
as mostly cloudy.

Replace with the Beer-Lambert correction:
  expectedClearSky = 950 * exp(-opticalDepth / sin(elevation)) * sin(elevation)

opticalDepth=0.1 (clear atmosphere) produces accurate values across all
angles: ~26 W/m² at 5°, ~93 W/m² at 10°, ~715 W/m² at solar noon.
This commit is contained in:
Adriano Caloiaro 2026-04-12 07:38:30 -06:00
parent 367ab3ec98
commit 567845f88e
No known key found for this signature in database

View file

@ -11,11 +11,18 @@ type Condition struct {
Emoji string `json:"emoji"`
}
// atmosphericCeiling is the clear-sky solar radiation (W/m²) with the sun
// directly overhead at sea level. Actual clear-sky radiation at any other
// elevation angle scales by sin(elevation).
// atmosphericCeiling is the extraterrestrial solar irradiance at sea level (W/m²)
// with the sun directly overhead.
const atmosphericCeiling = 950.0
// opticalDepth is the vertical atmospheric optical depth used in the Beer-Lambert
// extinction model. At low sun angles the light path through the atmosphere is
// 1/sin(elevation) times longer than vertical, so the effective transmittance
// drops sharply. A value of 0.1 represents a clear sky and produces realistic
// clear-sky readings across all elevation angles (e.g. ~26 W/m² at 5°, ~715 W/m²
// at solar noon for mid-latitudes in spring).
const opticalDepth = 0.1
// solarPosition returns the solar elevation angle (radians) and hour angle
// (degrees) for the given latitude, longitude, and UTC time.
//
@ -80,8 +87,10 @@ func Infer(d Data, lat, lon float64) *Condition {
}
// Sun is above the horizon. Normalise against the clear-sky radiation
// expected at this elevation angle.
expectedClearSky := atmosphericCeiling * math.Sin(elevationRad)
// expected at this elevation angle, corrected for atmospheric extinction
// (Beer-Lambert): at low angles the light path is ~1/sin(elev) times
// longer than vertical, so the clear-sky ceiling drops sharply.
expectedClearSky := atmosphericCeiling * math.Exp(-opticalDepth/math.Sin(elevationRad)) * math.Sin(elevationRad)
if solar < 10 {
return &Condition{"Overcast", "☁️"}
}