Add shared normalizer helpers to centralize payload extraction, JSON decoding, and event finalization/validation. Refactor NWS, Open-Meteo, and OpenWeather observation normalizers to use the shared spine, removing repeated boilerplate while preserving provider-specific mapping logic.
157 lines
4.6 KiB
Go
157 lines
4.6 KiB
Go
// FILE: ./internal/normalizers/openmeteo/observation.go
|
|
package openmeteo
|
|
|
|
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.SchemaRawOpenMeteoCurrentV1 -> standards.SchemaWeatherObservationV1
|
|
//
|
|
// It interprets Open-Meteo "current weather" JSON and maps it into the canonical
|
|
// model.WeatherObservation representation.
|
|
//
|
|
// Caveats / assumptions:
|
|
//
|
|
// - Open-Meteo is not a station feed; StationID is synthesized from lat/lon.
|
|
// - Open-Meteo provides WMO weather_code directly; we treat it as authoritative.
|
|
// - Day/night handling uses Open-Meteo's is_day flag when present.
|
|
// - Pressure fields are typically hPa; we convert to Pa to match our model.
|
|
//
|
|
// Timestamp handling:
|
|
//
|
|
// - Open-Meteo "current.time" is often "YYYY-MM-DDTHH:MM" with timezone specified
|
|
// separately; we interpret it using the returned timezone / utc_offset_seconds.
|
|
// - If parsing fails, Timestamp may be zero and EffectiveAt will be omitted.
|
|
type ObservationNormalizer struct{}
|
|
|
|
func (ObservationNormalizer) Match(e event.Event) bool {
|
|
return strings.TrimSpace(e.Schema) == standards.SchemaRawOpenMeteoCurrentV1
|
|
}
|
|
|
|
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,
|
|
"openmeteo observation",
|
|
standards.SchemaWeatherObservationV1,
|
|
buildObservation,
|
|
)
|
|
}
|
|
|
|
// buildObservation contains the domain mapping logic (provider -> canonical model).
|
|
func buildObservation(parsed omResponse) (model.WeatherObservation, time.Time, error) {
|
|
// Parse current.time.
|
|
var ts time.Time
|
|
if s := strings.TrimSpace(parsed.Current.Time); s != "" {
|
|
t, err := parseOpenMeteoTime(s, parsed.Timezone, parsed.UTCOffsetSeconds)
|
|
if err != nil {
|
|
return model.WeatherObservation{}, time.Time{}, fmt.Errorf("openmeteo observation normalize: parse time %q: %w", s, err)
|
|
}
|
|
ts = t.UTC()
|
|
}
|
|
|
|
// Day/night: optional.
|
|
var isDay *bool
|
|
if parsed.Current.IsDay != nil {
|
|
v := *parsed.Current.IsDay == 1
|
|
isDay = &v
|
|
}
|
|
|
|
// WMO weather code: optional.
|
|
wmo := model.WMOUnknown
|
|
if parsed.Current.WeatherCode != nil {
|
|
wmo = model.WMOCode(*parsed.Current.WeatherCode)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
obs := model.WeatherObservation{
|
|
StationID: stationID,
|
|
StationName: "Open-Meteo",
|
|
Timestamp: ts,
|
|
|
|
ConditionCode: wmo,
|
|
ConditionText: canonicalText,
|
|
IsDay: isDay,
|
|
|
|
// Open-Meteo does not provide a separate human text description for "current"
|
|
// when using weather_code; we leave provider evidence empty.
|
|
ProviderRawDescription: "",
|
|
|
|
// Transitional / human-facing:
|
|
// keep output consistent by populating TextDescription from canonical text.
|
|
TextDescription: canonicalText,
|
|
|
|
// IconURL: Open-Meteo does not provide an icon URL in this endpoint.
|
|
IconURL: "",
|
|
}
|
|
|
|
// Measurements (all optional; only set when present).
|
|
if parsed.Current.Temperature2m != nil {
|
|
v := *parsed.Current.Temperature2m
|
|
obs.TemperatureC = &v
|
|
}
|
|
|
|
if parsed.Current.RelativeHumidity2m != nil {
|
|
v := *parsed.Current.RelativeHumidity2m
|
|
obs.RelativeHumidityPercent = &v
|
|
}
|
|
|
|
if parsed.Current.WindDirection10m != nil {
|
|
v := *parsed.Current.WindDirection10m
|
|
obs.WindDirectionDegrees = &v
|
|
}
|
|
|
|
if parsed.Current.WindSpeed10m != nil {
|
|
v := *parsed.Current.WindSpeed10m // Open-Meteo returns km/h for wind_speed_10m
|
|
obs.WindSpeedKmh = &v
|
|
}
|
|
|
|
if parsed.Current.WindGusts10m != nil {
|
|
v := *parsed.Current.WindGusts10m // Open-Meteo returns km/h for wind_gusts_10m
|
|
obs.WindGustKmh = &v
|
|
}
|
|
|
|
if parsed.Current.SurfacePressure != nil {
|
|
v := normcommon.PressurePaFromHPa(*parsed.Current.SurfacePressure)
|
|
obs.BarometricPressurePa = &v
|
|
}
|
|
|
|
if parsed.Current.PressureMSL != nil {
|
|
v := normcommon.PressurePaFromHPa(*parsed.Current.PressureMSL)
|
|
obs.SeaLevelPressurePa = &v
|
|
}
|
|
|
|
if parsed.Elevation != nil {
|
|
v := *parsed.Elevation
|
|
obs.ElevationMeters = &v
|
|
}
|
|
|
|
return obs, ts, nil
|
|
}
|