weatherfeeder: split the former maximumdirect.net/weatherd project in two.
feedkit now contains a reusable core, while weatherfeeder is a concrete implementation that includes weather-specific functions.
This commit is contained in:
23
internal/model/alert.go
Normal file
23
internal/model/alert.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Placeholder for NWS alerts (GeoJSON feature properties are rich).
|
||||
type WeatherAlert struct {
|
||||
ID string
|
||||
|
||||
Event string
|
||||
Headline string
|
||||
Description string
|
||||
Instruction string
|
||||
|
||||
Severity string
|
||||
Urgency string
|
||||
Certainty string
|
||||
|
||||
Sent *time.Time
|
||||
Effective *time.Time
|
||||
Expires *time.Time
|
||||
|
||||
Areas []string
|
||||
}
|
||||
212
internal/model/event.go
Normal file
212
internal/model/event.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrInvalidEvent is a sentinel error used for errors.Is checks.
|
||||
var ErrInvalidEvent = errors.New("invalid event")
|
||||
|
||||
// EventValidationError reports one or more problems with an Event.
|
||||
//
|
||||
// We keep this structured because it makes debugging faster than a single
|
||||
// "invalid event" string; you get all issues in one pass.
|
||||
type EventValidationError struct {
|
||||
Problems []string
|
||||
}
|
||||
|
||||
func (e *EventValidationError) Error() string {
|
||||
if e == nil || len(e.Problems) == 0 {
|
||||
return "invalid event"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("invalid event:\n")
|
||||
for _, p := range e.Problems {
|
||||
b.WriteString(" - ")
|
||||
b.WriteString(p)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// Is lets errors.Is(err, ErrInvalidEvent) work.
|
||||
func (e *EventValidationError) Is(target error) bool {
|
||||
return target == ErrInvalidEvent
|
||||
}
|
||||
|
||||
// Event is the normalized unit your pipeline moves around.
|
||||
// It wraps exactly one of Observation/Forecast/Alert plus metadata.
|
||||
type Event struct {
|
||||
ID string // stable dedupe/storage key (source-defined or computed)
|
||||
Kind Kind
|
||||
Source string // configured source name (e.g. "NWSObservationKSTL")
|
||||
EmittedAt time.Time // when *your* system emitted this event
|
||||
EffectiveAt *time.Time // optional: “time the event applies”
|
||||
|
||||
// Union payload: EXACTLY ONE must be non-nil.
|
||||
Observation *WeatherObservation
|
||||
Forecast *WeatherForecast
|
||||
Alert *WeatherAlert
|
||||
}
|
||||
|
||||
// Validate enforces Event invariants.
|
||||
//
|
||||
// This is intentionally strict. If an event is invalid, we want to find out
|
||||
// immediately rather than letting it drift into sinks or storage.
|
||||
//
|
||||
// Invariants enforced:
|
||||
// - ID is non-empty
|
||||
// - Kind is known
|
||||
// - Source is non-empty
|
||||
// - EmittedAt is non-zero
|
||||
// - Exactly one payload pointer is non-nil
|
||||
// - Kind matches the non-nil payload
|
||||
func (e Event) Validate() error {
|
||||
var problems []string
|
||||
|
||||
if strings.TrimSpace(e.ID) == "" {
|
||||
problems = append(problems, "ID is required")
|
||||
}
|
||||
if !e.Kind.IsKnown() {
|
||||
problems = append(problems, fmt.Sprintf("Kind %q is not recognized", string(e.Kind)))
|
||||
}
|
||||
if strings.TrimSpace(e.Source) == "" {
|
||||
problems = append(problems, "Source is required")
|
||||
}
|
||||
if e.EmittedAt.IsZero() {
|
||||
problems = append(problems, "EmittedAt must be set (non-zero)")
|
||||
}
|
||||
|
||||
// Count payloads and ensure Kind matches.
|
||||
payloadCount := 0
|
||||
if e.Observation != nil {
|
||||
payloadCount++
|
||||
if e.Kind != KindObservation {
|
||||
problems = append(problems, fmt.Sprintf("Observation payload present but Kind=%q", string(e.Kind)))
|
||||
}
|
||||
}
|
||||
if e.Forecast != nil {
|
||||
payloadCount++
|
||||
if e.Kind != KindForecast {
|
||||
problems = append(problems, fmt.Sprintf("Forecast payload present but Kind=%q", string(e.Kind)))
|
||||
}
|
||||
}
|
||||
if e.Alert != nil {
|
||||
payloadCount++
|
||||
if e.Kind != KindAlert {
|
||||
problems = append(problems, fmt.Sprintf("Alert payload present but Kind=%q", string(e.Kind)))
|
||||
}
|
||||
}
|
||||
|
||||
if payloadCount == 0 {
|
||||
problems = append(problems, "exactly one payload must be set; all payloads are nil")
|
||||
} else if payloadCount > 1 {
|
||||
problems = append(problems, "exactly one payload must be set; multiple payloads are non-nil")
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return &EventValidationError{Problems: problems}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewObservationEvent constructs a valid observation Event.
|
||||
//
|
||||
// If emittedAt is zero, it defaults to time.Now().UTC().
|
||||
// effectiveAt is optional (nil allowed).
|
||||
//
|
||||
// The returned Event is guaranteed valid (or you get an error).
|
||||
func NewObservationEvent(
|
||||
id string,
|
||||
source string,
|
||||
emittedAt time.Time,
|
||||
effectiveAt *time.Time,
|
||||
obs *WeatherObservation,
|
||||
) (Event, error) {
|
||||
if obs == nil {
|
||||
return Event{}, fmt.Errorf("%w: observation payload is nil", ErrInvalidEvent)
|
||||
}
|
||||
|
||||
if emittedAt.IsZero() {
|
||||
emittedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
e := Event{
|
||||
ID: strings.TrimSpace(id),
|
||||
Kind: KindObservation,
|
||||
Source: strings.TrimSpace(source),
|
||||
EmittedAt: emittedAt,
|
||||
EffectiveAt: effectiveAt,
|
||||
Observation: obs,
|
||||
}
|
||||
|
||||
if err := e.Validate(); err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// NewForecastEvent constructs a valid forecast Event.
|
||||
func NewForecastEvent(
|
||||
id string,
|
||||
source string,
|
||||
emittedAt time.Time,
|
||||
effectiveAt *time.Time,
|
||||
fc *WeatherForecast,
|
||||
) (Event, error) {
|
||||
if fc == nil {
|
||||
return Event{}, fmt.Errorf("%w: forecast payload is nil", ErrInvalidEvent)
|
||||
}
|
||||
|
||||
if emittedAt.IsZero() {
|
||||
emittedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
e := Event{
|
||||
ID: strings.TrimSpace(id),
|
||||
Kind: KindForecast,
|
||||
Source: strings.TrimSpace(source),
|
||||
EmittedAt: emittedAt,
|
||||
EffectiveAt: effectiveAt,
|
||||
Forecast: fc,
|
||||
}
|
||||
|
||||
if err := e.Validate(); err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// NewAlertEvent constructs a valid alert Event.
|
||||
func NewAlertEvent(
|
||||
id string,
|
||||
source string,
|
||||
emittedAt time.Time,
|
||||
effectiveAt *time.Time,
|
||||
a *WeatherAlert,
|
||||
) (Event, error) {
|
||||
if a == nil {
|
||||
return Event{}, fmt.Errorf("%w: alert payload is nil", ErrInvalidEvent)
|
||||
}
|
||||
|
||||
if emittedAt.IsZero() {
|
||||
emittedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
e := Event{
|
||||
ID: strings.TrimSpace(id),
|
||||
Kind: KindAlert,
|
||||
Source: strings.TrimSpace(source),
|
||||
EmittedAt: emittedAt,
|
||||
EffectiveAt: effectiveAt,
|
||||
Alert: a,
|
||||
}
|
||||
|
||||
if err := e.Validate(); err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
17
internal/model/forecast.go
Normal file
17
internal/model/forecast.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// WeatherForecast identity fields (as you described).
|
||||
type WeatherForecast struct {
|
||||
IssuedBy string // e.g. "NWS"
|
||||
IssuedAt time.Time // when forecast product was issued
|
||||
ForecastType string // e.g. "hourly", "daily"
|
||||
ForecastStart time.Time // start of the applicable forecast period
|
||||
|
||||
// TODO: You’ll likely want ForecastEnd too.
|
||||
|
||||
// TODO: Add meteorological fields you care about.
|
||||
// Temperature, precip probability, wind, etc.
|
||||
// Decide if you want a single "period" model or an array of periods.
|
||||
}
|
||||
23
internal/model/kind.go
Normal file
23
internal/model/kind.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
// Kind identifies which payload an Event carries.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindObservation Kind = "observation"
|
||||
KindForecast Kind = "forecast"
|
||||
KindAlert Kind = "alert"
|
||||
)
|
||||
|
||||
// IsKnown returns true if k is one of the kinds supported by this binary.
|
||||
//
|
||||
// This is intentionally strict: if you add new kinds later, update this list.
|
||||
// That keeps validation useful (it catches partially-constructed events).
|
||||
func (k Kind) IsKnown() bool {
|
||||
switch k {
|
||||
case KindObservation, KindForecast, KindAlert:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
72
internal/model/observation.go
Normal file
72
internal/model/observation.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type WeatherObservation struct {
|
||||
// Identity / metadata
|
||||
StationID string
|
||||
StationName string
|
||||
Timestamp time.Time
|
||||
|
||||
// Canonical internal representation (provider-independent).
|
||||
//
|
||||
// ConditionCode should be populated by all sources. ConditionText should be the
|
||||
// canonical human-readable string derived from the WMO code (not the provider's
|
||||
// original wording).
|
||||
//
|
||||
// IsDay is optional; some providers supply a day/night flag (e.g., Open-Meteo),
|
||||
// while others may not (e.g., NWS observations). When unknown, it can be nil.
|
||||
ConditionCode WMOCode
|
||||
ConditionText string
|
||||
IsDay *bool
|
||||
|
||||
// Provider-specific “evidence” for troubleshooting mapping and drift.
|
||||
//
|
||||
// This is intentionally limited: it is not intended to be used downstream for
|
||||
// business logic. Downstream logic should rely on ConditionCode / ConditionText.
|
||||
ProviderRawDescription string
|
||||
|
||||
// Human-facing (legacy / transitional)
|
||||
//
|
||||
// TextDescription currently carries provider text in existing drivers.
|
||||
// As we transition to WMO-based normalization, downstream presentation should
|
||||
// switch to using ConditionText. After migration, this may be removed or repurposed.
|
||||
TextDescription string
|
||||
|
||||
// Provider-specific (legacy / transitional)
|
||||
//
|
||||
// IconURL is not part of the canonical internal vocabulary. It's retained only
|
||||
// because current sources populate it; it is not required for downstream systems.
|
||||
IconURL string
|
||||
|
||||
// Core measurements (nullable)
|
||||
TemperatureC *float64
|
||||
DewpointC *float64
|
||||
|
||||
WindDirectionDegrees *float64
|
||||
WindSpeedKmh *float64
|
||||
WindGustKmh *float64
|
||||
|
||||
BarometricPressurePa *float64
|
||||
SeaLevelPressurePa *float64
|
||||
VisibilityMeters *float64
|
||||
|
||||
RelativeHumidityPercent *float64
|
||||
WindChillC *float64
|
||||
HeatIndexC *float64
|
||||
|
||||
ElevationMeters *float64
|
||||
RawMessage string
|
||||
|
||||
PresentWeather []PresentWeather
|
||||
CloudLayers []CloudLayer
|
||||
}
|
||||
|
||||
type CloudLayer struct {
|
||||
BaseMeters *float64
|
||||
Amount string
|
||||
}
|
||||
|
||||
type PresentWeather struct {
|
||||
Raw map[string]any
|
||||
}
|
||||
15
internal/model/wmo.go
Normal file
15
internal/model/wmo.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
// WMOCode is the canonical internal “current conditions” vocabulary.
|
||||
//
|
||||
// We standardize on the WMO weather interpretation codes used by providers like
|
||||
// Open-Meteo, and we map other providers (e.g., NWS) into these codes.
|
||||
//
|
||||
// Reference codes include: 0,1,2,3,45,48,51,53,...,99.
|
||||
type WMOCode int
|
||||
|
||||
const (
|
||||
// WMOUnknown is used when we cannot confidently map an upstream condition
|
||||
// into a known WMO code.
|
||||
WMOUnknown WMOCode = -1
|
||||
)
|
||||
Reference in New Issue
Block a user