normalizers: implemented openmeteo forecast normalizer.
This commit is contained in:
@@ -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¤t=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¤t=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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
243
internal/normalizers/openmeteo/forecast.go
Normal file
243
internal/normalizers/openmeteo/forecast.go
Normal 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
|
||||||
|
}
|
||||||
@@ -13,4 +13,6 @@ func Register(reg *fknormalize.Registry) {
|
|||||||
|
|
||||||
// Observations
|
// Observations
|
||||||
reg.Register(ObservationNormalizer{})
|
reg.Register(ObservationNormalizer{})
|
||||||
|
// Forecasts
|
||||||
|
reg.Register(ForecastNormalizer{})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user