nws: refactored the NWS source files to relocate normalization logic to internal/normalizers.
This commit is contained in:
49
internal/normalizers/common/doc.go
Normal file
49
internal/normalizers/common/doc.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Package common contains cross-provider helper code used by weatherfeeder normalizers.
|
||||
//
|
||||
// Purpose
|
||||
// -------
|
||||
// Normalizers convert provider-specific RAW payloads into canonical internal/model types.
|
||||
// Some small utilities are naturally reusable across multiple providers (unit conversions,
|
||||
// payload extraction, common parsing, shared fallbacks). Those belong here.
|
||||
//
|
||||
// This package is intentionally "boring":
|
||||
// - pure helpers (deterministic, no I/O)
|
||||
// - minimal abstractions (prefer straightforward functions)
|
||||
// - easy to unit test
|
||||
//
|
||||
// What belongs here
|
||||
// -----------------
|
||||
// Put code in internal/normalizers/common when it is:
|
||||
//
|
||||
// - potentially reusable by more than one provider
|
||||
// - provider-agnostic (no NWS/OpenWeather/Open-Meteo specific assumptions)
|
||||
// - stable, small, and readable
|
||||
//
|
||||
// Typical examples:
|
||||
// - unit conversion helpers (°F <-> °C, m/s <-> km/h, hPa <-> Pa, etc.)
|
||||
// - json.RawMessage payload extraction helpers (with good error messages)
|
||||
// - shared parsing helpers (timestamps, simple numeric coercions)
|
||||
// - generic fallbacks (e.g., mapping a human text description into a coarse canonical code),
|
||||
// so long as the logic truly applies across providers
|
||||
//
|
||||
// What does NOT belong here
|
||||
// -------------------------
|
||||
// Do NOT put the following in this package:
|
||||
//
|
||||
// - Normalizer implementations (types that satisfy feedkit/normalize.Normalizer)
|
||||
// - provider-specific JSON structs or mapping logic (put those under
|
||||
// internal/normalizers/<provider>/)
|
||||
// - network or filesystem I/O (sources fetch; normalizers transform)
|
||||
// - code that depends on event.Source naming, config fields, or driver-specific params
|
||||
//
|
||||
// Style and API guidelines
|
||||
// ------------------------
|
||||
// - Prefer small, single-purpose functions.
|
||||
// - Keep function names explicit (avoid clever generic “DoThing” helpers).
|
||||
// - Return typed errors with context (include schema/field names where helpful).
|
||||
// - Keep dependencies minimal: standard library + weatherfeeder packages only.
|
||||
// - Add unit tests for any non-trivial logic (especially parsing and fallbacks).
|
||||
//
|
||||
// Keeping this clean matters: common is shared by all providers, so complexity here
|
||||
// multiplies across the project.
|
||||
package common
|
||||
54
internal/normalizers/common/payload.go
Normal file
54
internal/normalizers/common/payload.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// FILE: ./internal/normalizers/common/payload.go
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||
)
|
||||
|
||||
// PayloadBytes extracts a JSON-ish payload into bytes suitable for json.Unmarshal.
|
||||
//
|
||||
// Supported payload shapes (weatherfeeder convention):
|
||||
// - json.RawMessage (recommended for raw events)
|
||||
// - []byte
|
||||
// - string (assumed to contain JSON)
|
||||
// - map[string]any (re-marshaled to JSON)
|
||||
//
|
||||
// If you add other raw representations later, extend this function.
|
||||
func PayloadBytes(e event.Event) ([]byte, error) {
|
||||
if e.Payload == nil {
|
||||
return nil, fmt.Errorf("payload is nil")
|
||||
}
|
||||
|
||||
switch v := e.Payload.(type) {
|
||||
case json.RawMessage:
|
||||
if len(v) == 0 {
|
||||
return nil, fmt.Errorf("payload is empty json.RawMessage")
|
||||
}
|
||||
return []byte(v), nil
|
||||
|
||||
case []byte:
|
||||
if len(v) == 0 {
|
||||
return nil, fmt.Errorf("payload is empty []byte")
|
||||
}
|
||||
return v, nil
|
||||
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("payload is empty string")
|
||||
}
|
||||
return []byte(v), nil
|
||||
|
||||
case map[string]any:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal map payload: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported payload type %T", e.Payload)
|
||||
}
|
||||
}
|
||||
15
internal/normalizers/common/units.go
Normal file
15
internal/normalizers/common/units.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// FILE: ./internal/normalizers/common/units.go
|
||||
package common
|
||||
|
||||
// Common unit conversions used across providers.
|
||||
//
|
||||
// These helpers are intentionally small and “obvious” and are meant to remove
|
||||
// duplication across normalizers (and eventually across sources, once refactored).
|
||||
|
||||
func TempCFromF(f float64) float64 { return (f - 32.0) * 5.0 / 9.0 }
|
||||
func TempCFromK(k float64) float64 { return k - 273.15 }
|
||||
|
||||
func SpeedKmhFromMps(ms float64) float64 { return ms * 3.6 }
|
||||
func SpeedKmhFromMph(mph float64) float64 { return mph * 1.609344 }
|
||||
|
||||
func PressurePaFromHPa(hpa float64) float64 { return hpa * 100.0 }
|
||||
129
internal/normalizers/common/wmo_text.go
Normal file
129
internal/normalizers/common/wmo_text.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// FILE: ./internal/normalizers/common/wmo_text.go
|
||||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/model"
|
||||
)
|
||||
|
||||
// WMOFromTextDescription is a cross-provider fallback that tries to infer a WMO code
|
||||
// from a human-readable condition string.
|
||||
//
|
||||
// This is intentionally “coarse” and conservative. It is useful when a provider:
|
||||
//
|
||||
// - does not provide a condition code, or
|
||||
// - provides it inconsistently / null, or
|
||||
// - provides only textual conditions for some endpoints.
|
||||
//
|
||||
// Providers may still choose to override this with richer signals
|
||||
// (METAR phenomena, explicit numeric codes, etc.).
|
||||
func WMOFromTextDescription(desc string) model.WMOCode {
|
||||
s := strings.ToLower(strings.TrimSpace(desc))
|
||||
if s == "" {
|
||||
return model.WMOUnknown
|
||||
}
|
||||
|
||||
// Thunder / hail
|
||||
if strings.Contains(s, "thunder") {
|
||||
if strings.Contains(s, "hail") {
|
||||
return 99
|
||||
}
|
||||
return 95
|
||||
}
|
||||
|
||||
// Freezing hazards
|
||||
if strings.Contains(s, "freezing rain") {
|
||||
if strings.Contains(s, "light") {
|
||||
return 66
|
||||
}
|
||||
return 67
|
||||
}
|
||||
if strings.Contains(s, "freezing drizzle") {
|
||||
if strings.Contains(s, "light") {
|
||||
return 56
|
||||
}
|
||||
return 57
|
||||
}
|
||||
|
||||
// Drizzle
|
||||
if strings.Contains(s, "drizzle") {
|
||||
if strings.Contains(s, "heavy") || strings.Contains(s, "dense") {
|
||||
return 55
|
||||
}
|
||||
if strings.Contains(s, "light") {
|
||||
return 51
|
||||
}
|
||||
return 53
|
||||
}
|
||||
|
||||
// Showers
|
||||
if strings.Contains(s, "showers") {
|
||||
if strings.Contains(s, "heavy") {
|
||||
return 82
|
||||
}
|
||||
if strings.Contains(s, "light") {
|
||||
return 80
|
||||
}
|
||||
return 81
|
||||
}
|
||||
|
||||
// Rain
|
||||
if strings.Contains(s, "rain") {
|
||||
if strings.Contains(s, "heavy") {
|
||||
return 65
|
||||
}
|
||||
if strings.Contains(s, "light") {
|
||||
return 61
|
||||
}
|
||||
return 63
|
||||
}
|
||||
|
||||
// Snow (check snow showers first)
|
||||
if strings.Contains(s, "snow showers") {
|
||||
if strings.Contains(s, "light") {
|
||||
return 85
|
||||
}
|
||||
return 86
|
||||
}
|
||||
if strings.Contains(s, "snow grains") {
|
||||
return 77
|
||||
}
|
||||
if strings.Contains(s, "snow") {
|
||||
if strings.Contains(s, "heavy") {
|
||||
return 75
|
||||
}
|
||||
if strings.Contains(s, "light") {
|
||||
return 71
|
||||
}
|
||||
return 73
|
||||
}
|
||||
|
||||
// Fog / mist
|
||||
if strings.Contains(s, "rime fog") {
|
||||
return 48
|
||||
}
|
||||
if strings.Contains(s, "fog") || strings.Contains(s, "mist") {
|
||||
return 45
|
||||
}
|
||||
|
||||
// Sky-only
|
||||
if strings.Contains(s, "overcast") {
|
||||
return 3
|
||||
}
|
||||
if strings.Contains(s, "cloudy") {
|
||||
return 3
|
||||
}
|
||||
if strings.Contains(s, "partly cloudy") {
|
||||
return 2
|
||||
}
|
||||
if strings.Contains(s, "mostly sunny") || strings.Contains(s, "mostly clear") ||
|
||||
strings.Contains(s, "mainly sunny") || strings.Contains(s, "mainly clear") {
|
||||
return 1
|
||||
}
|
||||
if strings.Contains(s, "clear") || strings.Contains(s, "sunny") {
|
||||
return 0
|
||||
}
|
||||
|
||||
return model.WMOUnknown
|
||||
}
|
||||
Reference in New Issue
Block a user