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,40 @@
// FILE: ./internal/normalizers/openmeteo/common.go
package openmeteo
import (
"fmt"
"strings"
"time"
)
// parseOpenMeteoTime parses Open-Meteo timestamps.
//
// Open-Meteo commonly returns "YYYY-MM-DDTHH:MM" (no timezone suffix) when timezone
// is provided separately. When a timezone suffix is present (RFC3339), we accept it too.
//
// This is provider-specific because it relies on Open-Meteo's timezone and offset fields.
func parseOpenMeteoTime(s string, tz string, utcOffsetSeconds int) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, fmt.Errorf("empty time")
}
// If the server returned an RFC3339 timestamp with timezone, treat it as authoritative.
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
// Typical Open-Meteo format: "2006-01-02T15:04"
const layout = "2006-01-02T15:04"
// Best effort: try to load the timezone as an IANA name.
if tz != "" {
if loc, err := time.LoadLocation(tz); err == nil {
return time.ParseInLocation(layout, s, loc)
}
}
// Fallback: use a fixed zone from the offset seconds.
loc := time.FixedZone("open-meteo", utcOffsetSeconds)
return time.ParseInLocation(layout, s, loc)
}

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
}

View File

@@ -1,3 +1,4 @@
// FILE: ./internal/normalizers/openmeteo/register.go
package openmeteo
import (
@@ -5,15 +6,11 @@ import (
)
// Register registers Open-Meteo normalizers into the provided registry.
//
// This is intentionally empty as a stub. As normalizers are implemented,
// register them here, e.g.:
//
// reg.Register(ObservationNormalizer{})
func Register(reg *fknormalize.Registry) {
if reg == nil {
return
}
// TODO: register Open-Meteo normalizers here.
// Observations
reg.Register(ObservationNormalizer{})
}

View File

@@ -0,0 +1,33 @@
// FILE: ./internal/normalizers/openmeteo/types.go
package openmeteo
// omResponse is a minimal-but-sufficient representation of the Open-Meteo "current"
// payload needed for mapping into model.WeatherObservation.
//
// We use pointers for many fields so "missing" is distinguishable from "zero".
type omResponse struct {
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
Timezone string `json:"timezone"`
UTCOffsetSeconds int `json:"utc_offset_seconds"`
Elevation *float64 `json:"elevation"`
Current omCurrent `json:"current"`
}
type omCurrent struct {
Time string `json:"time"` // e.g. "2026-01-10T12:30" (often no timezone suffix)
Temperature2m *float64 `json:"temperature_2m"`
RelativeHumidity2m *float64 `json:"relative_humidity_2m"`
WeatherCode *int `json:"weather_code"`
WindSpeed10m *float64 `json:"wind_speed_10m"` // km/h (per Open-Meteo docs for these fields)
WindDirection10m *float64 `json:"wind_direction_10m"` // degrees
WindGusts10m *float64 `json:"wind_gusts_10m"` // km/h
SurfacePressure *float64 `json:"surface_pressure"` // hPa
PressureMSL *float64 `json:"pressure_msl"` // hPa
IsDay *int `json:"is_day"` // 0/1
}