// 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 } }