Files
weatherfeeder/internal/normalizers/openmeteo/forecast.go
2026-02-08 08:56:16 -06:00

247 lines
6.7 KiB
Go

package openmeteo
import (
"context"
"fmt"
"strings"
"time"
"gitea.maximumdirect.net/ejr/feedkit/event"
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"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// 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 mapped to ApparentTemperatureC when present.
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.ApparentTemp, i); v != nil {
period.ApparentTemperatureC = 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
}