247 lines
6.7 KiB
Go
247 lines
6.7 KiB
Go
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 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
|
|
}
|