openmeteo: refactored the OpenMeteo source files to relocate normalization logic to internal/normalizers.

This commit is contained in:
2026-01-14 12:10:32 -06:00
parent 1f8ba05e19
commit f43babdfd2
5 changed files with 348 additions and 115 deletions

View File

@@ -0,0 +1,180 @@
// FILE: ./internal/normalizers/openmeteo/observation.go
package openmeteo
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.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
rawBytes, err := normcommon.PayloadBytes(in)
if err != nil {
return nil, fmt.Errorf("openmeteo observation normalize: %w", err)
}
var parsed omResponse
if err := json.Unmarshal(rawBytes, &parsed); err != nil {
return nil, fmt.Errorf("openmeteo 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 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
}