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