diff --git a/internal/normalizers/nws/day_night.go b/internal/normalizers/nws/day_night.go new file mode 100644 index 0000000..6a3efe6 --- /dev/null +++ b/internal/normalizers/nws/day_night.go @@ -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 +} diff --git a/internal/normalizers/nws/observation.go b/internal/normalizers/nws/observation.go index cb004a2..27988b0 100644 --- a/internal/normalizers/nws/observation.go +++ b/internal/normalizers/nws/observation.go @@ -76,9 +76,13 @@ func buildObservation(parsed nwsObservationResponse) (model.WeatherObservation, // Determine canonical WMO condition code. 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. - // NWS observation responses typically do not include a day/night flag -> nil. - canonicalText := standards.WMOText(wmo, nil) + canonicalText := standards.WMOText(wmo, isDay) // Apparent temperature: prefer wind chill when both are supplied. var apparentC *float64 @@ -95,7 +99,7 @@ func buildObservation(parsed nwsObservationResponse) (model.WeatherObservation, ConditionCode: wmo, ConditionText: canonicalText, - IsDay: nil, + IsDay: isDay, ProviderRawDescription: providerDesc, diff --git a/internal/normalizers/nws/types.go b/internal/normalizers/nws/types.go index 3024ab0..dc5327f 100644 --- a/internal/normalizers/nws/types.go +++ b/internal/normalizers/nws/types.go @@ -8,7 +8,11 @@ import ( // nwsObservationResponse is a minimal-but-sufficient representation of the NWS // station observation GeoJSON payload needed for mapping into model.WeatherObservation. 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 { StationID string `json:"stationId"` StationName string `json:"stationName"`