Add convective outlook Postgres read adapter

This commit is contained in:
2026-06-11 15:33:26 +00:00
parent 6e0b71f6f4
commit 63c8f33a2a
5 changed files with 413 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
// outlooks_mapper.go maps outlook rows into weather model payloads.
// Layer: adapters/outbound/postgres outlook feature.
package postgres
import (
"database/sql"
"encoding/json"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func mapOutlookRunParentRow(row outlookRunParentRow) model.WeatherOutlookRun {
return model.WeatherOutlookRun{
LocationID: stringValue(row.LocationID),
LocationName: stringValue(row.LocationName),
Latitude: float64Ptr(row.Latitude),
Longitude: float64Ptr(row.Longitude),
AsOf: row.AsOf.UTC(),
IssuedAt: timePtr(row.IssuedAt),
}
}
func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
geometry := []byte(row.GeometryJSON)
if !json.Valid(geometry) {
return model.WeatherOutlook{}, fmt.Errorf("decode outlook geometry: invalid JSON")
}
return model.WeatherOutlook{
ID: row.OutlookID,
Provider: row.Provider,
Product: row.Product,
Day: row.Day,
OutlookType: row.OutlookType,
Label: row.Label,
LabelText: stringValue(row.LabelText),
SeverityRank: intPtr(row.SeverityRank),
ValidFrom: row.ValidFrom.UTC(),
ValidTo: row.ValidTo.UTC(),
IssuedAt: row.IssuedAt.UTC(),
ExpiresAt: row.ExpiresAt.UTC(),
Forecaster: stringValue(row.Forecaster),
Headline: stringValue(row.Headline),
Summary: stringValue(row.Summary),
Discussion: stringValue(row.Discussion),
SourceURL: stringValue(row.SourceURL),
ImageURL: stringValue(row.ImageURL),
ContainsLocation: row.ContainsLocation,
Geometry: json.RawMessage(append([]byte(nil), geometry...)),
}, nil
}
func intPtr(v sql.NullInt64) *int {
if !v.Valid {
return nil
}
out := int(v.Int64)
return &out
}

View File

@@ -0,0 +1,174 @@
// outlooks_mapper_test.go validates outlook row mapping.
// Layer: adapters/outbound/postgres outlook mapper tests.
package postgres
import (
"database/sql"
"testing"
"time"
)
func TestMapOutlookRunParentRowNullables(t *testing.T) {
asOf := time.Date(2026, 6, 11, 11, 30, 0, 0, time.FixedZone("CDT", -5*3600))
issuedAt := asOf.Add(-30 * time.Minute)
latitude := 38.62
run := mapOutlookRunParentRow(outlookRunParentRow{
EventID: "evt-outlook-run",
LocationID: sql.NullString{String: "stl", Valid: true},
LocationName: sql.NullString{String: "St. Louis", Valid: true},
Latitude: sql.NullFloat64{Float64: latitude, Valid: true},
Longitude: sql.NullFloat64{Valid: false},
AsOf: asOf,
IssuedAt: sql.NullTime{Time: issuedAt, Valid: true},
})
if run.LocationID != "stl" || run.LocationName != "St. Louis" {
t.Fatalf("unexpected location metadata: %+v", run)
}
if run.Latitude == nil || *run.Latitude != latitude {
t.Fatalf("expected latitude pointer %v, got %v", latitude, run.Latitude)
}
if run.Longitude != nil {
t.Fatalf("expected nil longitude, got %v", *run.Longitude)
}
if run.AsOf.Location().String() != "UTC" {
t.Fatalf("expected asOf UTC normalization, got %s", run.AsOf.Location())
}
if run.IssuedAt == nil || run.IssuedAt.Location().String() != "UTC" {
t.Fatalf("expected issuedAt UTC pointer, got %v", run.IssuedAt)
}
if run.Outlooks != nil {
t.Fatalf("expected nil outlooks before child load, got %+v", run.Outlooks)
}
}
func TestMapOutlookRunParentRowMissingOptionals(t *testing.T) {
run := mapOutlookRunParentRow(outlookRunParentRow{
AsOf: time.Date(2026, 6, 11, 16, 30, 0, 0, time.UTC),
})
if run.LocationID != "" || run.LocationName != "" {
t.Fatalf("expected empty location metadata, got %+v", run)
}
if run.Latitude != nil || run.Longitude != nil || run.IssuedAt != nil {
t.Fatalf("expected nil optional pointers, got %+v", run)
}
}
func TestMapOutlookRowMapsFields(t *testing.T) {
validFrom := time.Date(2026, 6, 11, 7, 0, 0, 0, time.FixedZone("CDT", -5*3600))
validTo := validFrom.Add(12 * time.Hour)
issuedAt := validFrom.Add(-1 * time.Hour)
expiresAt := validTo
severityRank := int64(5)
geometry := `{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,38]]]}`
outlook, err := mapOutlookRow(outlookRow{
OutlookIndex: 3,
OutlookID: "spc-20260611-1300-day1-cat-slight",
Provider: "spc",
Product: "convective",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: sql.NullString{String: "Slight Risk", Valid: true},
SeverityRank: sql.NullInt64{Int64: severityRank, Valid: true},
ValidFrom: validFrom,
ValidTo: validTo,
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
Forecaster: sql.NullString{String: "DIAL", Valid: true},
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
SourceURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", Valid: true},
ImageURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", Valid: true},
ContainsLocation: true,
GeometryJSON: geometry,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if outlook.ID != "spc-20260611-1300-day1-cat-slight" {
t.Fatalf("unexpected outlook id: %q", outlook.ID)
}
if outlook.Provider != "spc" || outlook.Product != "convective" || outlook.Day != 1 || outlook.OutlookType != "categorical" {
t.Fatalf("unexpected core fields: %+v", outlook)
}
if outlook.Label != "SLGT" || outlook.LabelText != "Slight Risk" {
t.Fatalf("unexpected label fields: %+v", outlook)
}
if outlook.SeverityRank == nil || *outlook.SeverityRank != int(severityRank) {
t.Fatalf("expected severity rank %d, got %v", severityRank, outlook.SeverityRank)
}
if outlook.Forecaster != "DIAL" || outlook.Headline == "" || outlook.Summary == "" || outlook.Discussion == "" {
t.Fatalf("unexpected text fields: %+v", outlook)
}
if outlook.SourceURL == "" || outlook.ImageURL == "" {
t.Fatalf("expected source and image URLs: %+v", outlook)
}
if !outlook.ContainsLocation {
t.Fatal("expected containsLocation true")
}
if outlook.ValidFrom.Location().String() != "UTC" ||
outlook.ValidTo.Location().String() != "UTC" ||
outlook.IssuedAt.Location().String() != "UTC" ||
outlook.ExpiresAt.Location().String() != "UTC" {
t.Fatalf("expected UTC timestamps, got %s %s %s %s", outlook.ValidFrom, outlook.ValidTo, outlook.IssuedAt, outlook.ExpiresAt)
}
if string(outlook.Geometry) != geometry {
t.Fatalf("expected geometry bytes preserved, got %s", outlook.Geometry)
}
}
func TestMapOutlookRowMissingOptionals(t *testing.T) {
outlook, err := mapOutlookRow(outlookRow{
OutlookID: "tor-1",
Provider: "spc",
Product: "convective",
Day: 1,
OutlookType: "tornado",
Label: "2%",
ValidFrom: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
ValidTo: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC),
IssuedAt: time.Date(2026, 6, 11, 11, 0, 0, 0, time.UTC),
ExpiresAt: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC),
ContainsLocation: false,
GeometryJSON: `{"type":"Point","coordinates":[-90.2,38.6]}`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.Headline != "" ||
outlook.Summary != "" || outlook.Discussion != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
t.Fatalf("expected optional strings to map to empty values, got %+v", outlook)
}
if outlook.SeverityRank != nil {
t.Fatalf("expected nil severity rank, got %v", *outlook.SeverityRank)
}
if outlook.ContainsLocation {
t.Fatal("expected containsLocation false")
}
}
func TestMapOutlookRowRejectsInvalidGeometry(t *testing.T) {
_, err := mapOutlookRow(outlookRow{
OutlookID: "bad-geometry",
Provider: "spc",
Product: "convective",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
ValidFrom: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
ValidTo: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC),
IssuedAt: time.Date(2026, 6, 11, 11, 0, 0, 0, time.UTC),
ExpiresAt: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC),
GeometryJSON: `{"type":"Point"`,
})
if err == nil {
t.Fatal("expected invalid geometry error")
}
}

View File

@@ -0,0 +1,45 @@
// outlooks_queries.go contains SQL text for outlook reads.
// Layer: adapters/outbound/postgres outlook feature.
package postgres
const (
queryLatestConvectiveOutlookRun = `
SELECT
event_id,
location_id,
location_name,
latitude,
longitude,
as_of,
issued_at
FROM outlook_runs
ORDER BY as_of DESC, event_emitted_at DESC
LIMIT 1`
queryOutlooksForRun = `
SELECT
outlook_index,
outlook_id,
provider,
product,
day,
outlook_type,
label,
label_text,
severity_rank,
valid_from,
valid_to,
issued_at,
expires_at,
forecaster,
headline,
summary,
discussion,
source_url,
image_url,
contains_location,
geometry_json
FROM outlooks
WHERE run_event_id = $1
ORDER BY outlook_index ASC`
)

View File

@@ -0,0 +1,92 @@
// outlooks_read.go executes outlook-run and outlook queries.
// Layer: adapters/outbound/postgres outlook feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestConvectiveOutlookRun(ctx context.Context) (*model.WeatherOutlookRun, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row outlookRunParentRow
err := r.db.QueryRowContext(ctx, queryLatestConvectiveOutlookRun).Scan(
&row.EventID,
&row.LocationID,
&row.LocationName,
&row.Latitude,
&row.Longitude,
&row.AsOf,
&row.IssuedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest convective outlook run: %w", err)
}
run := mapOutlookRunParentRow(row)
outlooks, err := r.loadOutlooks(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Outlooks = outlooks
return &run, nil
}
func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.WeatherOutlook, error) {
rows, err := r.db.QueryContext(ctx, queryOutlooksForRun, eventID)
if err != nil {
return nil, fmt.Errorf("query outlooks: %w", err)
}
defer rows.Close()
out := make([]model.WeatherOutlook, 0)
for rows.Next() {
var row outlookRow
if err := rows.Scan(
&row.OutlookIndex,
&row.OutlookID,
&row.Provider,
&row.Product,
&row.Day,
&row.OutlookType,
&row.Label,
&row.LabelText,
&row.SeverityRank,
&row.ValidFrom,
&row.ValidTo,
&row.IssuedAt,
&row.ExpiresAt,
&row.Forecaster,
&row.Headline,
&row.Summary,
&row.Discussion,
&row.SourceURL,
&row.ImageURL,
&row.ContainsLocation,
&row.GeometryJSON,
); err != nil {
return nil, fmt.Errorf("scan outlook row: %w", err)
}
outlook, err := mapOutlookRow(row)
if err != nil {
return nil, fmt.Errorf("map outlook row: %w", err)
}
out = append(out, outlook)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate outlook rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,42 @@
// outlooks_rows.go defines row DTOs for outlook reads.
// Layer: adapters/outbound/postgres outlook feature.
package postgres
import (
"database/sql"
"time"
)
type outlookRunParentRow struct {
EventID string
LocationID sql.NullString
LocationName sql.NullString
Latitude sql.NullFloat64
Longitude sql.NullFloat64
AsOf time.Time
IssuedAt sql.NullTime
}
type outlookRow struct {
OutlookIndex int
OutlookID string
Provider string
Product string
Day int
OutlookType string
Label string
LabelText sql.NullString
SeverityRank sql.NullInt64
ValidFrom time.Time
ValidTo time.Time
IssuedAt time.Time
ExpiresAt time.Time
Forecaster sql.NullString
Headline sql.NullString
Summary sql.NullString
Discussion sql.NullString
SourceURL sql.NullString
ImageURL sql.NullString
ContainsLocation bool
GeometryJSON string
}