122 lines
4.1 KiB
Go
122 lines
4.1 KiB
Go
// FILE: ./internal/normalizers/nws/observation.go
|
|
package nws
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
|
|
return normcommon.NormalizeJSON(
|
|
in,
|
|
"nws observation",
|
|
standards.SchemaWeatherObservationV1,
|
|
buildObservation,
|
|
)
|
|
}
|
|
|
|
// 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("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
|
|
}
|