Add day/night estimation for NWS observations based on solar elevation and update observation model.
This commit is contained in:
74
internal/normalizers/nws/day_night.go
Normal file
74
internal/normalizers/nws/day_night.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// FILE: internal/normalizers/nws/day_night.go
|
||||||
|
package nws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
degToRad = math.Pi / 180.0
|
||||||
|
radToDeg = 180.0 / math.Pi
|
||||||
|
)
|
||||||
|
|
||||||
|
func observationLatLon(coords []float64) (lat *float64, lon *float64) {
|
||||||
|
if len(coords) < 2 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
latVal := coords[1]
|
||||||
|
lonVal := coords[0]
|
||||||
|
return &latVal, &lonVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDayFromLatLonTime estimates day/night from solar elevation.
|
||||||
|
// Uses a refraction-adjusted cutoff (-0.833 degrees).
|
||||||
|
func isDayFromLatLonTime(lat, lon float64, ts time.Time) *bool {
|
||||||
|
if ts.IsZero() || math.IsNaN(lat) || math.IsNaN(lon) || math.IsInf(lat, 0) || math.IsInf(lon, 0) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t := ts.UTC()
|
||||||
|
day := float64(t.YearDay())
|
||||||
|
hour := float64(t.Hour())
|
||||||
|
min := float64(t.Minute())
|
||||||
|
sec := float64(t.Second()) + float64(t.Nanosecond())/1e9
|
||||||
|
utcHours := hour + min/60.0 + sec/3600.0
|
||||||
|
|
||||||
|
gamma := 2.0 * math.Pi / 365.0 * (day - 1.0 + (utcHours-12.0)/24.0)
|
||||||
|
|
||||||
|
eqtime := 229.18 * (0.000075 + 0.001868*math.Cos(gamma) - 0.032077*math.Sin(gamma) -
|
||||||
|
0.014615*math.Cos(2.0*gamma) - 0.040849*math.Sin(2.0*gamma))
|
||||||
|
|
||||||
|
decl := 0.006918 - 0.399912*math.Cos(gamma) + 0.070257*math.Sin(gamma) -
|
||||||
|
0.006758*math.Cos(2.0*gamma) + 0.000907*math.Sin(2.0*gamma) -
|
||||||
|
0.002697*math.Cos(3.0*gamma) + 0.00148*math.Sin(3.0*gamma)
|
||||||
|
|
||||||
|
timeOffset := eqtime + 4.0*lon
|
||||||
|
trueSolarMinutes := hour*60.0 + min + sec/60.0 + timeOffset
|
||||||
|
for trueSolarMinutes < 0 {
|
||||||
|
trueSolarMinutes += 1440.0
|
||||||
|
}
|
||||||
|
for trueSolarMinutes >= 1440.0 {
|
||||||
|
trueSolarMinutes -= 1440.0
|
||||||
|
}
|
||||||
|
|
||||||
|
hourAngleDeg := trueSolarMinutes/4.0 - 180.0
|
||||||
|
ha := hourAngleDeg * degToRad
|
||||||
|
latRad := lat * degToRad
|
||||||
|
|
||||||
|
cosZenith := math.Sin(latRad)*math.Sin(decl) + math.Cos(latRad)*math.Cos(decl)*math.Cos(ha)
|
||||||
|
if cosZenith > 1.0 {
|
||||||
|
cosZenith = 1.0
|
||||||
|
} else if cosZenith < -1.0 {
|
||||||
|
cosZenith = -1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
zenith := math.Acos(cosZenith)
|
||||||
|
elevation := 90.0 - zenith*radToDeg
|
||||||
|
|
||||||
|
isDay := elevation > -0.833
|
||||||
|
return &isDay
|
||||||
|
}
|
||||||
@@ -76,9 +76,13 @@ func buildObservation(parsed nwsObservationResponse) (model.WeatherObservation,
|
|||||||
// Determine canonical WMO condition code.
|
// Determine canonical WMO condition code.
|
||||||
wmo := mapNWSToWMO(providerDesc, cloudLayers, phenomena)
|
wmo := mapNWSToWMO(providerDesc, cloudLayers, phenomena)
|
||||||
|
|
||||||
|
var isDay *bool
|
||||||
|
if lat, lon := observationLatLon(parsed.Geometry.Coordinates); lat != nil && lon != nil {
|
||||||
|
isDay = isDayFromLatLonTime(*lat, *lon, ts)
|
||||||
|
}
|
||||||
|
|
||||||
// Canonical condition text comes from our WMO table.
|
// Canonical condition text comes from our WMO table.
|
||||||
// NWS observation responses typically do not include a day/night flag -> nil.
|
canonicalText := standards.WMOText(wmo, isDay)
|
||||||
canonicalText := standards.WMOText(wmo, nil)
|
|
||||||
|
|
||||||
// Apparent temperature: prefer wind chill when both are supplied.
|
// Apparent temperature: prefer wind chill when both are supplied.
|
||||||
var apparentC *float64
|
var apparentC *float64
|
||||||
@@ -95,7 +99,7 @@ func buildObservation(parsed nwsObservationResponse) (model.WeatherObservation,
|
|||||||
|
|
||||||
ConditionCode: wmo,
|
ConditionCode: wmo,
|
||||||
ConditionText: canonicalText,
|
ConditionText: canonicalText,
|
||||||
IsDay: nil,
|
IsDay: isDay,
|
||||||
|
|
||||||
ProviderRawDescription: providerDesc,
|
ProviderRawDescription: providerDesc,
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import (
|
|||||||
// nwsObservationResponse is a minimal-but-sufficient representation of the NWS
|
// nwsObservationResponse is a minimal-but-sufficient representation of the NWS
|
||||||
// station observation GeoJSON payload needed for mapping into model.WeatherObservation.
|
// station observation GeoJSON payload needed for mapping into model.WeatherObservation.
|
||||||
type nwsObservationResponse struct {
|
type nwsObservationResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Geometry struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Coordinates []float64 `json:"coordinates"` // GeoJSON point: [lon, lat]
|
||||||
|
} `json:"geometry"`
|
||||||
Properties struct {
|
Properties struct {
|
||||||
StationID string `json:"stationId"`
|
StationID string `json:"stationId"`
|
||||||
StationName string `json:"stationName"`
|
StationName string `json:"stationName"`
|
||||||
|
|||||||
Reference in New Issue
Block a user