mirror of
https://github.com/acaloiaro/roam-location
synced 2026-07-21 10:12:21 +00:00
refactor(weather): derive conditions from solar elevation angle
Replace the sinusoidal day model with solarPosition(), which computes the actual solar elevation and hour angle from latitude, longitude, and UTC time. Expected clear-sky radiation at any moment is simply atmosphericCeiling * sin(elevation), eliminating all hardcoded or approximated sunrise/sunset windows. Dawn/dusk is now detected when the sun is geometrically below the horizon but the sensor still reads positive (scattered twilight), and morning vs. afternoon is determined from the sign of the hour angle rather than a clock-time comparison.
This commit is contained in:
parent
7099961ba3
commit
367ab3ec98
1 changed files with 43 additions and 69 deletions
|
|
@ -12,41 +12,32 @@ type Condition struct {
|
|||
}
|
||||
|
||||
// atmosphericCeiling is the clear-sky solar radiation (W/m²) with the sun
|
||||
// directly overhead at sea level. This is the physical upper bound; actual
|
||||
// noon peaks are lower everywhere outside the tropics and scale with sin(elevation).
|
||||
// directly overhead at sea level. Actual clear-sky radiation at any other
|
||||
// elevation angle scales by sin(elevation).
|
||||
const atmosphericCeiling = 950.0
|
||||
|
||||
// solarNoonPeak returns the approximate clear-sky solar radiation (W/m²) at
|
||||
// solar noon for the given latitude and date. It accounts for both seasonal
|
||||
// declination and latitude so the normalization in Infer stays accurate year-round.
|
||||
func solarNoonPeak(lat float64, t time.Time) float64 {
|
||||
declinationDeg := 23.45 * math.Sin(2*math.Pi/365*float64(t.YearDay()-81))
|
||||
elevationDeg := 90.0 - math.Abs(lat-declinationDeg)
|
||||
if elevationDeg <= 0 {
|
||||
return 0
|
||||
}
|
||||
return atmosphericCeiling * math.Sin(elevationDeg * math.Pi / 180)
|
||||
// solarPosition returns the solar elevation angle (radians) and hour angle
|
||||
// (degrees) for the given latitude, longitude, and UTC time.
|
||||
//
|
||||
// Elevation > 0 means the sun is above the horizon.
|
||||
// Hour angle < 0 means the sun is east of the meridian (morning);
|
||||
// > 0 means west of the meridian (afternoon).
|
||||
func solarPosition(lat, lon float64, t time.Time) (elevationRad, hourAngleDeg float64) {
|
||||
decRad := 23.45 * math.Pi / 180 * math.Sin(2*math.Pi/365*float64(t.YearDay()-81))
|
||||
utcHours := float64(t.Hour()) + float64(t.Minute())/60.0
|
||||
// math.Remainder normalises to [-180, 180], handling midnight crossings cleanly.
|
||||
hourAngleDeg = math.Remainder((utcHours-(12.0-lon/15.0))*15, 360)
|
||||
hourAngleRad := hourAngleDeg * math.Pi / 180
|
||||
latRad := lat * math.Pi / 180
|
||||
sinElev := math.Sin(latRad)*math.Sin(decRad) + math.Cos(latRad)*math.Cos(decRad)*math.Cos(hourAngleRad)
|
||||
elevationRad = math.Asin(math.Max(-1, math.Min(1, sinElev)))
|
||||
return
|
||||
}
|
||||
|
||||
// twilightSolar is the solar radiation threshold (W/m²) below which the sun is considered
|
||||
// low in the sky, producing dawn or dusk conditions.
|
||||
const twilightSolar = 30.0
|
||||
|
||||
// sunFraction returns the fraction of peak solar radiation expected at the given
|
||||
// local time of day, using a simple sinusoidal model (sunrise ~6, sunset ~18).
|
||||
// Returns 0 outside of daylight hours.
|
||||
func sunFraction(localMinutes int) float64 {
|
||||
localMinutes = ((localMinutes % 1440) + 1440) % 1440
|
||||
localHourF := float64(localMinutes) / 60.0
|
||||
if localHourF < 6 || localHourF >= 18 {
|
||||
return 0
|
||||
}
|
||||
return math.Sin(math.Pi * (localHourF - 6) / 12.0)
|
||||
}
|
||||
|
||||
// Infer derives a weather condition from sensor readings and approximate location.
|
||||
// Solar radiation is normalized against the expected clear-sky maximum for the
|
||||
// current time of day, so conditions are accurate at any hour.
|
||||
// Infer derives a weather condition from sensor readings and position.
|
||||
// Solar radiation is normalised against the clear-sky expectation for the
|
||||
// actual solar elevation angle, so conditions are accurate at any time of day
|
||||
// without any hardcoded sunrise or sunset times.
|
||||
func Infer(d Data, lat, lon float64) *Condition {
|
||||
solar := 0.0
|
||||
if d.SolarRadiation != nil {
|
||||
|
|
@ -65,52 +56,36 @@ func Infer(d Data, lat, lon float64) *Condition {
|
|||
return &Condition{"Rainy", "🌧️"}
|
||||
}
|
||||
|
||||
// Near-rain: very high humidity and overcast.
|
||||
// Near-rain: very high humidity with no solar.
|
||||
if humidity >= 95 && solar < 10 {
|
||||
return &Condition{"Foggy", "🌫️"}
|
||||
}
|
||||
|
||||
// Estimate local time from longitude (rough UTC offset).
|
||||
// Use the current wall-clock time rather than d.LastUpdated so that a stale
|
||||
// cache entry (e.g. after a connectivity gap) doesn't cause the sun-angle
|
||||
// calculation to reflect an hour from earlier in the day.
|
||||
now := time.Now().UTC()
|
||||
utcMinutes := now.Hour()*60 + now.Minute()
|
||||
localMinutes := utcMinutes + int(math.Round(lon/15))*60
|
||||
localMinutes = ((localMinutes % 1440) + 1440) % 1440
|
||||
fraction := sunFraction(localMinutes)
|
||||
isDaytime := fraction > 0
|
||||
isMorning := localMinutes < 720
|
||||
elevationRad, hourAngleDeg := solarPosition(lat, lon, time.Now().UTC())
|
||||
isMorning := hourAngleDeg < 0 // sun east of meridian
|
||||
|
||||
// Dawn/dusk: sensor detects low but positive solar radiation while the sun is
|
||||
// geometrically near the horizon (fraction < 0.2 or before/after model daylight).
|
||||
// isMorning distinguishes the two without hard-coding sunrise/sunset hours.
|
||||
if solar > 0 && solar < twilightSolar && (!isDaytime || fraction < 0.2) {
|
||||
if isMorning {
|
||||
return &Condition{"Dawn", "🌅"}
|
||||
}
|
||||
return &Condition{"Dusk", "🌇"}
|
||||
}
|
||||
|
||||
if !isDaytime || solar < 10 {
|
||||
if !isDaytime {
|
||||
if humidity > 80 {
|
||||
return &Condition{"Cloudy night", "☁️"}
|
||||
if elevationRad <= 0 {
|
||||
// Sun is below the horizon.
|
||||
if solar > 0 {
|
||||
// Sensor still catching scattered twilight.
|
||||
if isMorning {
|
||||
return &Condition{"Dawn", "🌅"}
|
||||
}
|
||||
return &Condition{"Clear night", "🌙"}
|
||||
return &Condition{"Dusk", "🌇"}
|
||||
}
|
||||
if humidity > 80 {
|
||||
return &Condition{"Cloudy night", "☁️"}
|
||||
}
|
||||
// Daytime with no solar — heavy overcast or dense cloud.
|
||||
return &Condition{"Overcast", "☁️"}
|
||||
}
|
||||
|
||||
// Normalize solar radiation against the expected clear-sky max for this hour.
|
||||
// Clamp to [0, 1] to absorb sensor noise or unusually bright readings (e.g.
|
||||
// cloud-edge reflection).
|
||||
peak := solarNoonPeak(lat, now)
|
||||
if peak == 0 {
|
||||
return &Condition{"Clear night", "🌙"}
|
||||
}
|
||||
normalized := math.Min(solar/(peak*fraction), 1.0)
|
||||
|
||||
// Sun is above the horizon. Normalise against the clear-sky radiation
|
||||
// expected at this elevation angle.
|
||||
expectedClearSky := atmosphericCeiling * math.Sin(elevationRad)
|
||||
if solar < 10 {
|
||||
return &Condition{"Overcast", "☁️"}
|
||||
}
|
||||
normalized := math.Min(solar/expectedClearSky, 1.0)
|
||||
|
||||
if normalized > 0.85 {
|
||||
return &Condition{"Sunny", "🌞"}
|
||||
|
|
@ -123,4 +98,3 @@ func Infer(d Data, lat, lon float64) *Condition {
|
|||
}
|
||||
return &Condition{"Overcast", "☁️"}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue