openmeteo: refactored the OpenMeteo source files to relocate normalization logic to internal/normalizers.
This commit is contained in:
40
internal/normalizers/openmeteo/common.go
Normal file
40
internal/normalizers/openmeteo/common.go
Normal 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)
|
||||
}
|
||||
180
internal/normalizers/openmeteo/observation.go
Normal file
180
internal/normalizers/openmeteo/observation.go
Normal 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
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
33
internal/normalizers/openmeteo/types.go
Normal file
33
internal/normalizers/openmeteo/types.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user