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

@@ -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 != "" {