normalizers: implemented openmeteo forecast normalizer.

This commit is contained in:
2026-01-17 10:16:50 -06:00
parent b47f1b2051
commit c12cf91115
6 changed files with 328 additions and 47 deletions

View File

@@ -1,53 +1,53 @@
--- ---
sources: sources:
- name: NWSObservationKSTL # - name: NWSObservationKSTL
kind: observation # kind: observation
driver: nws_observation # driver: nws_observation
every: 10m # every: 10m
params:
url: "https://api.weather.gov/stations/KSTL/observations/latest"
user_agent: "HomeOps (eric@maximumdirect.net)"
- name: OpenMeteoObservation
kind: observation
driver: openmeteo_observation
every: 10m
params:
url: "https://api.open-meteo.com/v1/forecast?latitude=38.6239&longitude=-90.3571&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m,precipitation,surface_pressure,rain,showers,snowfall,cloud_cover,apparent_temperature,is_day,wind_gusts_10m,pressure_msl&forecast_days=1"
user_agent: "HomeOps (eric@maximumdirect.net)"
- name: OpenWeatherObservation
kind: observation
driver: openweather_observation
every: 10m
params:
url: "https://api.openweathermap.org/data/2.5/weather?lat=38.6239&lon=-90.3571&appid=c954f2566cb7ccb56b43737b52e88fc6&units=metric"
user_agent: "HomeOps (eric@maximumdirect.net)"
- name: NWSObservationKSUS
kind: observation
driver: nws_observation
every: 10m
params:
url: "https://api.weather.gov/stations/KSUS/observations/latest"
user_agent: "HomeOps (eric@maximumdirect.net)"
- name: NWSObservationKCPS
kind: observation
driver: nws_observation
every: 10m
params:
url: "https://api.weather.gov/stations/KCPS/observations/latest"
user_agent: "HomeOps (eric@maximumdirect.net)"
# - name: NWSHourlyForecastSTL
# kind: forecast
# driver: nws_forecast
# every: 45m
# params: # params:
# url: "https://api.weather.gov/gridpoints/LSX/90,74/forecast/hourly" # url: "https://api.weather.gov/stations/KSTL/observations/latest"
# user_agent: "HomeOps (eric@maximumdirect.net)" # user_agent: "HomeOps (eric@maximumdirect.net)"
# - name: OpenMeteoObservation
# kind: observation
# driver: openmeteo_observation
# every: 10m
# params:
# url: "https://api.open-meteo.com/v1/forecast?latitude=38.6239&longitude=-90.3571&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m,precipitation,surface_pressure,rain,showers,snowfall,cloud_cover,apparent_temperature,is_day,wind_gusts_10m,pressure_msl&forecast_days=1"
# user_agent: "HomeOps (eric@maximumdirect.net)"
# - name: OpenWeatherObservation
# kind: observation
# driver: openweather_observation
# every: 10m
# params:
# url: "https://api.openweathermap.org/data/2.5/weather?lat=38.6239&lon=-90.3571&appid=c954f2566cb7ccb56b43737b52e88fc6&units=metric"
# user_agent: "HomeOps (eric@maximumdirect.net)"
# - name: NWSObservationKSUS
# kind: observation
# driver: nws_observation
# every: 10m
# params:
# url: "https://api.weather.gov/stations/KSUS/observations/latest"
# user_agent: "HomeOps (eric@maximumdirect.net)"
# - name: NWSObservationKCPS
# kind: observation
# driver: nws_observation
# every: 10m
# params:
# url: "https://api.weather.gov/stations/KCPS/observations/latest"
# user_agent: "HomeOps (eric@maximumdirect.net)"
- name: NWSHourlyForecastSTL
kind: forecast
driver: nws_forecast
every: 45m
params:
url: "https://api.weather.gov/gridpoints/LSX/90,74/forecast/hourly"
user_agent: "HomeOps (eric@maximumdirect.net)"
- name: OpenMeteoHourlyForecastSTL - name: OpenMeteoHourlyForecastSTL
kind: forecast kind: forecast
driver: openmeteo_forecast driver: openmeteo_forecast

View File

@@ -102,6 +102,7 @@ type WeatherForecastPeriod struct {
WindGustKmh *float64 `json:"windGustKmh,omitempty"` WindGustKmh *float64 `json:"windGustKmh,omitempty"`
BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"` BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"`
VisibilityMeters *float64 `json:"visibilityMeters,omitempty"` VisibilityMeters *float64 `json:"visibilityMeters,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
WindChillC *float64 `json:"windChillC,omitempty"` WindChillC *float64 `json:"windChillC,omitempty"`
HeatIndexC *float64 `json:"heatIndexC,omitempty"` HeatIndexC *float64 `json:"heatIndexC,omitempty"`
CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty"` CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty"`
@@ -112,7 +113,7 @@ type WeatherForecastPeriod struct {
// Quantitative precip is not universally available, but OpenWeather/Open-Meteo often supply it. // Quantitative precip is not universally available, but OpenWeather/Open-Meteo often supply it.
// Use liquid-equivalent mm for interoperability. // Use liquid-equivalent mm for interoperability.
PrecipitationAmountMm *float64 `json:"precipitationAmountMm,omitempty"` PrecipitationAmountMm *float64 `json:"precipitationAmountMm,omitempty"`
SnowAmountMm *float64 `json:"snowAmountMm,omitempty"` SnowfallDepthMM *float64 `json:"SnowfallDepthMM,omitempty"`
// Optional extras that some providers supply and downstream might care about. // Optional extras that some providers supply and downstream might care about.
UVIndex *float64 `json:"uvIndex,omitempty"` UVIndex *float64 `json:"uvIndex,omitempty"`

View File

@@ -11,7 +11,7 @@ import (
// //
// Note: encoding/json will not necessarily print trailing zeros (e.g. 1.50 -> 1.5), // Note: encoding/json will not necessarily print trailing zeros (e.g. 1.50 -> 1.5),
// but values will be *rounded* to this number of digits after the decimal point. // but values will be *rounded* to this number of digits after the decimal point.
const DefaultFloatPrecision = 2 const DefaultFloatPrecision = 4
// RoundFloats returns a copy of v with all float32/float64 values (including pointers, // RoundFloats returns a copy of v with all float32/float64 values (including pointers,
// slices, arrays, maps, and nested exported-struct fields) rounded to `decimals` digits // slices, arrays, maps, and nested exported-struct fields) rounded to `decimals` digits

View File

@@ -0,0 +1,243 @@
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"
omcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/openmeteo"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards"
)
// ForecastNormalizer converts:
//
// standards.SchemaRawOpenMeteoHourlyForecastV1 -> standards.SchemaWeatherForecastV1
//
// It interprets Open-Meteo hourly forecast JSON and maps it into the canonical
// model.WeatherForecastRun representation.
//
// Caveats / assumptions:
// - Open-Meteo does not provide a true "issued at" timestamp; IssuedAt uses
// Event.EmittedAt when present, otherwise the first hourly time.
// - Hourly payloads are array-oriented; missing fields are treated as nil per-period.
// - Snowfall is provided in centimeters and is converted to millimeters.
// - apparent_temperature is ignored (no canonical "feels like" field).
type ForecastNormalizer struct{}
func (ForecastNormalizer) Match(e event.Event) bool {
return strings.TrimSpace(e.Schema) == standards.SchemaRawOpenMeteoHourlyForecastV1
}
func (ForecastNormalizer) Normalize(ctx context.Context, in event.Event) (*event.Event, error) {
_ = ctx // normalization is pure/CPU; keep ctx for future expensive steps
// If present, prefer the existing event EmittedAt as IssuedAt.
var fallbackIssued time.Time
if !in.EmittedAt.IsZero() {
fallbackIssued = in.EmittedAt.UTC()
}
return normcommon.NormalizeJSON(
in,
"openmeteo hourly forecast",
standards.SchemaWeatherForecastV1,
func(parsed omForecastResponse) (model.WeatherForecastRun, time.Time, error) {
return buildForecast(parsed, fallbackIssued)
},
)
}
// buildForecast contains the domain mapping logic (provider -> canonical model).
func buildForecast(parsed omForecastResponse, fallbackIssued time.Time) (model.WeatherForecastRun, time.Time, error) {
times := parsed.Hourly.Time
if len(times) == 0 {
return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("missing hourly.time")
}
issuedAt := fallbackIssued.UTC()
run := model.WeatherForecastRun{
LocationID: normcommon.SynthStationIDPtr("OPENMETEO", parsed.Latitude, parsed.Longitude),
LocationName: "Open-Meteo",
IssuedAt: time.Time{},
UpdatedAt: nil,
Product: model.ForecastProductHourly,
Latitude: floatCopy(parsed.Latitude),
Longitude: floatCopy(parsed.Longitude),
ElevationMeters: floatCopy(parsed.Elevation),
Periods: nil,
}
periods := make([]model.WeatherForecastPeriod, 0, len(times))
var prevStart time.Time
for i := range times {
start, err := parseHourlyTime(parsed, i)
if err != nil {
return model.WeatherForecastRun{}, time.Time{}, err
}
if issuedAt.IsZero() && i == 0 {
issuedAt = start
}
end, err := parsePeriodEnd(parsed, i, start, prevStart)
if err != nil {
return model.WeatherForecastRun{}, time.Time{}, err
}
prevStart = start
var isDay *bool
if v := intAt(parsed.Hourly.IsDay, i); v != nil {
b := *v == 1
isDay = &b
}
wmo := wmoAt(parsed.Hourly.WeatherCode, i)
canonicalText := standards.WMOText(wmo, isDay)
period := model.WeatherForecastPeriod{
StartTime: start,
EndTime: end,
Name: "",
IsDay: isDay,
ConditionCode: wmo,
ConditionText: canonicalText,
ProviderRawDescription: "",
TextDescription: canonicalText,
DetailedText: "",
IconURL: "",
}
if v := floatAt(parsed.Hourly.Temperature2m, i); v != nil {
period.TemperatureC = v
}
if v := floatAt(parsed.Hourly.DewPoint2m, i); v != nil {
period.DewpointC = v
}
if v := floatAt(parsed.Hourly.RelativeHumidity2m, i); v != nil {
period.RelativeHumidityPercent = v
}
if v := floatAt(parsed.Hourly.WindDirection10m, i); v != nil {
period.WindDirectionDegrees = v
}
if v := floatAt(parsed.Hourly.WindSpeed10m, i); v != nil {
period.WindSpeedKmh = v
}
if v := floatAt(parsed.Hourly.WindGusts10m, i); v != nil {
period.WindGustKmh = v
}
if v := floatAt(parsed.Hourly.Visibility, i); v != nil {
period.VisibilityMeters = v
}
if v := floatAt(parsed.Hourly.CloudCover, i); v != nil {
period.CloudCoverPercent = v
}
if v := floatAt(parsed.Hourly.UVIndex, i); v != nil {
period.UVIndex = v
}
if v := floatAt(parsed.Hourly.PrecipProbability, i); v != nil {
period.ProbabilityOfPrecipitationPercent = v
}
if v := floatAt(parsed.Hourly.Precipitation, i); v != nil {
period.PrecipitationAmountMm = v
}
if v := floatAt(parsed.Hourly.Snowfall, i); v != nil {
mm := *v * 10.0
period.SnowfallDepthMM = &mm
}
if v := floatAt(parsed.Hourly.SurfacePressure, i); v != nil {
pa := normcommon.PressurePaFromHPa(*v)
period.BarometricPressurePa = &pa
} else if v := floatAt(parsed.Hourly.PressureMSL, i); v != nil {
pa := normcommon.PressurePaFromHPa(*v)
period.BarometricPressurePa = &pa
}
periods = append(periods, period)
}
run.Periods = periods
if issuedAt.IsZero() {
return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("missing issuedAt")
}
run.IssuedAt = issuedAt
// EffectiveAt policy for forecasts: treat IssuedAt as the effective time (dedupe-friendly).
return run, issuedAt, nil
}
func parseHourlyTime(parsed omForecastResponse, idx int) (time.Time, error) {
if idx < 0 || idx >= len(parsed.Hourly.Time) {
return time.Time{}, fmt.Errorf("hourly.time[%d] missing", idx)
}
raw := strings.TrimSpace(parsed.Hourly.Time[idx])
if raw == "" {
return time.Time{}, fmt.Errorf("hourly.time[%d] empty", idx)
}
t, err := omcommon.ParseTime(raw, parsed.Timezone, parsed.UTCOffsetSeconds)
if err != nil {
return time.Time{}, fmt.Errorf("hourly.time[%d] invalid %q: %w", idx, raw, err)
}
return t.UTC(), nil
}
func parsePeriodEnd(parsed omForecastResponse, idx int, start, prevStart time.Time) (time.Time, error) {
if idx+1 < len(parsed.Hourly.Time) {
return parseHourlyTime(parsed, idx+1)
}
step := time.Hour
if !prevStart.IsZero() {
if d := start.Sub(prevStart); d > 0 {
step = d
}
}
return start.Add(step), nil
}
func floatCopy(v *float64) *float64 {
if v == nil {
return nil
}
out := *v
return &out
}
func floatAt(vals []*float64, idx int) *float64 {
if idx < 0 || idx >= len(vals) {
return nil
}
return floatCopy(vals[idx])
}
func intAt(vals []*int, idx int) *int {
if idx < 0 || idx >= len(vals) {
return nil
}
if vals[idx] == nil {
return nil
}
out := *vals[idx]
return &out
}
func wmoAt(vals []*int, idx int) model.WMOCode {
if v := intAt(vals, idx); v != nil {
return model.WMOCode(*v)
}
return model.WMOUnknown
}

View File

@@ -13,4 +13,6 @@ func Register(reg *fknormalize.Registry) {
// Observations // Observations
reg.Register(ObservationNormalizer{}) reg.Register(ObservationNormalizer{})
// Forecasts
reg.Register(ForecastNormalizer{})
} }

View File

@@ -31,3 +31,38 @@ type omCurrent struct {
IsDay *int `json:"is_day"` // 0/1 IsDay *int `json:"is_day"` // 0/1
} }
// omForecastResponse is a minimal-but-sufficient representation of Open-Meteo
// hourly forecast payloads for mapping into model.WeatherForecastRun.
type omForecastResponse struct {
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
Timezone string `json:"timezone"`
UTCOffsetSeconds int `json:"utc_offset_seconds"`
Elevation *float64 `json:"elevation"`
Hourly omForecastHourly `json:"hourly"`
}
type omForecastHourly struct {
Time []string `json:"time"`
Temperature2m []*float64 `json:"temperature_2m"`
RelativeHumidity2m []*float64 `json:"relative_humidity_2m"`
DewPoint2m []*float64 `json:"dew_point_2m"`
ApparentTemp []*float64 `json:"apparent_temperature"`
PrecipProbability []*float64 `json:"precipitation_probability"`
Precipitation []*float64 `json:"precipitation"`
Snowfall []*float64 `json:"snowfall"`
WeatherCode []*int `json:"weather_code"`
SurfacePressure []*float64 `json:"surface_pressure"`
PressureMSL []*float64 `json:"pressure_msl"`
WindSpeed10m []*float64 `json:"wind_speed_10m"`
WindDirection10m []*float64 `json:"wind_direction_10m"`
WindGusts10m []*float64 `json:"wind_gusts_10m"`
IsDay []*int `json:"is_day"`
CloudCover []*float64 `json:"cloud_cover"`
Visibility []*float64 `json:"visibility"`
UVIndex []*float64 `json:"uv_index"`
}