Updates in preparation for adding forecast sources.

This commit is contained in:
2026-01-16 00:04:37 -06:00
parent e10ba804ca
commit 0fcc536885
6 changed files with 162 additions and 61 deletions

View File

@@ -40,6 +40,22 @@ sources:
# url: "https://api.weather.gov/stations/KCPS/observations/latest" # url: "https://api.weather.gov/stations/KCPS/observations/latest"
# user_agent: "HomeOps (eric@maximumdirect.net)" # 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 # - name: NWSAlertsSTL
# kind: alert # kind: alert
# driver: nws_alerts # driver: nws_alerts

View File

@@ -3,13 +3,117 @@ package model
import "time" import "time"
// WeatherForecast identity fields (as you described). // ForecastProduct distinguishes *what kind* of forecast a provider is offering.
type WeatherForecast struct { // This is intentionally canonical and not provider-nomenclature.
IssuedBy string `json:"issuedBy,omitempty"` // e.g. "NWS" type ForecastProduct string
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
// TODO: Youll likely want ForecastEnd too. const (
// TODO: Add meteorological fields you care about. // 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 WeatherObservations 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 wont 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 wont).
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"`
} }

View File

@@ -34,7 +34,7 @@
// as they do not define additional Normalizer types. // as they do not define additional Normalizer types.
// //
// 2. Provider-level shared helpers live under the provider directory: // 2. Provider-level shared helpers live under the provider directory:
// internal/normalizers/<provider>/ // internal/providers/<provider>/
// //
// Use this for provider-specific quirks that should be shared by BOTH sources // Use this for provider-specific quirks that should be shared by BOTH sources
// and normalizers (time parsing, URL/unit invariants, ID normalization, etc.). // and normalizers (time parsing, URL/unit invariants, ID normalization, etc.).
@@ -44,8 +44,6 @@
// - types.go (provider JSON structs) // - types.go (provider JSON structs)
// - common.go (provider-shared helpers) // - common.go (provider-shared helpers)
// - mapping.go (provider mapping logic) // - 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: // 3. Cross-provider helpers live in:
// internal/normalizers/common/ // internal/normalizers/common/

View File

@@ -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)
}

View File

@@ -1,8 +1,6 @@
package sources package sources
import ( import (
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/nws" "gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/nws"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/openmeteo" "gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/openmeteo"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/openweather" "gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/openweather"
@@ -35,22 +33,3 @@ func RegisterBuiltins(r *fksource.Registry) {
return openweather.NewObservationSource(cfg) 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
}

View File

@@ -3,52 +3,37 @@ package nws
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"gitea.maximumdirect.net/ejr/feedkit/config" "gitea.maximumdirect.net/ejr/feedkit/config"
"gitea.maximumdirect.net/ejr/feedkit/event" "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 { type AlertsSource struct {
name string http *common.HTTPSource
url string
userAgent string
} }
func NewAlertsSource(cfg config.SourceConfig) (*AlertsSource, error) { func NewAlertsSource(cfg config.SourceConfig) (*AlertsSource, error) {
if strings.TrimSpace(cfg.Name) == "" { const driver = "nws_alerts"
return nil, fmt.Errorf("nws_alerts: name is required")
} // NWS APIs are typically GeoJSON; allow fallback to plain JSON as well.
if cfg.Params == nil { hs, err := common.NewHTTPSource(driver, cfg, "application/geo+json, application/json")
return nil, fmt.Errorf("nws_alerts %q: params are required (need params.url and params.user_agent)", cfg.Name) if err != nil {
return nil, err
} }
// Driver-specific options live in cfg.Params to keep feedkit domain-agnostic. return &AlertsSource{http: hs}, nil
// Use the typed accessor so callers cant 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") func (s *AlertsSource) Name() string { return s.http.Name }
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
}
func (s *AlertsSource) Name() string { return s.name }
// Kind is used for routing/policy. // 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) Kind() event.Kind { return event.Kind("alert") }
func (s *AlertsSource) Poll(ctx context.Context) ([]event.Event, error) { func (s *AlertsSource) Poll(ctx context.Context) ([]event.Event, error) {
_ = ctx _ = ctx
return nil, fmt.Errorf("nws.AlertsSource.Poll: TODO implement (url=%s)", s.url) return nil, fmt.Errorf("nws.AlertsSource.Poll: TODO implement")
} }