- Implement full NWS hourly forecast normalizer (raw.nws.hourly.forecast.v1 → weather.forecast.v1) - Add GeoJSON forecast types and helpers for NWS gridpoint hourly payloads - Normalize temperatures, winds, humidity, PoP, and infer WMO condition codes from forecast text/icons - Treat forecast IssuedAt as EffectiveAt for stable, dedupe-friendly event IDs - Introduce project-wide float rounding at normalization finalization - Round all float values in canonical payloads to 2 decimal places - Apply consistently across pointers, slices, maps, and nested structs - Preserve opaque structs (e.g., time.Time) unchanged - Add SchemaRawNWSHourlyForecastV1 and align schema matching/comments - Clean up NWS helper organization and comments - Update documentation to reflect numeric wire-format and normalization policies This establishes a complete, deterministic hourly forecast pipeline for NWS and improves JSON output stability across all canonical weather schemas.
160 lines
5.0 KiB
Go
160 lines
5.0 KiB
Go
// FILE: internal/normalizers/nws/forecast.go
|
|
package nws
|
|
|
|
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"
|
|
nwscommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/nws"
|
|
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards"
|
|
)
|
|
|
|
// ForecastNormalizer converts:
|
|
//
|
|
// standards.SchemaRawNWSHourlyForecastV1 -> standards.SchemaWeatherForecastV1
|
|
//
|
|
// It interprets NWS GeoJSON gridpoint *hourly* forecast responses and maps them into
|
|
// the canonical model.WeatherForecastRun representation.
|
|
//
|
|
// 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 {
|
|
s := strings.TrimSpace(e.Schema)
|
|
return s == standards.SchemaRawNWSHourlyForecastV1
|
|
}
|
|
|
|
func (ForecastNormalizer) Normalize(ctx context.Context, in event.Event) (*event.Event, error) {
|
|
_ = ctx // normalization is pure/CPU; keep ctx for future expensive steps
|
|
|
|
return normcommon.NormalizeJSON(
|
|
in,
|
|
"nws hourly forecast",
|
|
standards.SchemaWeatherForecastV1,
|
|
buildForecast,
|
|
)
|
|
}
|
|
|
|
// buildForecast contains the domain mapping logic (provider -> canonical model).
|
|
func buildForecast(parsed nwsForecastResponse) (model.WeatherForecastRun, time.Time, error) {
|
|
// IssuedAt is required by the canonical model.
|
|
issuedStr := strings.TrimSpace(parsed.Properties.GeneratedAt)
|
|
if issuedStr == "" {
|
|
return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("missing properties.generatedAt")
|
|
}
|
|
issuedAt, err := nwscommon.ParseTime(issuedStr)
|
|
if err != nil {
|
|
return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("invalid properties.generatedAt %q: %w", issuedStr, err)
|
|
}
|
|
issuedAt = issuedAt.UTC()
|
|
|
|
// UpdatedAt is optional.
|
|
var updatedAt *time.Time
|
|
if s := strings.TrimSpace(parsed.Properties.UpdateTime); s != "" {
|
|
if t, err := nwscommon.ParseTime(s); err == nil {
|
|
tt := t.UTC()
|
|
updatedAt = &tt
|
|
}
|
|
}
|
|
|
|
// Best-effort location centroid from the GeoJSON polygon (optional).
|
|
lat, lon := centroidLatLon(parsed.Geometry.Coordinates)
|
|
|
|
// Schema is explicitly hourly, so product is not a heuristic.
|
|
run := model.WeatherForecastRun{
|
|
LocationID: "",
|
|
LocationName: "",
|
|
|
|
IssuedAt: issuedAt,
|
|
UpdatedAt: updatedAt,
|
|
Product: model.ForecastProductHourly,
|
|
|
|
Latitude: lat,
|
|
Longitude: lon,
|
|
ElevationMeters: parsed.Properties.Elevation.Value,
|
|
|
|
Periods: nil,
|
|
}
|
|
|
|
periods := make([]model.WeatherForecastPeriod, 0, len(parsed.Properties.Periods))
|
|
for i, p := range parsed.Properties.Periods {
|
|
startStr := strings.TrimSpace(p.StartTime)
|
|
endStr := strings.TrimSpace(p.EndTime)
|
|
|
|
if startStr == "" || endStr == "" {
|
|
return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("periods[%d]: missing startTime/endTime", i)
|
|
}
|
|
|
|
start, err := nwscommon.ParseTime(startStr)
|
|
if err != nil {
|
|
return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("periods[%d].startTime invalid %q: %w", i, startStr, err)
|
|
}
|
|
end, err := nwscommon.ParseTime(endStr)
|
|
if err != nil {
|
|
return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("periods[%d].endTime invalid %q: %w", i, endStr, err)
|
|
}
|
|
start = start.UTC()
|
|
end = end.UTC()
|
|
|
|
// 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)
|
|
|
|
canonicalText := standards.WMOText(wmo, isDay)
|
|
|
|
period := model.WeatherForecastPeriod{
|
|
StartTime: start,
|
|
EndTime: end,
|
|
|
|
Name: strings.TrimSpace(p.Name),
|
|
IsDay: isDay,
|
|
|
|
ConditionCode: wmo,
|
|
ConditionText: canonicalText,
|
|
|
|
ProviderRawDescription: providerDesc,
|
|
|
|
// For forecasts, keep provider text as the human-facing description.
|
|
TextDescription: strings.TrimSpace(p.ShortForecast),
|
|
DetailedText: strings.TrimSpace(p.DetailedForecast),
|
|
|
|
IconURL: strings.TrimSpace(p.Icon),
|
|
|
|
TemperatureC: tempC,
|
|
|
|
DewpointC: p.Dewpoint.Value,
|
|
RelativeHumidityPercent: p.RelativeHumidity.Value,
|
|
|
|
WindDirectionDegrees: parseNWSWindDirectionDegrees(p.WindDirection),
|
|
WindSpeedKmh: parseNWSWindSpeedKmh(p.WindSpeed),
|
|
|
|
ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitation.Value,
|
|
}
|
|
|
|
periods = append(periods, period)
|
|
}
|
|
|
|
run.Periods = periods
|
|
|
|
// EffectiveAt policy for forecasts: treat IssuedAt as the effective time (dedupe-friendly).
|
|
return run, issuedAt, nil
|
|
}
|