Files
weatherfeeder/internal/normalizers/nws/wmo_map.go
2026-02-08 08:56:16 -06:00

224 lines
4.4 KiB
Go

// FILE: ./internal/normalizers/nws/wmo_map.go
package nws
import (
"strings"
normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// mapNWSToWMO maps NWS signals into a canonical WMO code.
//
// Precedence:
// 1. METAR phenomena (presentWeather) — most reliable for precip/hazards
// 2. textDescription keywords — weaker, but reusable across providers
// 3. cloud layers fallback — only for sky-only conditions
func mapNWSToWMO(providerDesc string, cloudLayers []model.CloudLayer, phenomena []metarPhenomenon) model.WMOCode {
// 1) Prefer METAR phenomena if present.
if code := wmoFromPhenomena(phenomena); code != model.WMOUnknown {
return code
}
// 2) Reusable fallback: infer WMO from human text description.
if code := normcommon.WMOFromTextDescription(providerDesc); code != model.WMOUnknown {
return code
}
// 3) NWS/METAR-specific sky fallback.
if code := wmoFromCloudLayers(cloudLayers); code != model.WMOUnknown {
return code
}
return model.WMOUnknown
}
func wmoFromPhenomena(phenomena []metarPhenomenon) model.WMOCode {
if len(phenomena) == 0 {
return model.WMOUnknown
}
intensityOf := func(p metarPhenomenon) string {
if p.Intensity == nil {
return ""
}
return strings.ToLower(strings.TrimSpace(*p.Intensity))
}
modifierOf := func(p metarPhenomenon) string {
if p.Modifier == nil {
return ""
}
return strings.ToLower(strings.TrimSpace(*p.Modifier))
}
// Pass 1: thunder + hail overrides everything (hazard).
hasThunder := false
hailIntensity := ""
for _, p := range phenomena {
switch p.Weather {
case "thunderstorms":
hasThunder = true
case "hail":
if hailIntensity == "" {
hailIntensity = intensityOf(p)
}
}
}
if hasThunder {
if hailIntensity != "" || containsWeather(phenomena, "hail") {
if hailIntensity == "heavy" {
return 99
}
return 96
}
return 95
}
// Pass 2: freezing hazards.
for _, p := range phenomena {
if modifierOf(p) != "freezing" {
continue
}
switch p.Weather {
case "rain":
if intensityOf(p) == "light" {
return 66
}
return 67
case "drizzle":
if intensityOf(p) == "light" {
return 56
}
return 57
case "fog", "fog_mist":
return 48
}
}
// Pass 3: fog / obscuration.
for _, p := range phenomena {
switch p.Weather {
case "fog", "fog_mist":
return 45
case "haze", "smoke", "dust", "sand", "spray", "volcanic_ash":
return 45
}
}
// Pass 4: precip families.
for _, p := range phenomena {
inten := intensityOf(p)
mod := modifierOf(p)
if mod == "showers" {
switch p.Weather {
case "rain":
if inten == "light" {
return 80
}
if inten == "heavy" {
return 82
}
return 81
case "snow":
if inten == "light" {
return 85
}
return 86
}
}
switch p.Weather {
case "drizzle":
if inten == "heavy" {
return 55
}
if inten == "light" {
return 51
}
return 53
case "rain":
if inten == "heavy" {
return 65
}
if inten == "light" {
return 61
}
return 63
case "snow":
if inten == "heavy" {
return 75
}
if inten == "light" {
return 71
}
return 73
case "snow_grains":
return 77
case "ice_pellets", "snow_pellets":
return 73
}
}
return model.WMOUnknown
}
func wmoFromCloudLayers(cloudLayers []model.CloudLayer) model.WMOCode {
// NWS cloud layer amount values commonly include:
// OVC, BKN, SCT, FEW, SKC, CLR, VV (vertical visibility / obscured sky)
//
// Conservative mapping within our current WMO subset:
// - OVC / BKN / VV => Cloudy (3)
// - SCT => Partly Cloudy (2)
// - FEW => Mainly Sunny/Clear (1)
// - CLR / SKC => Sunny/Clear (0)
//
// Multiple layers: bias toward “most cloudy”.
mostCloudy := ""
for _, cl := range cloudLayers {
a := strings.ToUpper(strings.TrimSpace(cl.Amount))
if a == "" {
continue
}
switch a {
case "OVC":
return 3
case "BKN", "VV":
if mostCloudy != "OVC" {
mostCloudy = a
}
case "SCT":
if mostCloudy == "" {
mostCloudy = "SCT"
}
case "FEW":
if mostCloudy == "" {
mostCloudy = "FEW"
}
case "CLR", "SKC":
if mostCloudy == "" {
mostCloudy = "CLR"
}
}
}
switch mostCloudy {
case "BKN", "VV":
return 3
case "SCT":
return 2
case "FEW":
return 1
case "CLR":
return 0
default:
return model.WMOUnknown
}
}