All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
236 lines
4.9 KiB
Go
236 lines
4.9 KiB
Go
// FILE: internal/normalizers/nws/helpers.go
|
|
package nws
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common"
|
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
|
)
|
|
|
|
// centroidLatLon returns a best-effort centroid (lat, lon) from a GeoJSON polygon.
|
|
// If geometry is missing or malformed, returns (nil, nil).
|
|
func centroidLatLon(coords [][][]float64) (lat *float64, lon *float64) {
|
|
if len(coords) == 0 || len(coords[0]) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var sumLon, sumLat float64
|
|
var n float64
|
|
|
|
for _, pt := range coords[0] {
|
|
if len(pt) < 2 {
|
|
continue
|
|
}
|
|
sumLon += pt[0]
|
|
sumLat += pt[1]
|
|
n++
|
|
}
|
|
|
|
if n == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
avgLon := sumLon / n
|
|
avgLat := sumLat / n
|
|
|
|
return &avgLat, &avgLon
|
|
}
|
|
|
|
func tempCFromNWS(v *float64, unit string) *float64 {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
|
|
u := strings.ToUpper(strings.TrimSpace(unit))
|
|
switch u {
|
|
case "F":
|
|
c := normcommon.TempCFromF(*v)
|
|
return &c
|
|
case "C":
|
|
c := *v
|
|
return &c
|
|
default:
|
|
// Unknown unit; be conservative.
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// wmoFromNWSForecast infers a canonical WMO code for a forecast period.
|
|
//
|
|
// Strategy:
|
|
// 1. Try to infer from shortForecast using the cross-provider fallback.
|
|
// 2. Special-case mixed rain+snow using temperature when available (since our WMO table
|
|
// does not include a “mixed precip” code).
|
|
// 3. Fall back to an icon token (e.g., "rain", "snow", "ovc", "bkn", "sct", ...).
|
|
func wmoFromNWSForecast(shortForecast, iconURL string, tempC *float64) model.WMOCode {
|
|
sf := strings.TrimSpace(shortForecast)
|
|
s := strings.ToLower(sf)
|
|
|
|
// Mixed precip heuristic: choose rain vs snow based on temperature.
|
|
if strings.Contains(s, "rain") && strings.Contains(s, "snow") {
|
|
if tempC != nil && *tempC <= 0.0 {
|
|
return 73 // Snow
|
|
}
|
|
return 63 // Rain
|
|
}
|
|
|
|
if code := normcommon.WMOFromTextDescription(sf); code != model.WMOUnknown {
|
|
return code
|
|
}
|
|
|
|
// Icon fallback: token is usually the last path segment (before any comma/query).
|
|
if token := nwsIconToken(iconURL); token != "" {
|
|
// Try the general text fallback first (works for "rain", "snow", etc.).
|
|
if code := normcommon.WMOFromTextDescription(token); code != model.WMOUnknown {
|
|
return code
|
|
}
|
|
|
|
// Sky-condition icon tokens are common; map conservatively.
|
|
switch token {
|
|
case "ovc", "bkn", "cloudy", "ovcast":
|
|
return 3
|
|
case "sct", "bkn-sct":
|
|
return 2
|
|
case "few":
|
|
return 1
|
|
case "skc", "clr", "clear":
|
|
return 0
|
|
}
|
|
}
|
|
|
|
return model.WMOUnknown
|
|
}
|
|
|
|
func nwsIconToken(iconURL string) string {
|
|
u := strings.TrimSpace(iconURL)
|
|
if u == "" {
|
|
return ""
|
|
}
|
|
|
|
// Drop query string.
|
|
base := strings.SplitN(u, "?", 2)[0]
|
|
|
|
// Take last path segment.
|
|
parts := strings.Split(base, "/")
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
last := parts[len(parts)-1]
|
|
if last == "" && len(parts) > 1 {
|
|
last = parts[len(parts)-2]
|
|
}
|
|
|
|
// Some icons look like "rain,30" or "snow,20".
|
|
last = strings.SplitN(last, ",", 2)[0]
|
|
last = strings.ToLower(strings.TrimSpace(last))
|
|
|
|
return last
|
|
}
|
|
|
|
// parseNWSWindSpeedKmh parses NWS wind speed strings like:
|
|
// - "9 mph"
|
|
// - "10 to 15 mph"
|
|
//
|
|
// and converts to km/h.
|
|
//
|
|
// Policy: if a range is present, we use the midpoint (best effort).
|
|
func parseNWSWindSpeedKmh(s string) *float64 {
|
|
raw := strings.ToLower(strings.TrimSpace(s))
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
|
|
nums := extractFloats(raw)
|
|
if len(nums) == 0 {
|
|
return nil
|
|
}
|
|
|
|
val := nums[0]
|
|
if len(nums) >= 2 && (strings.Contains(raw, " to ") || strings.Contains(raw, "-")) {
|
|
val = (nums[0] + nums[1]) / 2.0
|
|
}
|
|
|
|
switch {
|
|
case strings.Contains(raw, "mph"):
|
|
k := normcommon.SpeedKmhFromMph(val)
|
|
return &k
|
|
|
|
case strings.Contains(raw, "km/h") || strings.Contains(raw, "kph"):
|
|
k := val
|
|
return &k
|
|
|
|
case strings.Contains(raw, "kt") || strings.Contains(raw, "kts") || strings.Contains(raw, "knot"):
|
|
// 1 knot = 1.852 km/h
|
|
k := val * 1.852
|
|
return &k
|
|
|
|
default:
|
|
// Unknown unit; be conservative.
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// parseNWSWindDirectionDegrees maps compass directions to degrees.
|
|
// Returns nil if direction is empty/unknown.
|
|
func parseNWSWindDirectionDegrees(dir string) *float64 {
|
|
d := strings.ToUpper(strings.TrimSpace(dir))
|
|
if d == "" {
|
|
return nil
|
|
}
|
|
|
|
// 16-wind compass.
|
|
m := map[string]float64{
|
|
"N": 0,
|
|
"NNE": 22.5,
|
|
"NE": 45,
|
|
"ENE": 67.5,
|
|
"E": 90,
|
|
"ESE": 112.5,
|
|
"SE": 135,
|
|
"SSE": 157.5,
|
|
"S": 180,
|
|
"SSW": 202.5,
|
|
"SW": 225,
|
|
"WSW": 247.5,
|
|
"W": 270,
|
|
"WNW": 292.5,
|
|
"NW": 315,
|
|
"NNW": 337.5,
|
|
}
|
|
|
|
if deg, ok := m[d]; ok {
|
|
return °
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func extractFloats(s string) []float64 {
|
|
var out []float64
|
|
var buf strings.Builder
|
|
|
|
flush := func() {
|
|
if buf.Len() == 0 {
|
|
return
|
|
}
|
|
v, err := strconv.ParseFloat(buf.String(), 64)
|
|
if err == nil {
|
|
out = append(out, v)
|
|
}
|
|
buf.Reset()
|
|
}
|
|
|
|
for _, r := range s {
|
|
if unicode.IsDigit(r) || r == '.' {
|
|
buf.WriteRune(r)
|
|
continue
|
|
}
|
|
flush()
|
|
}
|
|
flush()
|
|
|
|
return out
|
|
}
|