diff --git a/cmd/weatherfeeder/config.yml b/cmd/weatherfeeder/config.yml index 05e8eda..71c2fec 100644 --- a/cmd/weatherfeeder/config.yml +++ b/cmd/weatherfeeder/config.yml @@ -1,53 +1,53 @@ --- sources: - - name: NWSObservationKSTL - kind: observation - driver: nws_observation - 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 +# - name: NWSObservationKSTL +# kind: observation +# driver: nws_observation +# every: 10m # 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)" +# - 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 kind: forecast driver: openmeteo_forecast diff --git a/internal/model/forecast.go b/internal/model/forecast.go index 6acb475..b48cd26 100644 --- a/internal/model/forecast.go +++ b/internal/model/forecast.go @@ -102,6 +102,7 @@ type WeatherForecastPeriod struct { WindGustKmh *float64 `json:"windGustKmh,omitempty"` BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"` VisibilityMeters *float64 `json:"visibilityMeters,omitempty"` + ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"` WindChillC *float64 `json:"windChillC,omitempty"` HeatIndexC *float64 `json:"heatIndexC,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. // Use liquid-equivalent mm for interoperability. 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. UVIndex *float64 `json:"uvIndex,omitempty"` diff --git a/internal/normalizers/common/round.go b/internal/normalizers/common/round.go index 72b22b8..067fd38 100644 --- a/internal/normalizers/common/round.go +++ b/internal/normalizers/common/round.go @@ -11,7 +11,7 @@ import ( // // 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. -const DefaultFloatPrecision = 2 +const DefaultFloatPrecision = 4 // 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 diff --git a/internal/normalizers/openmeteo/forecast.go b/internal/normalizers/openmeteo/forecast.go new file mode 100644 index 0000000..bc7bdbf --- /dev/null +++ b/internal/normalizers/openmeteo/forecast.go @@ -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 +} diff --git a/internal/normalizers/openmeteo/register.go b/internal/normalizers/openmeteo/register.go index 4616c1b..e7484fa 100644 --- a/internal/normalizers/openmeteo/register.go +++ b/internal/normalizers/openmeteo/register.go @@ -13,4 +13,6 @@ func Register(reg *fknormalize.Registry) { // Observations reg.Register(ObservationNormalizer{}) + // Forecasts + reg.Register(ForecastNormalizer{}) } diff --git a/internal/normalizers/openmeteo/types.go b/internal/normalizers/openmeteo/types.go index c5a21fc..5e25463 100644 --- a/internal/normalizers/openmeteo/types.go +++ b/internal/normalizers/openmeteo/types.go @@ -31,3 +31,38 @@ type omCurrent struct { 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"` +}