refactor(normalizers): deduplicate synthetic station ID generation

- Add common SynthStationID helpers for coordinate-based providers
- Use shared helper for Open-Meteo and OpenWeather station ID synthesis
- Require both lat/lon when generating synthetic IDs to avoid misleading defaults
- Remove unused Open-Meteo normalizer wrapper code

This reduces cross-provider duplication while keeping provider-specific
mapping logic explicit and readable.
This commit is contained in:
2026-01-16 22:13:44 -06:00
parent 00e811f8f7
commit b8804d32d2
8 changed files with 111 additions and 141 deletions

View File

@@ -1,75 +1,59 @@
---
sources:
# - name: NWSObservationKSTL
# kind: observation
# driver: nws_observation
# every: 12m
# params:
# url: "https://api.weather.gov/stations/KSTL/observations/latest"
# user_agent: "HomeOps (eric@maximumdirect.net)"
- name: NWSObservationKSTL
kind: observation
driver: nws_observation
every: 10m
params:
url: "https://api.weather.gov/stations/KSTL/observations/latest"
user_agent: "HomeOps (eric@maximumdirect.net)"
# - name: OpenMeteoObservation
# kind: observation
# driver: openmeteo_observation
# every: 12m
# params:
# url: "https://api.open-meteo.com/v1/forecast?latitude=38.6239&longitude=-90.3571&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m,precipitation,surface_pressure,rain,showers,snowfall,cloud_cover,apparent_temperature,is_day,wind_gusts_10m,pressure_msl&forecast_days=1"
# user_agent: "HomeOps (eric@maximumdirect.net)"
- name: OpenMeteoObservation
kind: observation
driver: openmeteo_observation
every: 10m
params:
url: "https://api.open-meteo.com/v1/forecast?latitude=38.6239&longitude=-90.3571&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m,precipitation,surface_pressure,rain,showers,snowfall,cloud_cover,apparent_temperature,is_day,wind_gusts_10m,pressure_msl&forecast_days=1"
user_agent: "HomeOps (eric@maximumdirect.net)"
# - name: OpenWeatherObservation
# kind: observation
# driver: openweather_observation
# every: 12m
# params:
# url: "https://api.openweathermap.org/data/2.5/weather?lat=38.6239&lon=-90.3571&appid=c954f2566cb7ccb56b43737b52e88fc6&units=metric"
# user_agent: "HomeOps (eric@maximumdirect.net)"
- name: OpenWeatherObservation
kind: observation
driver: openweather_observation
every: 10m
params:
url: "https://api.openweathermap.org/data/2.5/weather?lat=38.6239&lon=-90.3571&appid=c954f2566cb7ccb56b43737b52e88fc6&units=metric"
user_agent: "HomeOps (eric@maximumdirect.net)"
# - name: NWSObservationKSUS
# kind: observation
# driver: nws_observation
# every: 18s
# params:
# url: "https://api.weather.gov/stations/KSUS/observations/latest"
# user_agent: "HomeOps (eric@maximumdirect.net)"
- name: NWSObservationKSUS
kind: observation
driver: nws_observation
every: 10m
params:
url: "https://api.weather.gov/stations/KSUS/observations/latest"
user_agent: "HomeOps (eric@maximumdirect.net)"
# - name: NWSObservationKCPS
# kind: observation
# driver: nws_observation
# every: 12m
# params:
# url: "https://api.weather.gov/stations/KCPS/observations/latest"
# user_agent: "HomeOps (eric@maximumdirect.net)"
- name: NWSObservationKCPS
kind: observation
driver: nws_observation
every: 10m
params:
url: "https://api.weather.gov/stations/KCPS/observations/latest"
user_agent: "HomeOps (eric@maximumdirect.net)"
# - name: NWSForecastSTL
# kind: forecast
# driver: nws_forecast
# every: 1m
# params:
# url: "https://api.weather.gov/gridpoints/LSX/90,74/forecast"
# user_agent: "HomeOps (eric@maximumdirect.net)"
# - name: NWSHourlyForecastSTL
# kind: forecast
# driver: nws_forecast
# every: 1m
# params:
# url: "https://api.weather.gov/gridpoints/LSX/90,74/forecast/hourly"
# user_agent: "HomeOps (eric@maximumdirect.net)"
- name: NWSHourlyForecastSTL
kind: forecast
driver: nws_forecast
every: 45m
params:
url: "https://api.weather.gov/gridpoints/LSX/90,74/forecast/hourly"
user_agent: "HomeOps (eric@maximumdirect.net)"
- name: NWSAlertsSTL
kind: alert
driver: nws_alerts
every: 1m
params:
url: "https://api.weather.gov/alerts?point=38.6239,-90.3571&limit=500"
user_agent: "HomeOps (eric@maximumdirect.net)"
- name: NWSAlertsIowa
kind: alert
driver: nws_alerts
every: 1m
params:
url: "https://api.weather.gov/alerts/active/zone/IAZ048"
url: "https://api.weather.gov/alerts?point=38.6239,-90.3571&limit=20"
user_agent: "HomeOps (eric@maximumdirect.net)"
sinks:

View File

@@ -0,0 +1,23 @@
// FILE: internal/normalizers/common/id.go
package common
import "fmt"
// SynthStationID formats a stable synthetic station identifier for providers that are
// coordinate-based rather than station-based.
//
// Example output:
//
// OPENMETEO(38.62700,-90.19940)
func SynthStationID(prefix string, lat, lon float64) string {
return fmt.Sprintf("%s(%.5f,%.5f)", prefix, lat, lon)
}
// SynthStationIDPtr is the pointer-friendly variant.
// If either coordinate is missing, it returns "" (unknown).
func SynthStationIDPtr(prefix string, lat, lon *float64) string {
if lat == nil || lon == nil {
return ""
}
return SynthStationID(prefix, *lat, *lon)
}

View File

@@ -56,14 +56,11 @@ func roundValue(v reflect.Value, decimals int) reflect.Value {
out.Set(elem)
return out
}
if elem.IsValid() && elem.Type().AssignableTo(v.Type()) {
out.Set(elem)
return out
}
if elem.IsValid() && elem.Type().ConvertibleTo(v.Type()) {
out.Set(elem.Convert(v.Type()))
return out
}
// If we can't sensibly re-wrap, just keep the original.
return v
}

View File

@@ -39,34 +39,26 @@ func (AlertsNormalizer) Match(e event.Event) bool {
func (AlertsNormalizer) Normalize(ctx context.Context, in event.Event) (*event.Event, error) {
_ = ctx // normalization is pure/CPU; keep ctx for future expensive steps
parsed, err := normcommon.DecodeJSONPayload[nwsAlertsResponse](in)
if err != nil {
return nil, fmt.Errorf("nws alerts normalize: %w", err)
}
// If we can't derive AsOf from the payload, fall back to the existing event envelope.
fallbackAsOf := in.EmittedAt.UTC()
if in.EffectiveAt != nil && !in.EffectiveAt.IsZero() {
fallbackAsOf = in.EffectiveAt.UTC()
}
payload, effectiveAt, err := buildAlerts(parsed, fallbackAsOf)
if err != nil {
return nil, fmt.Errorf("nws alerts normalize: build: %w", err)
}
out, err := normcommon.Finalize(in, standards.SchemaWeatherAlertV1, payload, effectiveAt)
if err != nil {
return nil, fmt.Errorf("nws alerts normalize: %w", err)
}
return out, nil
return normcommon.NormalizeJSON(
in,
"nws alerts",
standards.SchemaWeatherAlertV1,
func(parsed nwsAlertsResponse) (model.WeatherAlertRun, time.Time, error) {
return buildAlerts(parsed, fallbackAsOf)
},
)
}
// buildAlerts contains the domain mapping logic (provider -> canonical model).
func buildAlerts(parsed nwsAlertsResponse, fallbackAsOf time.Time) (model.WeatherAlertRun, time.Time, error) {
// 1) Determine AsOf (required by canonical model; also used as EffectiveAt).
asOf := parseNWSTimeUTC(parsed.Updated)
asOf := nwscommon.ParseTimeBestEffort(parsed.Updated)
if asOf.IsZero() {
asOf = latestAlertTimestamp(parsed.Features)
}
@@ -98,14 +90,14 @@ func buildAlerts(parsed nwsAlertsResponse, fallbackAsOf time.Time) (model.Weathe
id = fmt.Sprintf("nws:alert:%s:%d", asOf.UTC().Format(time.RFC3339Nano), i)
}
sent := parseNWSTimePtr(p.Sent)
effective := parseNWSTimePtr(p.Effective)
onset := parseNWSTimePtr(p.Onset)
sent := nwscommon.ParseTimePtr(p.Sent)
effective := nwscommon.ParseTimePtr(p.Effective)
onset := nwscommon.ParseTimePtr(p.Onset)
// Expires: prefer "expires"; fall back to "ends" if present.
expires := parseNWSTimePtr(p.Expires)
expires := nwscommon.ParseTimePtr(p.Expires)
if expires == nil {
expires = parseNWSTimePtr(p.Ends)
expires = nwscommon.ParseTimePtr(p.Ends)
}
refs := parseNWSAlertReferences(p.References)
@@ -168,7 +160,7 @@ func latestAlertTimestamp(features []nwsAlertFeature) time.Time {
p.Onset,
}
for _, s := range candidates {
t := parseNWSTimeUTC(s)
t := nwscommon.ParseTimeBestEffort(s)
if t.IsZero() {
continue
}
@@ -180,28 +172,6 @@ func latestAlertTimestamp(features []nwsAlertFeature) time.Time {
return latest
}
// parseNWSTimeUTC parses an NWS timestamp string into UTC time.Time.
// Returns zero time on empty/unparseable input (best-effort; alerts should be resilient).
func parseNWSTimeUTC(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
t, err := nwscommon.ParseTime(s)
if err != nil {
return time.Time{}
}
return t.UTC()
}
func parseNWSTimePtr(s string) *time.Time {
t := parseNWSTimeUTC(s)
if t.IsZero() {
return nil
}
return &t
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
@@ -228,7 +198,7 @@ func parseNWSAlertReferences(raw json.RawMessage) []model.AlertReference {
ID: strings.TrimSpace(r.ID),
Identifier: strings.TrimSpace(r.Identifier),
Sender: strings.TrimSpace(r.Sender),
Sent: parseNWSTimePtr(r.Sent),
Sent: nwscommon.ParseTimePtr(r.Sent),
}
// If only Identifier is present, preserve it as ID too (useful downstream).
if ref.ID == "" && ref.Identifier != "" {

View File

@@ -1,19 +0,0 @@
// FILE: ./internal/normalizers/openmeteo/common.go
package openmeteo
import (
"time"
openmeteo "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/openmeteo"
)
// parseOpenMeteoTime parses Open-Meteo timestamps.
//
// The actual parsing logic lives in internal/providers/openmeteo so both the
// source (envelope EffectiveAt / event ID) and normalizer (canonical payload)
// can share identical timestamp behavior.
//
// We keep this thin wrapper to avoid churn in the normalizer package.
func parseOpenMeteoTime(s string, tz string, utcOffsetSeconds int) (time.Time, error) {
return openmeteo.ParseTime(s, tz, utcOffsetSeconds)
}

View File

@@ -78,18 +78,8 @@ func buildObservation(parsed omResponse) (model.WeatherObservation, time.Time, e
canonicalText := standards.WMOText(wmo, isDay)
// Station identity: Open-Meteo is not a station feed; synthesize from coordinates.
stationID := ""
if parsed.Latitude != nil || parsed.Longitude != nil {
lat := 0.0
lon := 0.0
if parsed.Latitude != nil {
lat = *parsed.Latitude
}
if parsed.Longitude != nil {
lon = *parsed.Longitude
}
stationID = fmt.Sprintf("OPENMETEO(%.5f,%.5f)", lat, lon)
}
// Require BOTH lat/lon to avoid misleading OPENMETEO(0.00000,...) IDs.
stationID := normcommon.SynthStationIDPtr("OPENMETEO", parsed.Latitude, parsed.Longitude)
obs := model.WeatherObservation{
StationID: stationID,

View File

@@ -4,6 +4,8 @@ package openweather
import (
"fmt"
"strings"
normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common"
)
// This file holds provider-specific helpers that are shared across multiple
@@ -67,5 +69,5 @@ func openWeatherStationID(parsed owmResponse) string {
return fmt.Sprintf("OPENWEATHER(%d)", parsed.ID)
}
// Fallback: synthesize from coordinates.
return fmt.Sprintf("OPENWEATHER(%.5f,%.5f)", parsed.Coord.Lat, parsed.Coord.Lon)
return normcommon.SynthStationID("OPENWEATHER", parsed.Coord.Lat, parsed.Coord.Lon)
}

View File

@@ -25,3 +25,26 @@ func ParseTime(s string) (time.Time, error) {
return time.Time{}, fmt.Errorf("unsupported NWS timestamp format: %q", s)
}
// ParseTimeBestEffort parses an NWS timestamp and returns it in UTC.
//
// This is a convenience for normalizers that want "best effort" parsing:
// invalid/empty strings do not fail the entire normalization; they return zero time.
func ParseTimeBestEffort(s string) time.Time {
t, err := ParseTime(s)
if err != nil {
return time.Time{}
}
return t.UTC()
}
// ParseTimePtr parses an NWS timestamp and returns a UTC *time.Time.
//
// Empty/unparseable input returns nil. This is useful for optional CAP fields.
func ParseTimePtr(s string) *time.Time {
t := ParseTimeBestEffort(s)
if t.IsZero() {
return nil
}
return &t
}