Updates in preparation for adding forecast sources.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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: You’ll 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 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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
19
internal/normalizers/openmeteo/common.go
Normal file
19
internal/normalizers/openmeteo/common.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AlertsSource) Name() string { return s.name }
|
func (s *AlertsSource) Name() string { return s.http.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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user