All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
224 lines
4.4 KiB
Go
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
|
|
}
|
|
}
|