From 454f47b2b5f694af5980ba6f9aa73c5c25606b27 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 9 Jun 2026 20:16:47 +0000 Subject: [PATCH] Split weather data types from forecast derivation --- docs/internal/briefing.md | 2 +- docs/internal/forecast-derivation.md | 8 +-- docs/internal/weather-data.md | 4 +- docs/policy/development.md | 5 +- internal/adapters/weatherapi/client.go | 54 ++++++++--------- internal/adapters/weatherapi/client_test.go | 6 +- internal/app/app.go | 7 ++- internal/app/inspect.go | 12 ++-- internal/briefing/daily.go | 23 +++---- internal/briefing/daily_test.go | 25 ++++---- internal/briefing/package.go | 62 +++++++++---------- internal/briefing/storm.go | 35 +++++------ internal/briefing/storm_test.go | 38 ++++++------ internal/briefing/three_day_test.go | 7 ++- internal/briefing/weekend.go | 3 +- internal/briefing/weekend_test.go | 9 +-- internal/forecast/dayparts.go | 3 +- internal/forecast/derive.go | 63 ++++++++++---------- internal/forecast/derive_test.go | 33 +++++----- internal/promptinput/package.go | 14 ++--- internal/state/metadata.go | 40 ++++++------- internal/{forecast => weatherdata}/bundle.go | 4 +- 22 files changed, 234 insertions(+), 223 deletions(-) rename internal/{forecast => weatherdata}/bundle.go (98%) diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 4c7413c..d5dbf61 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -14,7 +14,7 @@ prose. Inputs: - resolved report definition, generation time, timezone, and valid period -- forecast bundle with source provenance and warnings +- `weatherdata.Bundle` with source provenance and warnings - derived daily or period summaries where required - configured units, timezone, and descriptive location context diff --git a/docs/internal/forecast-derivation.md b/docs/internal/forecast-derivation.md index fe8a2dc..cf32190 100644 --- a/docs/internal/forecast-derivation.md +++ b/docs/internal/forecast-derivation.md @@ -5,14 +5,14 @@ This document describes deterministic forecast summarization in ## Purpose -`internal/forecast` converts normalized bundle data into daily and period -summaries used by briefing builders. +`internal/forecast` converts normalized `weatherdata` bundle data into daily +and period summaries used by briefing builders. ## Inputs And Outputs Inputs: -- `forecast.Bundle` +- `weatherdata.Bundle` - local date or resolved report period - timezone - configured daypart definitions @@ -43,7 +43,7 @@ configuration. ## External Adapters Used -None directly. Forecast data arrives through `forecast.Bundle`. +None directly. Forecast data arrives through `weatherdata.Bundle`. ## State Or Manifest Behavior diff --git a/docs/internal/weather-data.md b/docs/internal/weather-data.md index 3a1afe0..bbe3ca2 100644 --- a/docs/internal/weather-data.md +++ b/docs/internal/weather-data.md @@ -1,6 +1,6 @@ # Weather Data Internals -This document describes Weather API ingestion into `forecast.Bundle`. +This document describes Weather API ingestion into `weatherdata.Bundle`. ## Purpose @@ -19,7 +19,7 @@ Inputs: Outputs: -- `forecast.Bundle` with observation, current conditions, hourly forecast, +- `weatherdata.Bundle` with observation, current conditions, hourly forecast, narrative forecast, active alerts, discussion, latest weather story, source records, and source warnings - stub source record for the daily forecast source slot diff --git a/docs/policy/development.md b/docs/policy/development.md index 781d772..07156ed 100644 --- a/docs/policy/development.md +++ b/docs/policy/development.md @@ -16,8 +16,9 @@ Developers and LLM coding agents should use it with - `internal/adapters/distributor`: Distributor upload adapter. - `internal/adapters/weatherapi`: Weather API HTTP adapter. - `internal/adapters/scriptorium`: Scriptorium subprocess adapter. -- `internal/forecast`: normalized bundle types and deterministic forecast - derivation. +- `internal/weatherdata`: normalized weather source facts, source metadata, and + source warnings. +- `internal/forecast`: deterministic forecast derivation. - `internal/report`: report definitions, valid periods, batches, output names, and comparison declarations. - `internal/briefing`: report-specific briefing package builders. diff --git a/internal/adapters/weatherapi/client.go b/internal/adapters/weatherapi/client.go index 6cd0dbc..d83cb81 100644 --- a/internal/adapters/weatherapi/client.go +++ b/internal/adapters/weatherapi/client.go @@ -1,4 +1,4 @@ -// Package weatherapi adapts the internal weather API to forecast bundles. +// Package weatherapi adapts the internal weather API to weather data bundles. package weatherapi import ( @@ -18,7 +18,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) type Client struct { @@ -83,11 +83,11 @@ func New(cfg config.Config, opts ...Option) (*Client, error) { return client, nil } -func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) { +func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) { fetchedAt := c.now() builder := bundleBuilder{ client: c, - bundle: &forecast.Bundle{FetchedAt: fetchedAt}, + bundle: &weatherdata.Bundle{FetchedAt: fetchedAt}, fetchedAt: fetchedAt, } @@ -121,7 +121,7 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) { type bundleBuilder struct { client *Client - bundle *forecast.Bundle + bundle *weatherdata.Bundle fetchedAt time.Time } @@ -133,7 +133,7 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error { if raw == nil { return b.handleMissing(&source, "observation data is missing", false) } - var observation forecast.Observation + var observation weatherdata.Observation if err := decodeSource(raw, &observation); err != nil { return b.handleMalformed(&source, err, false) } @@ -151,7 +151,7 @@ func (b *bundleBuilder) fetchCurrent(ctx context.Context) error { if raw == nil { return b.handleMissing(&source, "current conditions data is missing", false) } - var current forecast.Current + var current weatherdata.Current if err := decodeSource(raw, ¤t); err != nil { return b.handleMalformed(&source, err, false) } @@ -168,7 +168,7 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error { if raw == nil { return b.handleMissing(&source, "hourly forecast data is missing", true) } - var hourly forecast.ForecastRun + var hourly weatherdata.ForecastRun if err := decodeSource(raw, &hourly); err != nil { return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err) } @@ -190,7 +190,7 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error { if raw == nil { return b.handleMissing(&source, "narrative forecast data is missing", false) } - var narrative forecast.ForecastRun + var narrative weatherdata.ForecastRun if err := decodeSource(raw, &narrative); err != nil { return b.handleMalformed(&source, err, false) } @@ -210,11 +210,11 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error { return b.handleMissing(&source, "active alerts data is missing", false) } if isJSONNull(raw) { - b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)} + b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)} b.addSource(source) return nil } - var alerts forecast.AlertRun + var alerts weatherdata.AlertRun if err := decodeSource(raw, &alerts); err != nil { return b.handleMalformed(&source, err, false) } @@ -235,7 +235,7 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error { if raw == nil { return b.handleMissing(&source, "forecast discussion data is missing", false) } - var discussion forecast.Discussion + var discussion weatherdata.Discussion if err := decodeSource(raw, &discussion); err != nil { return b.handleMalformed(&source, err, false) } @@ -254,7 +254,7 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error { if raw == nil { return b.handleMissing(&source, "NWS weather story data is missing", false) } - var story forecast.WeatherStory + var story weatherdata.WeatherStory if err := decodeSource(raw, &story); err != nil { return b.handleMalformed(&source, err, false) } @@ -268,7 +268,7 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error { } func (b *bundleBuilder) addStub(sourceName string, message string) error { - source := forecast.Source{ + source := weatherdata.Source{ Name: sourceName, FetchedAt: b.fetchedAt, Missing: true, @@ -276,7 +276,7 @@ func (b *bundleBuilder) addStub(sourceName string, message string) error { return b.applyMissingPolicy(&source, "missing_source", message) } -func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, required bool) error { +func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error { source.Missing = true if required { return fmt.Errorf("%s from %s is required", message, source.Endpoint) @@ -284,7 +284,7 @@ func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, r return b.applyMissingPolicy(source, "missing_source", message) } -func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, required bool) error { +func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error { if required { return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err) } @@ -292,13 +292,13 @@ func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, requ return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err)) } -func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string, message string) error { +func (b *bundleBuilder) applyMissingPolicy(source *weatherdata.Source, code string, message string) error { policy := b.client.policyFor(source.Name) if policy == config.MissingSourceError { return fmt.Errorf("%s: %s", source.Name, message) } if policy == config.MissingSourceWarn { - warning := forecast.SourceWarning{ + warning := weatherdata.SourceWarning{ Source: source.Name, Code: code, Severity: "warning", @@ -313,7 +313,7 @@ func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string, return nil } -func (b *bundleBuilder) addSource(source forecast.Source) { +func (b *bundleBuilder) addSource(source weatherdata.Source) { b.bundle.Sources = append(b.bundle.Sources, source) } @@ -335,33 +335,33 @@ type envelope struct { Data json.RawMessage `json:"data"` } -func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, forecast.Source, error) { +func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) { reqURL := c.endpointURL(endpoint, opts) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil) if err != nil { - return nil, forecast.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err) + return nil, weatherdata.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err) } resp, err := c.httpClient.Do(req) if err != nil { - return nil, forecast.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err) + return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) if err != nil { - return nil, forecast.Source{}, fmt.Errorf("read %s response: %w", endpoint, err) + return nil, weatherdata.Source{}, fmt.Errorf("read %s response: %w", endpoint, err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, forecast.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body))) + return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body))) } var env envelope if err := json.Unmarshal(body, &env); err != nil { - return nil, forecast.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err) + return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err) } - source := forecast.Source{ + source := weatherdata.Source{ Name: sourceName, Endpoint: endpoint, Query: queryMap(reqURL.Query()), @@ -430,7 +430,7 @@ func sourceHash(raw json.RawMessage) (string, error) { return hex.EncodeToString(sum[:]), nil } -func SaveBundle(path string, bundle *forecast.Bundle) error { +func SaveBundle(path string, bundle *weatherdata.Bundle) error { if err := fileutil.WriteJSONAtomic(path, bundle); err != nil { return fmt.Errorf("save bundle: %w", err) } diff --git a/internal/adapters/weatherapi/client_test.go b/internal/adapters/weatherapi/client_test.go index 0d6c7ed..5c4fabb 100644 --- a/internal/adapters/weatherapi/client_test.go +++ b/internal/adapters/weatherapi/client_test.go @@ -12,7 +12,7 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestFetchBundleFromFixtures(t *testing.T) { @@ -423,7 +423,7 @@ func containsPath(requested []string, path string) bool { return false } -func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast.Source { +func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source { t.Helper() for _, source := range sources { if source.Name == name { @@ -431,7 +431,7 @@ func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast } } t.Fatalf("source %q not found in %#v", name, sources) - return forecast.Source{} + return weatherdata.Source{} } func hashFixtureData(t *testing.T, fixture string) string { diff --git a/internal/app/app.go b/internal/app/app.go index 1d63c33..3f1a79a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -20,6 +20,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) type ReportKind string @@ -393,7 +394,7 @@ func reportBatchForCommand(kind BatchKind) (report.Batch, error) { } } -func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) { +func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) { client, err := weatherapi.New(req.Config) if err != nil { return nil, err @@ -405,7 +406,7 @@ func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, return bundle, nil } -func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) { +func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) { if req.OutputPath == "" { return nil, fmt.Errorf("output path is required") } @@ -787,7 +788,7 @@ func distributorUploadFiles(sourcePath string, bundlePaths []string) []distribut return files } -func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) { +func BuildBriefing(req BriefingRequest, bundle *weatherdata.Bundle) (briefing.Package, error) { location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) if err != nil { return briefing.Package{}, err diff --git a/internal/app/inspect.go b/internal/app/inspect.go index 9fe89ac..d321d7e 100644 --- a/internal/app/inspect.go +++ b/internal/app/inspect.go @@ -6,11 +6,11 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) type InspectReportsRequest struct { @@ -24,11 +24,11 @@ type InspectRunRequest struct { } type SourceInspection struct { - RunID string `json:"runId"` - ReportID report.ID `json:"reportId"` - SourceLocation string `json:"sourceLocation,omitempty"` - Sources []briefing.SourceMetadata `json:"sources,omitempty"` - Warnings []forecast.SourceWarning `json:"warnings,omitempty"` + RunID string `json:"runId"` + ReportID report.ID `json:"reportId"` + SourceLocation string `json:"sourceLocation,omitempty"` + Sources []briefing.SourceMetadata `json:"sources,omitempty"` + Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"` } func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) { diff --git a/internal/briefing/daily.go b/internal/briefing/daily.go index 92c56e1..6ce3556 100644 --- a/internal/briefing/daily.go +++ b/internal/briefing/daily.go @@ -9,18 +9,19 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) type Daily struct { - BottomLine BottomLine `json:"bottomLine"` - Dayparts []forecast.DaypartSummary `json:"dayparts"` - RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` - OutdoorWindows OutdoorWindows `json:"outdoorWindows"` - Planning *TomorrowPlanning `json:"planning,omitempty"` - NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"` - Discussion DiscussionContext `json:"discussion,omitempty"` - WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` - ForecastSummaryDate string `json:"forecastSummaryDate"` + BottomLine BottomLine `json:"bottomLine"` + Dayparts []forecast.DaypartSummary `json:"dayparts"` + RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` + OutdoorWindows OutdoorWindows `json:"outdoorWindows"` + Planning *TomorrowPlanning `json:"planning,omitempty"` + NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"` + Discussion DiscussionContext `json:"discussion,omitempty"` + WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` + ForecastSummaryDate string `json:"forecastSummaryDate"` } type BottomLine struct { @@ -250,7 +251,7 @@ func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.Day return nil } -func buildDiscussion(discussion *forecast.Discussion) DiscussionContext { +func buildDiscussion(discussion *weatherdata.Discussion) DiscussionContext { if discussion == nil { return DiscussionContext{} } @@ -267,7 +268,7 @@ func buildDiscussion(discussion *forecast.Discussion) DiscussionContext { return ctx } -func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext { +func buildWeatherStory(bundle *weatherdata.Bundle) *WeatherStoryContext { if bundle == nil || bundle.WeatherStory == nil { return nil } diff --git a/internal/briefing/daily_test.go b/internal/briefing/daily_test.go index 8f62cbc..4c0ac44 100644 --- a/internal/briefing/daily_test.go +++ b/internal/briefing/daily_test.go @@ -11,6 +11,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { @@ -20,7 +21,7 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { currentFeelsLike := 76.1 currentHumidity := 56.0 currentWind := 10.7 - bundle.Current = &forecast.Current{ + bundle.Current = &weatherdata.Current{ ConditionText: "Partly cloudy", IsDay: ¤tIsDay, TemperatureF: ¤tTemp, @@ -29,7 +30,7 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { WindSpeedMph: ¤tWind, } bundle.Sources[0].DataSHA256 = "abc123" - bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}} + bundle.Warnings = []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}} location := mustLocation(t) resolved := mustResolveDaily(t, location) summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) @@ -112,17 +113,17 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { func TestDailyBriefingQuietWeather(t *testing.T) { location := mustLocation(t) resolved := mustResolveDaily(t, location) - bundle := &forecast.Bundle{ - Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{ + bundle := &weatherdata.Bundle{ + Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72), }}, - Alerts: &forecast.AlertRun{}, - Sources: []forecast.Source{ + Alerts: &weatherdata.AlertRun{}, + Sources: []weatherdata.Source{ {Name: "hourly", FetchedAt: time.Now()}, {Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()}, {Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true}, }, - Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}}, + Warnings: []weatherdata.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}}, } summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) if err != nil { @@ -163,7 +164,7 @@ func TestDailyBriefingAlertExclusion(t *testing.T) { location := mustLocation(t) resolved := mustResolveDaily(t, location) bundle := loadBundleFixture(t) - bundle.Alerts = &forecast.AlertRun{Alerts: []json.RawMessage{ + bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{ json.RawMessage(`{"event":"Future Watch","effective":"2026-06-01T00:00:00-05:00","expires":"2026-06-01T06:00:00-05:00"}`), }} summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) @@ -265,13 +266,13 @@ func TestSaveBriefingPackage(t *testing.T) { } } -func loadBundleFixture(t *testing.T) *forecast.Bundle { +func loadBundleFixture(t *testing.T) *weatherdata.Bundle { t.Helper() data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json")) if err != nil { t.Fatalf("read bundle fixture: %v", err) } - var bundle forecast.Bundle + var bundle weatherdata.Bundle if err := json.Unmarshal(data, &bundle); err != nil { t.Fatalf("decode bundle fixture: %v", err) } @@ -300,8 +301,8 @@ func defaultDayparts() []forecast.DaypartDefinition { } } -func quietHour(start string, end string, temperature float64) forecast.ForecastPeriod { - return forecast.ForecastPeriod{ +func quietHour(start string, end string, temperature float64) weatherdata.ForecastPeriod { + return weatherdata.ForecastPeriod{ StartTime: mustParse(start), EndTime: mustParse(end), TextDescription: "Clear", diff --git a/internal/briefing/package.go b/internal/briefing/package.go index d34b21f..d8e5dc5 100644 --- a/internal/briefing/package.go +++ b/internal/briefing/package.go @@ -6,9 +6,9 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) const SchemaVersion = "weatherreporter.briefing.v1" @@ -23,21 +23,21 @@ type Package struct { } type Metadata struct { - SchemaVersion string `json:"schemaVersion"` - RunID string `json:"runId"` - ReportID report.ID `json:"reportId"` - Variant string `json:"variant,omitempty"` - PromptID string `json:"promptId"` - GeneratedAt time.Time `json:"generatedAt"` - Units string `json:"units"` - Timezone string `json:"timezone"` - ValidPeriod timeutil.Period `json:"validPeriod"` - Location *LocationContext `json:"location,omitempty"` - SourceLocationID string `json:"sourceLocationId,omitempty"` - SourceLocation string `json:"sourceLocation,omitempty"` - Sources []SourceMetadata `json:"sources,omitempty"` - SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` - Alerts *AlertStatus `json:"alerts,omitempty"` + SchemaVersion string `json:"schemaVersion"` + RunID string `json:"runId"` + ReportID report.ID `json:"reportId"` + Variant string `json:"variant,omitempty"` + PromptID string `json:"promptId"` + GeneratedAt time.Time `json:"generatedAt"` + Units string `json:"units"` + Timezone string `json:"timezone"` + ValidPeriod timeutil.Period `json:"validPeriod"` + Location *LocationContext `json:"location,omitempty"` + SourceLocationID string `json:"sourceLocationId,omitempty"` + SourceLocation string `json:"sourceLocation,omitempty"` + Sources []SourceMetadata `json:"sources,omitempty"` + SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` + Alerts *AlertStatus `json:"alerts,omitempty"` } type LocationContext struct { @@ -63,14 +63,14 @@ type CurrentConditionsContext struct { } type SourceMetadata struct { - Name string `json:"name"` - Endpoint string `json:"endpoint,omitempty"` - FetchedAt time.Time `json:"fetchedAt"` - IssuedAt *time.Time `json:"issuedAt,omitempty"` - UpdatedAt *time.Time `json:"updatedAt,omitempty"` - DataSHA256 string `json:"dataSha256,omitempty"` - Missing bool `json:"missing,omitempty"` - Warnings []forecast.SourceWarning `json:"warnings,omitempty"` + Name string `json:"name"` + Endpoint string `json:"endpoint,omitempty"` + FetchedAt time.Time `json:"fetchedAt"` + IssuedAt *time.Time `json:"issuedAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + DataSHA256 string `json:"dataSha256,omitempty"` + Missing bool `json:"missing,omitempty"` + Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"` } type AlertStatus struct { @@ -82,7 +82,7 @@ type AlertStatus struct { type BuildContext struct { Resolved report.Resolved - Bundle *forecast.Bundle + Bundle *weatherdata.Bundle Units string Timezone string Location *LocationContext @@ -125,7 +125,7 @@ func copyLocation(location *LocationContext) *LocationContext { return &copied } -func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext { +func currentConditions(bundle *weatherdata.Bundle) *CurrentConditionsContext { if bundle == nil || bundle.Current == nil { return nil } @@ -184,11 +184,11 @@ func Save(path string, pkg Package) error { return nil } -func sourceLocation(bundle *forecast.Bundle) (string, string) { +func sourceLocation(bundle *weatherdata.Bundle) (string, string) { if bundle == nil { return "", "" } - for _, run := range []*forecast.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} { + for _, run := range []*weatherdata.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} { if run == nil { continue } @@ -199,7 +199,7 @@ func sourceLocation(bundle *forecast.Bundle) (string, string) { return "", "" } -func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata { +func sourceMetadata(bundle *weatherdata.Bundle) []SourceMetadata { if bundle == nil { return nil } @@ -219,14 +219,14 @@ func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata { return out } -func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning { +func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning { if bundle == nil { return nil } return bundle.Warnings } -func alertStatus(bundle *forecast.Bundle) *AlertStatus { +func alertStatus(bundle *weatherdata.Bundle) *AlertStatus { if bundle == nil { return nil } diff --git a/internal/briefing/storm.go b/internal/briefing/storm.go index f042c6d..971ff7f 100644 --- a/internal/briefing/storm.go +++ b/internal/briefing/storm.go @@ -6,23 +6,24 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) type Storm struct { - TimingWindow timeutil.Period `json:"timingWindow"` - EventHeadlines []string `json:"eventHeadlines,omitempty"` - Hazards []string `json:"hazards,omitempty"` - MostLikelyScenario []string `json:"mostLikelyScenario,omitempty"` - ReasonableWorstCase []string `json:"reasonableWorstCase,omitempty"` - ConfidenceInputs []string `json:"confidenceInputs,omitempty"` - WhatToWatchNext []string `json:"whatToWatchNext,omitempty"` - RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` - HourlyPeriods []forecast.ForecastPeriod `json:"hourlyPeriods,omitempty"` - DailyPeriods []forecast.ForecastPeriod `json:"dailyPeriods,omitempty"` - NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"` - WindowSummary forecast.DaypartSummary `json:"windowSummary"` - Discussion DiscussionContext `json:"discussion,omitempty"` - WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` + TimingWindow timeutil.Period `json:"timingWindow"` + EventHeadlines []string `json:"eventHeadlines,omitempty"` + Hazards []string `json:"hazards,omitempty"` + MostLikelyScenario []string `json:"mostLikelyScenario,omitempty"` + ReasonableWorstCase []string `json:"reasonableWorstCase,omitempty"` + ConfidenceInputs []string `json:"confidenceInputs,omitempty"` + WhatToWatchNext []string `json:"whatToWatchNext,omitempty"` + RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` + HourlyPeriods []weatherdata.ForecastPeriod `json:"hourlyPeriods,omitempty"` + DailyPeriods []weatherdata.ForecastPeriod `json:"dailyPeriods,omitempty"` + NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"` + WindowSummary forecast.DaypartSummary `json:"windowSummary"` + Discussion DiscussionContext `json:"discussion,omitempty"` + WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` } func BuildStorm(ctx BuildContext) (Package, error) { @@ -102,7 +103,7 @@ func stormHazards(alerts []forecast.AlertOverlap, summary forecast.DaypartSummar return out } -func mostLikelyStormScenario(hourly []forecast.ForecastPeriod, narrative []forecast.ForecastPeriod, summary forecast.DaypartSummary) []string { +func mostLikelyStormScenario(hourly []weatherdata.ForecastPeriod, narrative []weatherdata.ForecastPeriod, summary forecast.DaypartSummary) []string { var items []string if summary.DominantCondition != "" { items = append(items, "Dominant hourly condition: "+summary.DominantCondition+".") @@ -151,7 +152,7 @@ func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.Daypar return items } -func stormConfidenceInputs(bundle *forecast.Bundle) []string { +func stormConfidenceInputs(bundle *weatherdata.Bundle) []string { var items []string if bundle == nil { return []string{"No source bundle was available for confidence context."} @@ -180,7 +181,7 @@ func stormConfidenceInputs(bundle *forecast.Bundle) []string { return items } -func stormWatchItems(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary, bundle *forecast.Bundle) []string { +func stormWatchItems(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary, bundle *weatherdata.Bundle) []string { var items []string if len(alerts) > 0 { items = append(items, "Watch for alert extensions, cancellations, or upgrades.") diff --git a/internal/briefing/storm_test.go b/internal/briefing/storm_test.go index 155361e..0d86fd5 100644 --- a/internal/briefing/storm_test.go +++ b/internal/briefing/storm_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestStormBriefingWithActiveAlert(t *testing.T) { @@ -23,34 +23,34 @@ func TestStormBriefingWithActiveAlert(t *testing.T) { } precip := 80.0 gust := 42.0 - bundle := &forecast.Bundle{ - Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{ + bundle := &weatherdata.Bundle{ + Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{ StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Severe thunderstorms and gusty wind", ProbabilityOfPrecipitationPercent: &precip, WindGustMph: &gust, }}}, - Daily: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{ + Daily: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{ StartTime: mustParse("2026-05-29T06:00:00-05:00"), EndTime: mustParse("2026-05-29T18:00:00-05:00"), TextDescription: "Storms likely.", }}}, - Narrative: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{ + Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{ StartTime: mustParse("2026-05-29T06:00:00-05:00"), EndTime: mustParse("2026-05-29T18:00:00-05:00"), TextDescription: "Damaging wind possible in stronger storms.", }}}, - Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{ + Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{ json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`), }}, - Discussion: &forecast.Discussion{ + Discussion: &weatherdata.Discussion{ Product: "discussion", KeyMessages: []string{"Storms may intensify quickly."}, - ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."}, - LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."}, + ShortTerm: &weatherdata.DiscussionSection{Text: "Short-term storm coverage peaks this morning."}, + LongTerm: &weatherdata.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."}, }, - WeatherStory: &forecast.WeatherStory{ + WeatherStory: &weatherdata.WeatherStory{ OfficeID: "LSX", StartTime: mustParse("2026-05-29T06:00:00Z"), EndTime: mustParse("2026-05-29T18:00:00Z"), @@ -59,7 +59,7 @@ func TestStormBriefingWithActiveAlert(t *testing.T) { AltText: "Weather story graphic showing storm risk.", Order: 1, }, - Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, + Sources: []weatherdata.Source{{Name: "hourly", FetchedAt: time.Now()}}, } pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}) @@ -113,11 +113,11 @@ func TestStormBriefingWithDiscussionButNoAlert(t *testing.T) { if err != nil { t.Fatalf("resolve storm: %v", err) } - bundle := &forecast.Bundle{ - Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Showers"}}}, - Alerts: &forecast.AlertRun{}, - Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}}, - Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, + bundle := &weatherdata.Bundle{ + Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Showers"}}}, + Alerts: &weatherdata.AlertRun{}, + Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}}, + Sources: []weatherdata.Source{{Name: "hourly", FetchedAt: time.Now()}}, } pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}) @@ -150,9 +150,9 @@ func TestStormBriefingQuietWindow(t *testing.T) { if err != nil { t.Fatalf("resolve storm: %v", err) } - bundle := &forecast.Bundle{ - Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Clear"}}}, - Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, + bundle := &weatherdata.Bundle{ + Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Clear"}}}, + Sources: []weatherdata.Source{{Name: "hourly", FetchedAt: time.Now()}}, } pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}) diff --git a/internal/briefing/three_day_test.go b/internal/briefing/three_day_test.go index 64cc8bf..5b2aae1 100644 --- a/internal/briefing/three_day_test.go +++ b/internal/briefing/three_day_test.go @@ -7,6 +7,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) { @@ -43,11 +44,11 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) { }, }, AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}}, - Discussion: &forecast.Discussion{ + Discussion: &weatherdata.Discussion{ Product: "discussion", KeyMessages: []string{"Unsettled stretch."}, - ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."}, - LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."}, + ShortTerm: &weatherdata.DiscussionSection{Text: "Short-term rain chances remain focused today."}, + LongTerm: &weatherdata.DiscussionSection{Text: "Long-term warmth builds into the weekend."}, }, }, { diff --git a/internal/briefing/weekend.go b/internal/briefing/weekend.go index 90b0f26..15d734b 100644 --- a/internal/briefing/weekend.go +++ b/internal/briefing/weekend.go @@ -6,6 +6,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) type Weekend struct { @@ -45,7 +46,7 @@ func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package, return pkg, nil } -func buildWeekendPlanning(days []OutlookDay, discussion DiscussionContext, bundle *forecast.Bundle) WeekendPlanning { +func buildWeekendPlanning(days []OutlookDay, discussion DiscussionContext, bundle *weatherdata.Bundle) WeekendPlanning { planning := WeekendPlanning{} for _, day := range days { if day.OutdoorWindows.Best != nil { diff --git a/internal/briefing/weekend_test.go b/internal/briefing/weekend_test.go index 3478a21..554cf6d 100644 --- a/internal/briefing/weekend_test.go +++ b/internal/briefing/weekend_test.go @@ -7,6 +7,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) { @@ -44,7 +45,7 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) { Time: mustParse("2026-05-30T16:00:00-05:00"), }, Indicators: forecast.Indicators{Wind: true}, - HourlyPeriods: []forecast.ForecastPeriod{ + HourlyPeriods: []weatherdata.ForecastPeriod{ { StartTime: mustParse("2026-05-30T15:00:00-05:00"), EndTime: mustParse("2026-05-30T16:00:00-05:00"), @@ -54,11 +55,11 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) { }, }, AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}}, - Discussion: &forecast.Discussion{ + Discussion: &weatherdata.Discussion{ Product: "discussion", KeyMessages: []string{"Timing may shift."}, - ShortTerm: &forecast.DiscussionSection{Text: "Short-term showers exit before the weekend."}, - LongTerm: &forecast.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."}, + ShortTerm: &weatherdata.DiscussionSection{Text: "Short-term showers exit before the weekend."}, + LongTerm: &weatherdata.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."}, }, }, } diff --git a/internal/forecast/dayparts.go b/internal/forecast/dayparts.go index 46fd73f..f9f981c 100644 --- a/internal/forecast/dayparts.go +++ b/internal/forecast/dayparts.go @@ -5,6 +5,7 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) type DaypartDefinition struct { @@ -48,6 +49,6 @@ func ResolveDayparts(date time.Time, location *time.Location, definitions []Dayp return windows, nil } -func PeriodForForecastPeriod(period ForecastPeriod) timeutil.Period { +func PeriodForForecastPeriod(period weatherdata.ForecastPeriod) timeutil.Period { return timeutil.Period{Start: period.StartTime, End: period.EndTime} } diff --git a/internal/forecast/derive.go b/internal/forecast/derive.go index a37ebda..d9e3d58 100644 --- a/internal/forecast/derive.go +++ b/internal/forecast/derive.go @@ -8,32 +8,33 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) type DailySummary struct { - Date string `json:"date"` - Period timeutil.Period `json:"period"` - Dayparts []DaypartSummary `json:"dayparts"` - NarrativePeriods []ForecastPeriod `json:"narrativePeriods,omitempty"` - AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"` - Discussion *Discussion `json:"discussion,omitempty"` - SourceWarnings []SourceWarning `json:"sourceWarnings,omitempty"` - SourceProvenance []Source `json:"sourceProvenance,omitempty"` + Date string `json:"date"` + Period timeutil.Period `json:"period"` + Dayparts []DaypartSummary `json:"dayparts"` + NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"` + AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"` + Discussion *weatherdata.Discussion `json:"discussion,omitempty"` + SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` + SourceProvenance []weatherdata.Source `json:"sourceProvenance,omitempty"` } type DaypartSummary struct { - Name string `json:"name"` - Period timeutil.Period `json:"period"` - HourlyPeriods []ForecastPeriod `json:"hourlyPeriods"` - Temperature Range `json:"temperature,omitempty"` - ApparentTemperature Range `json:"apparentTemperature,omitempty"` - MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"` - PeakWindSpeed *TimedValue `json:"peakWindSpeed,omitempty"` - PeakWindGust *TimedValue `json:"peakWindGust,omitempty"` - DominantCondition string `json:"dominantCondition,omitempty"` - NotableConditions []string `json:"notableConditions,omitempty"` - Indicators Indicators `json:"indicators"` - AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"` + Name string `json:"name"` + Period timeutil.Period `json:"period"` + HourlyPeriods []weatherdata.ForecastPeriod `json:"hourlyPeriods"` + Temperature Range `json:"temperature,omitempty"` + ApparentTemperature Range `json:"apparentTemperature,omitempty"` + MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"` + PeakWindSpeed *TimedValue `json:"peakWindSpeed,omitempty"` + PeakWindGust *TimedValue `json:"peakWindGust,omitempty"` + DominantCondition string `json:"dominantCondition,omitempty"` + NotableConditions []string `json:"notableConditions,omitempty"` + Indicators Indicators `json:"indicators"` + AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"` } type Range struct { @@ -64,7 +65,7 @@ type AlertOverlap struct { Description string `json:"description,omitempty"` } -func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) { +func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) { if bundle == nil { return nil, fmt.Errorf("forecast bundle is required") } @@ -99,7 +100,7 @@ func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location, return summary, nil } -func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) { +func BuildPeriodDailySummaries(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) { if !period.IsValid() { return nil, fmt.Errorf("valid forecast period is required") } @@ -121,7 +122,7 @@ func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location return summaries, nil } -func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) { +func buildDailySummaryForPeriod(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) { if bundle == nil { return nil, fmt.Errorf("forecast bundle is required") } @@ -155,11 +156,11 @@ func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location return summary, nil } -func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod { +func SelectHourlyPeriods(run *weatherdata.ForecastRun, period timeutil.Period) []weatherdata.ForecastPeriod { if run == nil { return nil } - var selected []ForecastPeriod + var selected []weatherdata.ForecastPeriod for _, forecastPeriod := range run.Periods { if PeriodForForecastPeriod(forecastPeriod).Overlaps(period) { selected = append(selected, forecastPeriod) @@ -171,21 +172,21 @@ func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPer return selected } -func SelectNarrativePeriods(bundle *Bundle, period timeutil.Period) []ForecastPeriod { +func SelectNarrativePeriods(bundle *weatherdata.Bundle, period timeutil.Period) []weatherdata.ForecastPeriod { if bundle == nil || bundle.Narrative == nil { return nil } return SelectHourlyPeriods(bundle.Narrative, period) } -func SelectDiscussion(bundle *Bundle) *Discussion { +func SelectDiscussion(bundle *weatherdata.Bundle) *weatherdata.Discussion { if bundle == nil { return nil } return bundle.Discussion } -func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPeriod) DaypartSummary { +func SummarizeDaypart(name string, period timeutil.Period, periods []weatherdata.ForecastPeriod) DaypartSummary { summary := DaypartSummary{ Name: name, Period: period, @@ -215,7 +216,7 @@ func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPer return summary } -func periodTemperatureValues(period ForecastPeriod) []*float64 { +func periodTemperatureValues(period weatherdata.ForecastPeriod) []*float64 { values := []*float64{} values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...) values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...) @@ -297,7 +298,7 @@ func indicatorsForText(text string) Indicators { } } -func numericIndicators(period ForecastPeriod) Indicators { +func numericIndicators(period weatherdata.ForecastPeriod) Indicators { windGust := firstValue(period.WindGustMph, period.WindGustKmh) windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh) indicators := Indicators{} @@ -325,7 +326,7 @@ func mergeIndicators(left Indicators, right Indicators) Indicators { } } -func AlertOverlaps(alertRun *AlertRun, period timeutil.Period) []AlertOverlap { +func AlertOverlaps(alertRun *weatherdata.AlertRun, period timeutil.Period) []AlertOverlap { if alertRun == nil { return nil } diff --git a/internal/forecast/derive_test.go b/internal/forecast/derive_test.go index ab13904..c5442d2 100644 --- a/internal/forecast/derive_test.go +++ b/internal/forecast/derive_test.go @@ -8,6 +8,7 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) { @@ -72,7 +73,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) { if err != nil { t.Fatalf("read fixture bundle: %v", err) } - var bundle Bundle + var bundle weatherdata.Bundle if err := json.Unmarshal(data, &bundle); err != nil { t.Fatalf("decode fixture bundle: %v", err) } @@ -98,7 +99,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) { func TestOvernightGroupingAcrossMidnight(t *testing.T) { location := time.FixedZone("Test", -5*60*60) date := time.Date(2026, 5, 29, 12, 0, 0, 0, location) - bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{ + bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil), hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil), hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil), @@ -122,7 +123,7 @@ func TestOvernightGroupingAcrossMidnight(t *testing.T) { func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) { location := time.FixedZone("Test", -5*60*60) date := time.Date(2026, 5, 29, 12, 0, 0, 0, location) - bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{ + bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 55, nil, nil, nil, nil), hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Start", 56, nil, nil, nil, nil), hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "After", 70, nil, nil, nil, nil), @@ -145,7 +146,7 @@ func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) { func TestBuildDailySummaryRequiresHourlyData(t *testing.T) { location := time.UTC - _, err := BuildDailySummary(&Bundle{}, time.Now(), location, []DaypartDefinition{ + _, err := BuildDailySummary(&weatherdata.Bundle{}, time.Now(), location, []DaypartDefinition{ {Name: "morning", Start: "06:00", End: "12:00"}, }) if err == nil { @@ -155,7 +156,7 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) { func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) { location := time.FixedZone("Test", -5*60*60) - bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{ + bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil), hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil), hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil), @@ -191,7 +192,7 @@ func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) { func TestAlertOverlap(t *testing.T) { location := time.FixedZone("Test", -5*60*60) raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`) - alertRun := &AlertRun{Alerts: []json.RawMessage{raw}} + alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}} period := timeutil.Period{ Start: mustParse("2026-05-29T06:00:00-05:00").In(location), End: mustParse("2026-05-29T09:00:00-05:00").In(location), @@ -221,31 +222,31 @@ func TestThresholdHelpers(t *testing.T) { } } -func testBundle(location *time.Location) *Bundle { - return &Bundle{ - Hourly: &ForecastRun{Periods: []ForecastPeriod{ +func testBundle(location *time.Location) *weatherdata.Bundle { + return &weatherdata.Bundle{ + Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Cloudy", 55, nil, nil, nil, nil), hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Thunderstorms and gusty wind", 58, ptr(57), ptr(70), ptr(22), ptr(40)), hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Thunderstorms and gusty wind", 72, ptr(74), ptr(60), ptr(18), ptr(35)), hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot and sunny", 96, ptr(100), ptr(5), ptr(10), ptr(12)), }}, - Narrative: &ForecastRun{Periods: []ForecastPeriod{ + Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storms early, hot later.", 96, nil, nil, nil, nil), }}, - Alerts: &AlertRun{Alerts: []json.RawMessage{ + Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{ json.RawMessage(`{"event":"Severe Thunderstorm Watch","headline":"Storms possible","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T11:30:00-05:00"}`), }}, - Discussion: &Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}}, - Sources: []Source{{Name: "hourly"}}, - Warnings: []SourceWarning{{Source: "daily", Code: "missing_source"}}, + Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}}, + Sources: []weatherdata.Source{{Name: "hourly"}}, + Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}}, } } -func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) ForecastPeriod { +func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) weatherdata.ForecastPeriod { startTime := mustParse(start).In(location) endTime := mustParse(end).In(location) temp := temperature - return ForecastPeriod{ + return weatherdata.ForecastPeriod{ StartTime: startTime, EndTime: endTime, TextDescription: text, diff --git a/internal/promptinput/package.go b/internal/promptinput/package.go index 6f8ccba..8927d83 100644 --- a/internal/promptinput/package.go +++ b/internal/promptinput/package.go @@ -8,20 +8,20 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) const SchemaVersion = "weatherreporter.data_package.v1" type Package struct { - SchemaVersion string `json:"schemaVersion"` - RunID string `json:"runId"` - Report Report `json:"report"` - Briefing briefing.Package `json:"briefing"` - RecentChanges RecentChanges `json:"recentChanges"` - SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` + SchemaVersion string `json:"schemaVersion"` + RunID string `json:"runId"` + Report Report `json:"report"` + Briefing briefing.Package `json:"briefing"` + RecentChanges RecentChanges `json:"recentChanges"` + SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` } type Report struct { diff --git a/internal/state/metadata.go b/internal/state/metadata.go index 01eedf9..cf49fc0 100644 --- a/internal/state/metadata.go +++ b/internal/state/metadata.go @@ -4,33 +4,33 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) const MetadataSchemaVersion = "weatherreporter.metadata.v1" type Metadata struct { - SchemaVersion string `json:"schemaVersion"` - RunID string `json:"runId"` - MetadataPath string `json:"-"` - ReportID report.ID `json:"reportId"` - Variant string `json:"variant,omitempty"` - PromptID string `json:"promptId"` - GeneratedAt time.Time `json:"generatedAt"` - Timezone string `json:"timezone"` - ValidPeriod timeutil.Period `json:"validPeriod"` - Location *briefing.LocationContext `json:"location,omitempty"` - SourceLocationID string `json:"sourceLocationId,omitempty"` - SourceLocation string `json:"sourceLocation,omitempty"` - Sources []briefing.SourceMetadata `json:"sources,omitempty"` - SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` - BriefingPath string `json:"briefingPath"` - DataPackagePath string `json:"dataPackagePath"` - PreflightPath string `json:"preflightPath"` - NotificationPath string `json:"notificationPath,omitempty"` - RenderedReportPath string `json:"renderedReportPath,omitempty"` + SchemaVersion string `json:"schemaVersion"` + RunID string `json:"runId"` + MetadataPath string `json:"-"` + ReportID report.ID `json:"reportId"` + Variant string `json:"variant,omitempty"` + PromptID string `json:"promptId"` + GeneratedAt time.Time `json:"generatedAt"` + Timezone string `json:"timezone"` + ValidPeriod timeutil.Period `json:"validPeriod"` + Location *briefing.LocationContext `json:"location,omitempty"` + SourceLocationID string `json:"sourceLocationId,omitempty"` + SourceLocation string `json:"sourceLocation,omitempty"` + Sources []briefing.SourceMetadata `json:"sources,omitempty"` + SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` + BriefingPath string `json:"briefingPath"` + DataPackagePath string `json:"dataPackagePath"` + PreflightPath string `json:"preflightPath"` + NotificationPath string `json:"notificationPath,omitempty"` + RenderedReportPath string `json:"renderedReportPath,omitempty"` } func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, paths ArtifactPaths) Metadata { diff --git a/internal/forecast/bundle.go b/internal/weatherdata/bundle.go similarity index 98% rename from internal/forecast/bundle.go rename to internal/weatherdata/bundle.go index ff5d184..1352ff0 100644 --- a/internal/forecast/bundle.go +++ b/internal/weatherdata/bundle.go @@ -1,5 +1,5 @@ -// Package forecast defines normalized weather data consumed by report builders. -package forecast +// Package weatherdata defines normalized weather data collected from sources. +package weatherdata import ( "encoding/json"