295 lines
8.4 KiB
Go
295 lines
8.4 KiB
Go
// FILE: internal/normalizers/nws/forecast.go
|
|
package nws
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/ejr/feedkit/event"
|
|
normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common"
|
|
nwscommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/nws"
|
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
|
)
|
|
|
|
// ForecastNormalizer converts:
|
|
//
|
|
// standards.SchemaRawNWSHourlyForecastV1 -> standards.SchemaWeatherForecastV1
|
|
// standards.SchemaRawNWSNarrativeForecastV1 -> standards.SchemaWeatherForecastV1
|
|
//
|
|
// It keeps one NWS forecast normalization entrypoint and dispatches to product-specific
|
|
// builders by raw schema.
|
|
//
|
|
// Caveats / policy:
|
|
// 1. NWS forecast periods do not include METAR presentWeather phenomena, so ConditionCode
|
|
// is inferred from period.shortForecast (with a conservative icon-based fallback).
|
|
// 2. Temperature is converted to °C when NWS supplies °F.
|
|
// 3. WindSpeed is parsed from strings like "9 mph" / "10 to 15 mph" and converted to km/h.
|
|
type ForecastNormalizer struct{}
|
|
|
|
func (ForecastNormalizer) Match(e event.Event) bool {
|
|
switch strings.TrimSpace(e.Schema) {
|
|
case standards.SchemaRawNWSHourlyForecastV1:
|
|
return true
|
|
case standards.SchemaRawNWSNarrativeForecastV1:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (ForecastNormalizer) Normalize(ctx context.Context, in event.Event) (*event.Event, error) {
|
|
_ = ctx // normalization is pure/CPU; keep ctx for future expensive steps
|
|
|
|
return normalizeForecastEventBySchema(in)
|
|
}
|
|
|
|
func normalizeForecastEventBySchema(in event.Event) (*event.Event, error) {
|
|
switch strings.TrimSpace(in.Schema) {
|
|
case standards.SchemaRawNWSHourlyForecastV1:
|
|
return normalizeHourlyForecastEvent(in)
|
|
case standards.SchemaRawNWSNarrativeForecastV1:
|
|
return normalizeNarrativeForecastEvent(in)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported nws forecast schema %q", strings.TrimSpace(in.Schema))
|
|
}
|
|
}
|
|
|
|
func normalizeHourlyForecastEvent(in event.Event) (*event.Event, error) {
|
|
return normcommon.NormalizeJSON(
|
|
in,
|
|
"nws hourly forecast",
|
|
standards.SchemaWeatherForecastV1,
|
|
buildHourlyForecast,
|
|
)
|
|
}
|
|
|
|
func normalizeNarrativeForecastEvent(in event.Event) (*event.Event, error) {
|
|
return normcommon.NormalizeJSON(
|
|
in,
|
|
"nws narrative forecast",
|
|
standards.SchemaWeatherForecastV1,
|
|
buildNarrativeForecast,
|
|
)
|
|
}
|
|
|
|
type forecastPeriodMapper[T any] func(idx int, period T) (model.WeatherForecastPeriod, error)
|
|
|
|
// buildHourlyForecast contains hourly forecast mapping logic (provider -> canonical model).
|
|
func buildHourlyForecast(parsed nwsHourlyForecastResponse) (model.WeatherForecastRun, time.Time, error) {
|
|
return buildForecastRun(
|
|
parsed.Properties.GeneratedAt,
|
|
parsed.Properties.UpdateTime,
|
|
parsed.Geometry.Coordinates,
|
|
parsed.Properties.Elevation.Value,
|
|
model.ForecastProductHourly,
|
|
parsed.Properties.Periods,
|
|
mapHourlyForecastPeriod,
|
|
)
|
|
}
|
|
|
|
// buildNarrativeForecast contains narrative forecast mapping logic (provider -> canonical model).
|
|
func buildNarrativeForecast(parsed nwsNarrativeForecastResponse) (model.WeatherForecastRun, time.Time, error) {
|
|
return buildForecastRun(
|
|
parsed.Properties.GeneratedAt,
|
|
parsed.Properties.UpdateTime,
|
|
parsed.Geometry.Coordinates,
|
|
parsed.Properties.Elevation.Value,
|
|
model.ForecastProductNarrative,
|
|
parsed.Properties.Periods,
|
|
mapNarrativeForecastPeriod,
|
|
)
|
|
}
|
|
|
|
func buildForecastRun[T any](
|
|
generatedAt string,
|
|
updateTime string,
|
|
coordinates [][][]float64,
|
|
elevation *float64,
|
|
product model.ForecastProduct,
|
|
srcPeriods []T,
|
|
mapPeriod forecastPeriodMapper[T],
|
|
) (model.WeatherForecastRun, time.Time, error) {
|
|
issuedAt, updatedAt, err := parseForecastRunTimes(generatedAt, updateTime)
|
|
if err != nil {
|
|
return model.WeatherForecastRun{}, time.Time{}, err
|
|
}
|
|
|
|
// Best-effort location centroid from the GeoJSON polygon (optional).
|
|
lat, lon := centroidLatLon(coordinates)
|
|
|
|
run := newForecastRunBase(
|
|
issuedAt,
|
|
updatedAt,
|
|
product,
|
|
lat,
|
|
lon,
|
|
elevation,
|
|
)
|
|
|
|
periods := make([]model.WeatherForecastPeriod, 0, len(srcPeriods))
|
|
for i, p := range srcPeriods {
|
|
period, err := mapPeriod(i, p)
|
|
if err != nil {
|
|
return model.WeatherForecastRun{}, time.Time{}, err
|
|
}
|
|
periods = append(periods, period)
|
|
}
|
|
|
|
run.Periods = periods
|
|
|
|
// EffectiveAt policy for forecasts: treat IssuedAt as the effective time (dedupe-friendly).
|
|
return run, issuedAt, nil
|
|
}
|
|
|
|
func parseForecastRunTimes(generatedAt, updateTime string) (time.Time, *time.Time, error) {
|
|
issuedStr := strings.TrimSpace(generatedAt)
|
|
if issuedStr == "" {
|
|
return time.Time{}, nil, fmt.Errorf("missing properties.generatedAt")
|
|
}
|
|
issuedAt, err := nwscommon.ParseTime(issuedStr)
|
|
if err != nil {
|
|
return time.Time{}, nil, fmt.Errorf("invalid properties.generatedAt %q: %w", issuedStr, err)
|
|
}
|
|
issuedAt = issuedAt.UTC()
|
|
|
|
var updatedAt *time.Time
|
|
if s := strings.TrimSpace(updateTime); s != "" {
|
|
if t, err := nwscommon.ParseTime(s); err == nil {
|
|
tt := t.UTC()
|
|
updatedAt = &tt
|
|
}
|
|
}
|
|
|
|
return issuedAt, updatedAt, nil
|
|
}
|
|
|
|
func newForecastRunBase(
|
|
issuedAt time.Time,
|
|
updatedAt *time.Time,
|
|
product model.ForecastProduct,
|
|
lat, lon, elevation *float64,
|
|
) model.WeatherForecastRun {
|
|
return model.WeatherForecastRun{
|
|
LocationID: "",
|
|
LocationName: "",
|
|
IssuedAt: issuedAt,
|
|
UpdatedAt: updatedAt,
|
|
Product: product,
|
|
Latitude: lat,
|
|
Longitude: lon,
|
|
|
|
ElevationMeters: elevation,
|
|
Periods: nil,
|
|
}
|
|
}
|
|
|
|
func parseForecastPeriodWindow(startStr, endStr string, idx int) (time.Time, time.Time, error) {
|
|
startStr = strings.TrimSpace(startStr)
|
|
endStr = strings.TrimSpace(endStr)
|
|
|
|
if startStr == "" || endStr == "" {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("periods[%d]: missing startTime/endTime", idx)
|
|
}
|
|
|
|
start, err := nwscommon.ParseTime(startStr)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("periods[%d].startTime invalid %q: %w", idx, startStr, err)
|
|
}
|
|
end, err := nwscommon.ParseTime(endStr)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("periods[%d].endTime invalid %q: %w", idx, endStr, err)
|
|
}
|
|
|
|
return start.UTC(), end.UTC(), nil
|
|
}
|
|
|
|
func mapHourlyForecastPeriod(idx int, p nwsHourlyForecastPeriod) (model.WeatherForecastPeriod, error) {
|
|
start, end, err := parseForecastPeriodWindow(p.StartTime, p.EndTime, idx)
|
|
if err != nil {
|
|
return model.WeatherForecastPeriod{}, err
|
|
}
|
|
|
|
// NWS hourly supplies isDaytime; make it a pointer to match the canonical model.
|
|
var isDay *bool
|
|
if p.IsDaytime != nil {
|
|
b := *p.IsDaytime
|
|
isDay = &b
|
|
}
|
|
|
|
tempC := tempCFromNWS(p.Temperature, p.TemperatureUnit)
|
|
|
|
// Infer WMO from shortForecast (and fall back to icon token).
|
|
providerDesc := strings.TrimSpace(p.ShortForecast)
|
|
wmo := wmoFromNWSForecast(providerDesc, p.Icon, tempC)
|
|
|
|
return model.WeatherForecastPeriod{
|
|
StartTime: start,
|
|
EndTime: end,
|
|
|
|
Name: strings.TrimSpace(p.Name),
|
|
IsDay: isDay,
|
|
|
|
ConditionCode: wmo,
|
|
|
|
// For forecasts, keep provider short forecast text as the human-facing description.
|
|
TextDescription: providerDesc,
|
|
|
|
TemperatureC: tempC,
|
|
|
|
DewpointC: p.Dewpoint.Value,
|
|
RelativeHumidityPercent: p.RelativeHumidity.Value,
|
|
|
|
WindDirectionDegrees: parseNWSWindDirectionDegrees(p.WindDirection),
|
|
WindSpeedKmh: parseNWSWindSpeedKmh(p.WindSpeed),
|
|
|
|
ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitation.Value,
|
|
}, nil
|
|
}
|
|
|
|
func mapNarrativeForecastPeriod(idx int, p nwsNarrativeForecastPeriod) (model.WeatherForecastPeriod, error) {
|
|
start, end, err := parseForecastPeriodWindow(p.StartTime, p.EndTime, idx)
|
|
if err != nil {
|
|
return model.WeatherForecastPeriod{}, err
|
|
}
|
|
|
|
// NWS narrative supplies isDaytime; make it a pointer to match the canonical model.
|
|
var isDay *bool
|
|
if p.IsDaytime != nil {
|
|
b := *p.IsDaytime
|
|
isDay = &b
|
|
}
|
|
|
|
tempC := tempCFromNWS(p.Temperature, p.TemperatureUnit)
|
|
|
|
// Infer WMO from shortForecast (and fall back to icon token).
|
|
shortForecast := strings.TrimSpace(p.ShortForecast)
|
|
wmo := wmoFromNWSForecast(shortForecast, p.Icon, tempC)
|
|
|
|
textDescription := strings.TrimSpace(p.DetailedForecast)
|
|
if textDescription == "" {
|
|
textDescription = shortForecast
|
|
}
|
|
|
|
return model.WeatherForecastPeriod{
|
|
StartTime: start,
|
|
EndTime: end,
|
|
|
|
Name: strings.TrimSpace(p.Name),
|
|
IsDay: isDay,
|
|
|
|
ConditionCode: wmo,
|
|
|
|
TextDescription: textDescription,
|
|
|
|
TemperatureC: tempC,
|
|
|
|
WindDirectionDegrees: parseNWSWindDirectionDegrees(p.WindDirection),
|
|
WindSpeedKmh: parseNWSWindSpeedKmh(p.WindSpeed),
|
|
|
|
ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitation.Value,
|
|
}, nil
|
|
}
|