nws: refactored the NWS source files to relocate normalization logic to internal/normalizers.
This commit is contained in:
223
internal/normalizers/nws/wmo_map.go
Normal file
223
internal/normalizers/nws/wmo_map.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// FILE: ./internal/normalizers/nws/wmo_map.go
|
||||
package nws
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/model"
|
||||
normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user