From 0fcc5368856fd438f0f333e78050a7a2038d4932 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 16 Jan 2026 00:04:37 -0600 Subject: [PATCH] Updates in preparation for adding forecast sources. --- cmd/weatherfeeder/config.yml | 16 +++ internal/model/forecast.go | 120 +++++++++++++++++++++-- internal/normalizers/doc.go | 4 +- internal/normalizers/openmeteo/common.go | 19 ++++ internal/sources/builtins.go | 21 ---- internal/sources/nws/alerts.go | 43 +++----- 6 files changed, 162 insertions(+), 61 deletions(-) create mode 100644 internal/normalizers/openmeteo/common.go diff --git a/cmd/weatherfeeder/config.yml b/cmd/weatherfeeder/config.yml index a7bc90d..c73b5d1 100644 --- a/cmd/weatherfeeder/config.yml +++ b/cmd/weatherfeeder/config.yml @@ -40,6 +40,22 @@ sources: # url: "https://api.weather.gov/stations/KCPS/observations/latest" # user_agent: "HomeOps (eric@maximumdirect.net)" +# - name: NWSForecastSTL +# kind: forecast +# driver: nws_forecast +# every: 1m +# params: +# url: "https://api.weather.gov/gridpoints/LSX/90,74/forecast" +# user_agent: "HomeOps (eric@maximumdirect.net)" + +# - name: NWSHourlyForecastSTL +# kind: forecast +# driver: nws_forecast +# every: 1m +# params: +# url: "https://api.weather.gov/gridpoints/LSX/90,74/forecast/hourly" +# user_agent: "HomeOps (eric@maximumdirect.net)" + # - name: NWSAlertsSTL # kind: alert # driver: nws_alerts diff --git a/internal/model/forecast.go b/internal/model/forecast.go index 45089af..6acb475 100644 --- a/internal/model/forecast.go +++ b/internal/model/forecast.go @@ -3,13 +3,117 @@ package model import "time" -// WeatherForecast identity fields (as you described). -type WeatherForecast struct { - IssuedBy string `json:"issuedBy,omitempty"` // e.g. "NWS" - IssuedAt time.Time `json:"issuedAt"` // when forecast product was issued - ForecastType string `json:"forecastType,omitempty"` // e.g. "hourly", "daily" - ForecastStart time.Time `json:"forecastStart"` // start of the applicable forecast period +// ForecastProduct distinguishes *what kind* of forecast a provider is offering. +// This is intentionally canonical and not provider-nomenclature. +type ForecastProduct string - // TODO: You’ll likely want ForecastEnd too. - // TODO: Add meteorological fields you care about. +const ( + // ForecastProductHourly is a sub-daily forecast where each period is typically ~1 hour. + ForecastProductHourly ForecastProduct = "hourly" + + // ForecastProductNarrative is a human-oriented sequence of periods like + // "Tonight", "Friday", "Friday Night" with variable duration. + // + // (NWS "/forecast" looks like this; it is not strictly “daily”.) + ForecastProductNarrative ForecastProduct = "narrative" + + // ForecastProductDaily is a calendar-day (or day-bucketed) forecast where a period + // commonly carries min/max values (Open-Meteo daily, many others). + ForecastProductDaily ForecastProduct = "daily" +) + +// WeatherForecastRun is a single issued forecast snapshot for a location and product. +// +// Design goals: +// - Immutable snapshot semantics: “provider asserted X at IssuedAt”. +// - Provider-independent schema: normalize many upstreams into one shape. +// - Retrieval-friendly: periods are inside the run, but can be stored/indexed separately. +type WeatherForecastRun struct { + // Identity / metadata (aligned with WeatherObservation’s StationID/StationName/Timestamp). + LocationID string `json:"locationId,omitempty"` + LocationName string `json:"locationName,omitempty"` + IssuedAt time.Time `json:"issuedAt"` // required: when this run was generated/issued + + // Some providers include both a generated time and a later update time. + // Keep UpdatedAt optional; many providers won’t supply it. + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + + // What sort of forecast this run represents (hourly vs narrative vs daily). + Product ForecastProduct `json:"product"` + + // Optional spatial context. Many providers are fundamentally lat/lon-based. + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + + // Kept to align with WeatherObservation and because elevation is sometimes important + // for interpreting temps/precip, even if not always supplied by the provider. + ElevationMeters *float64 `json:"elevationMeters,omitempty"` + + // The forecast periods contained in this issued run. Order should be chronological. + Periods []WeatherForecastPeriod `json:"periods"` +} + +// WeatherForecastPeriod is a forecast for a specific valid interval [StartTime, EndTime). +// +// Conceptually, it mirrors WeatherObservation (condition + measurements), but: +// / - It has a time *range* (start/end) instead of a single timestamp. +// / - It adds forecast-specific fields like probability/amount of precip. +// / - It supports min/max for “daily” products (and other aggregated periods). +type WeatherForecastPeriod struct { + // Identity / validity window (required) + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + + // Human-facing period label (e.g., "Tonight", "Friday"). Often empty for hourly. + Name string `json:"name,omitempty"` + + // Canonical day/night hint (aligned with WeatherObservation.IsDay). + // Providers vary in whether they explicitly include this. + IsDay *bool `json:"isDay,omitempty"` + + // Canonical internal representation (provider-independent). + // Like WeatherObservation, this is required; use an “unknown” WMOCode if unmappable. + ConditionCode WMOCode `json:"conditionCode"` + + // Provider-independent short text describing the conditions (normalized, if possible). + ConditionText string `json:"conditionText,omitempty"` + + // Provider-specific “evidence” for troubleshooting mapping and drift. + ProviderRawDescription string `json:"providerRawDescription,omitempty"` + + // Human-facing narrative. Not all providers supply rich text (Open-Meteo often won’t). + TextDescription string `json:"textDescription,omitempty"` // short phrase / summary + DetailedText string `json:"detailedText,omitempty"` // longer narrative, if available + + // Provider-specific (legacy / transitional) + IconURL string `json:"iconUrl,omitempty"` + + // Core predicted measurements (nullable; units align with WeatherObservation) + TemperatureC *float64 `json:"temperatureC,omitempty"` + + // For aggregated products (notably “daily”), providers may supply min/max. + TemperatureCMin *float64 `json:"temperatureCMin,omitempty"` + TemperatureCMax *float64 `json:"temperatureCMax,omitempty"` + + DewpointC *float64 `json:"dewpointC,omitempty"` + RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"` + WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"` + WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"` + WindGustKmh *float64 `json:"windGustKmh,omitempty"` + BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"` + VisibilityMeters *float64 `json:"visibilityMeters,omitempty"` + WindChillC *float64 `json:"windChillC,omitempty"` + HeatIndexC *float64 `json:"heatIndexC,omitempty"` + CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty"` + + // Precipitation (forecast-specific). Keep these generic and provider-independent. + ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty"` + + // 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"` + + // Optional extras that some providers supply and downstream might care about. + UVIndex *float64 `json:"uvIndex,omitempty"` } diff --git a/internal/normalizers/doc.go b/internal/normalizers/doc.go index 84f9c9e..663b8d1 100644 --- a/internal/normalizers/doc.go +++ b/internal/normalizers/doc.go @@ -34,7 +34,7 @@ // as they do not define additional Normalizer types. // // 2. Provider-level shared helpers live under the provider directory: -// internal/normalizers// +// internal/providers// // // Use this for provider-specific quirks that should be shared by BOTH sources // and normalizers (time parsing, URL/unit invariants, ID normalization, etc.). @@ -44,8 +44,6 @@ // - types.go (provider JSON structs) // - common.go (provider-shared helpers) // - mapping.go (provider mapping logic) -// Use common.go only when you truly have “shared across multiple normalizers -// within this provider” helpers. // // 3. Cross-provider helpers live in: // internal/normalizers/common/ diff --git a/internal/normalizers/openmeteo/common.go b/internal/normalizers/openmeteo/common.go new file mode 100644 index 0000000..c2b630b --- /dev/null +++ b/internal/normalizers/openmeteo/common.go @@ -0,0 +1,19 @@ +// FILE: ./internal/normalizers/openmeteo/common.go +package openmeteo + +import ( + "time" + + openmeteo "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/openmeteo" +) + +// parseOpenMeteoTime parses Open-Meteo timestamps. +// +// The actual parsing logic lives in internal/providers/openmeteo so both the +// source (envelope EffectiveAt / event ID) and normalizer (canonical payload) +// can share identical timestamp behavior. +// +// We keep this thin wrapper to avoid churn in the normalizer package. +func parseOpenMeteoTime(s string, tz string, utcOffsetSeconds int) (time.Time, error) { + return openmeteo.ParseTime(s, tz, utcOffsetSeconds) +} diff --git a/internal/sources/builtins.go b/internal/sources/builtins.go index b87f80b..7bb0ceb 100644 --- a/internal/sources/builtins.go +++ b/internal/sources/builtins.go @@ -1,8 +1,6 @@ package sources import ( - "fmt" - "gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/nws" "gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/openmeteo" "gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/openweather" @@ -35,22 +33,3 @@ func RegisterBuiltins(r *fksource.Registry) { return openweather.NewObservationSource(cfg) }) } - -// Optional: centralize some common config checks used by multiple drivers. -// -// NOTE: feedkit/config.SourceConfig intentionally keeps driver-specific options -// inside cfg.Params, so drivers can evolve independently without feedkit -// importing domain config packages. -func RequireURL(cfg config.SourceConfig) error { - if cfg.Params == nil { - return fmt.Errorf("source %q: params.url is required", cfg.Name) - } - - // Canonical key is "url". We also accept "URL" as a convenience. - url, ok := cfg.ParamString("url", "URL") - if !ok { - return fmt.Errorf("source %q: params.url is required", cfg.Name) - } - _ = url // (optional) return it if you want this helper to provide the value - return nil -} diff --git a/internal/sources/nws/alerts.go b/internal/sources/nws/alerts.go index 9dc44a9..9533e17 100644 --- a/internal/sources/nws/alerts.go +++ b/internal/sources/nws/alerts.go @@ -3,52 +3,37 @@ package nws import ( "context" "fmt" - "strings" "gitea.maximumdirect.net/ejr/feedkit/config" "gitea.maximumdirect.net/ejr/feedkit/event" + "gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/common" ) +// AlertsSource polls an NWS alerts endpoint and will emit RAW alert Events. +// For now Poll remains TODO; this file just migrates to the shared HTTPSource spine. type AlertsSource struct { - name string - url string - userAgent string + http *common.HTTPSource } func NewAlertsSource(cfg config.SourceConfig) (*AlertsSource, error) { - if strings.TrimSpace(cfg.Name) == "" { - return nil, fmt.Errorf("nws_alerts: name is required") - } - if cfg.Params == nil { - return nil, fmt.Errorf("nws_alerts %q: params are required (need params.url and params.user_agent)", cfg.Name) + const driver = "nws_alerts" + + // NWS APIs are typically GeoJSON; allow fallback to plain JSON as well. + hs, err := common.NewHTTPSource(driver, cfg, "application/geo+json, application/json") + if err != nil { + return nil, err } - // Driver-specific options live in cfg.Params to keep feedkit domain-agnostic. - // Use the typed accessor so callers can’t accidentally pass non-strings to TrimSpace. - url, ok := cfg.ParamString("url", "URL") - if !ok { - return nil, fmt.Errorf("nws_alerts %q: params.url is required", cfg.Name) - } - - ua, ok := cfg.ParamString("user_agent", "userAgent") - if !ok { - return nil, fmt.Errorf("nws_alerts %q: params.user_agent is required", cfg.Name) - } - - return &AlertsSource{ - name: cfg.Name, - url: url, - userAgent: ua, - }, nil + return &AlertsSource{http: hs}, nil } -func (s *AlertsSource) Name() string { return s.name } +func (s *AlertsSource) Name() string { return s.http.Name } // Kind is used for routing/policy. -// The envelope type is event.Event; payload will eventually be something like model.WeatherAlert. +// The envelope type is event.Event; payload will eventually normalize into model.WeatherAlert. func (s *AlertsSource) Kind() event.Kind { return event.Kind("alert") } func (s *AlertsSource) Poll(ctx context.Context) ([]event.Event, error) { _ = ctx - return nil, fmt.Errorf("nws.AlertsSource.Poll: TODO implement (url=%s)", s.url) + return nil, fmt.Errorf("nws.AlertsSource.Poll: TODO implement") }