nws: refactored the NWS source files to relocate normalization logic to internal/normalizers.

This commit is contained in:
2026-01-14 11:18:21 -06:00
parent efc44e8c6a
commit 0ba2602bcc
11 changed files with 873 additions and 616 deletions

View File

@@ -0,0 +1,145 @@
// FILE: ./internal/normalizers/nws/observation.go
package nws
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"gitea.maximumdirect.net/ejr/feedkit/event"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/model"
normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards"
)
// ObservationNormalizer converts:
//
// standards.SchemaRawNWSObservationV1 -> standards.SchemaWeatherObservationV1
//
// It interprets NWS GeoJSON station observations and maps them into the
// canonical model.WeatherObservation representation.
//
// Precedence for determining ConditionCode (WMO):
// 1. presentWeather (METAR phenomena objects) — strongest signal
// 2. textDescription keyword fallback — reusable across providers
// 3. cloudLayers sky-only fallback — NWS/METAR-specific
type ObservationNormalizer struct{}
func (ObservationNormalizer) Match(e event.Event) bool {
return strings.TrimSpace(e.Schema) == standards.SchemaRawNWSObservationV1
}
func (ObservationNormalizer) Normalize(ctx context.Context, in event.Event) (*event.Event, error) {
_ = ctx // normalization is pure/CPU; keep ctx for future expensive steps
rawBytes, err := normcommon.PayloadBytes(in)
if err != nil {
return nil, fmt.Errorf("nws observation normalize: %w", err)
}
var parsed nwsObservationResponse
if err := json.Unmarshal(rawBytes, &parsed); err != nil {
return nil, fmt.Errorf("nws observation normalize: decode raw payload: %w", err)
}
obs, effectiveAt, err := buildObservation(parsed)
if err != nil {
return nil, err
}
out := in
out.Schema = standards.SchemaWeatherObservationV1
out.Payload = obs
// EffectiveAt is optional; for observations it is naturally the observation timestamp.
if !effectiveAt.IsZero() {
t := effectiveAt.UTC()
out.EffectiveAt = &t
}
if err := out.Validate(); err != nil {
return nil, err
}
return &out, nil
}
// buildObservation contains the domain mapping logic (provider -> canonical model).
func buildObservation(parsed nwsObservationResponse) (model.WeatherObservation, time.Time, error) {
// Timestamp (RFC3339)
var ts time.Time
if s := strings.TrimSpace(parsed.Properties.Timestamp); s != "" {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return model.WeatherObservation{}, time.Time{}, fmt.Errorf("nws observation normalize: invalid timestamp %q: %w", s, err)
}
ts = t
}
cloudLayers := make([]model.CloudLayer, 0, len(parsed.Properties.CloudLayers))
for _, cl := range parsed.Properties.CloudLayers {
cloudLayers = append(cloudLayers, model.CloudLayer{
BaseMeters: cl.Base.Value,
Amount: cl.Amount,
})
}
// Preserve raw presentWeather objects (for troubleshooting / drift analysis).
present := make([]model.PresentWeather, 0, len(parsed.Properties.PresentWeather))
for _, pw := range parsed.Properties.PresentWeather {
present = append(present, model.PresentWeather{Raw: pw})
}
// Decode presentWeather into typed METAR phenomena for mapping.
phenomena := decodeMetarPhenomena(parsed.Properties.PresentWeather)
providerDesc := strings.TrimSpace(parsed.Properties.TextDescription)
// Determine canonical WMO condition code.
wmo := mapNWSToWMO(providerDesc, cloudLayers, phenomena)
// Canonical condition text comes from our WMO table.
// NWS observation responses typically do not include a day/night flag -> nil.
canonicalText := standards.WMOText(wmo, nil)
obs := model.WeatherObservation{
StationID: parsed.Properties.StationID,
StationName: parsed.Properties.StationName,
Timestamp: ts,
ConditionCode: wmo,
ConditionText: canonicalText,
IsDay: nil,
ProviderRawDescription: providerDesc,
// Transitional / human-facing:
// keep output consistent by populating TextDescription from canonical text.
TextDescription: canonicalText,
IconURL: parsed.Properties.Icon,
TemperatureC: parsed.Properties.Temperature.Value,
DewpointC: parsed.Properties.Dewpoint.Value,
WindDirectionDegrees: parsed.Properties.WindDirection.Value,
WindSpeedKmh: parsed.Properties.WindSpeed.Value,
WindGustKmh: parsed.Properties.WindGust.Value,
BarometricPressurePa: parsed.Properties.BarometricPressure.Value,
SeaLevelPressurePa: parsed.Properties.SeaLevelPressure.Value,
VisibilityMeters: parsed.Properties.Visibility.Value,
RelativeHumidityPercent: parsed.Properties.RelativeHumidity.Value,
WindChillC: parsed.Properties.WindChill.Value,
HeatIndexC: parsed.Properties.HeatIndex.Value,
ElevationMeters: parsed.Properties.Elevation.Value,
RawMessage: parsed.Properties.RawMessage,
PresentWeather: present,
CloudLayers: cloudLayers,
}
return obs, ts, nil
}