Split weather data types from forecast derivation

This commit is contained in:
2026-06-09 20:16:47 +00:00
parent d8b417458b
commit 454f47b2b5
22 changed files with 234 additions and 223 deletions

View File

@@ -14,7 +14,7 @@ prose.
Inputs: Inputs:
- resolved report definition, generation time, timezone, and valid period - 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 - derived daily or period summaries where required
- configured units, timezone, and descriptive location context - configured units, timezone, and descriptive location context

View File

@@ -5,14 +5,14 @@ This document describes deterministic forecast summarization in
## Purpose ## Purpose
`internal/forecast` converts normalized bundle data into daily and period `internal/forecast` converts normalized `weatherdata` bundle data into daily
summaries used by briefing builders. and period summaries used by briefing builders.
## Inputs And Outputs ## Inputs And Outputs
Inputs: Inputs:
- `forecast.Bundle` - `weatherdata.Bundle`
- local date or resolved report period - local date or resolved report period
- timezone - timezone
- configured daypart definitions - configured daypart definitions
@@ -43,7 +43,7 @@ configuration.
## External Adapters Used ## External Adapters Used
None directly. Forecast data arrives through `forecast.Bundle`. None directly. Forecast data arrives through `weatherdata.Bundle`.
## State Or Manifest Behavior ## State Or Manifest Behavior

View File

@@ -1,6 +1,6 @@
# Weather Data Internals # Weather Data Internals
This document describes Weather API ingestion into `forecast.Bundle`. This document describes Weather API ingestion into `weatherdata.Bundle`.
## Purpose ## Purpose
@@ -19,7 +19,7 @@ Inputs:
Outputs: 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 narrative forecast, active alerts, discussion, latest weather story, source
records, and source warnings records, and source warnings
- stub source record for the daily forecast source slot - stub source record for the daily forecast source slot

View File

@@ -16,8 +16,9 @@ Developers and LLM coding agents should use it with
- `internal/adapters/distributor`: Distributor upload adapter. - `internal/adapters/distributor`: Distributor upload adapter.
- `internal/adapters/weatherapi`: Weather API HTTP adapter. - `internal/adapters/weatherapi`: Weather API HTTP adapter.
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter. - `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
- `internal/forecast`: normalized bundle types and deterministic forecast - `internal/weatherdata`: normalized weather source facts, source metadata, and
derivation. source warnings.
- `internal/forecast`: deterministic forecast derivation.
- `internal/report`: report definitions, valid periods, batches, output names, - `internal/report`: report definitions, valid periods, batches, output names,
and comparison declarations. and comparison declarations.
- `internal/briefing`: report-specific briefing package builders. - `internal/briefing`: report-specific briefing package builders.

View File

@@ -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 package weatherapi
import ( import (
@@ -18,7 +18,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type Client struct { type Client struct {
@@ -83,11 +83,11 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
return client, nil 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() fetchedAt := c.now()
builder := bundleBuilder{ builder := bundleBuilder{
client: c, client: c,
bundle: &forecast.Bundle{FetchedAt: fetchedAt}, bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
fetchedAt: fetchedAt, fetchedAt: fetchedAt,
} }
@@ -121,7 +121,7 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
type bundleBuilder struct { type bundleBuilder struct {
client *Client client *Client
bundle *forecast.Bundle bundle *weatherdata.Bundle
fetchedAt time.Time fetchedAt time.Time
} }
@@ -133,7 +133,7 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
if raw == nil { if raw == nil {
return b.handleMissing(&source, "observation data is missing", false) return b.handleMissing(&source, "observation data is missing", false)
} }
var observation forecast.Observation var observation weatherdata.Observation
if err := decodeSource(raw, &observation); err != nil { if err := decodeSource(raw, &observation); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, false)
} }
@@ -151,7 +151,7 @@ func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
if raw == nil { if raw == nil {
return b.handleMissing(&source, "current conditions data is missing", false) return b.handleMissing(&source, "current conditions data is missing", false)
} }
var current forecast.Current var current weatherdata.Current
if err := decodeSource(raw, &current); err != nil { if err := decodeSource(raw, &current); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, false)
} }
@@ -168,7 +168,7 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
if raw == nil { if raw == nil {
return b.handleMissing(&source, "hourly forecast data is missing", true) 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 { if err := decodeSource(raw, &hourly); err != nil {
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err) 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 { if raw == nil {
return b.handleMissing(&source, "narrative forecast data is missing", false) 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 { if err := decodeSource(raw, &narrative); err != nil {
return b.handleMalformed(&source, err, false) 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) return b.handleMissing(&source, "active alerts data is missing", false)
} }
if isJSONNull(raw) { 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) b.addSource(source)
return nil return nil
} }
var alerts forecast.AlertRun var alerts weatherdata.AlertRun
if err := decodeSource(raw, &alerts); err != nil { if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, false)
} }
@@ -235,7 +235,7 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
if raw == nil { if raw == nil {
return b.handleMissing(&source, "forecast discussion data is missing", false) 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 { if err := decodeSource(raw, &discussion); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, false)
} }
@@ -254,7 +254,7 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
if raw == nil { if raw == nil {
return b.handleMissing(&source, "NWS weather story data is missing", false) 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 { if err := decodeSource(raw, &story); err != nil {
return b.handleMalformed(&source, err, false) 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 { func (b *bundleBuilder) addStub(sourceName string, message string) error {
source := forecast.Source{ source := weatherdata.Source{
Name: sourceName, Name: sourceName,
FetchedAt: b.fetchedAt, FetchedAt: b.fetchedAt,
Missing: true, Missing: true,
@@ -276,7 +276,7 @@ func (b *bundleBuilder) addStub(sourceName string, message string) error {
return b.applyMissingPolicy(&source, "missing_source", message) 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 source.Missing = true
if required { if required {
return fmt.Errorf("%s from %s is required", message, source.Endpoint) 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) 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 { if required {
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err) 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)) 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) policy := b.client.policyFor(source.Name)
if policy == config.MissingSourceError { if policy == config.MissingSourceError {
return fmt.Errorf("%s: %s", source.Name, message) return fmt.Errorf("%s: %s", source.Name, message)
} }
if policy == config.MissingSourceWarn { if policy == config.MissingSourceWarn {
warning := forecast.SourceWarning{ warning := weatherdata.SourceWarning{
Source: source.Name, Source: source.Name,
Code: code, Code: code,
Severity: "warning", Severity: "warning",
@@ -313,7 +313,7 @@ func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string,
return nil return nil
} }
func (b *bundleBuilder) addSource(source forecast.Source) { func (b *bundleBuilder) addSource(source weatherdata.Source) {
b.bundle.Sources = append(b.bundle.Sources, source) b.bundle.Sources = append(b.bundle.Sources, source)
} }
@@ -335,33 +335,33 @@ type envelope struct {
Data json.RawMessage `json:"data"` 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) reqURL := c.endpointURL(endpoint, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != 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) resp, err := c.httpClient.Do(req)
if err != nil { 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() defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil { 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 { 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 var env envelope
if err := json.Unmarshal(body, &env); err != nil { 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, Name: sourceName,
Endpoint: endpoint, Endpoint: endpoint,
Query: queryMap(reqURL.Query()), Query: queryMap(reqURL.Query()),
@@ -430,7 +430,7 @@ func sourceHash(raw json.RawMessage) (string, error) {
return hex.EncodeToString(sum[:]), nil 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 { if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
return fmt.Errorf("save bundle: %w", err) return fmt.Errorf("save bundle: %w", err)
} }

View File

@@ -12,7 +12,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "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) { func TestFetchBundleFromFixtures(t *testing.T) {
@@ -423,7 +423,7 @@ func containsPath(requested []string, path string) bool {
return false 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() t.Helper()
for _, source := range sources { for _, source := range sources {
if source.Name == name { 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) t.Fatalf("source %q not found in %#v", name, sources)
return forecast.Source{} return weatherdata.Source{}
} }
func hashFixtureData(t *testing.T, fixture string) string { func hashFixtureData(t *testing.T, fixture string) string {

View File

@@ -20,6 +20,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type ReportKind string 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) client, err := weatherapi.New(req.Config)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -405,7 +406,7 @@ func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle,
return bundle, nil 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 == "" { if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required") return nil, fmt.Errorf("output path is required")
} }
@@ -787,7 +788,7 @@ func distributorUploadFiles(sourcePath string, bundlePaths []string) []distribut
return files 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) location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil { if err != nil {
return briefing.Package{}, err return briefing.Package{}, err

View File

@@ -6,11 +6,11 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "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/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type InspectReportsRequest struct { type InspectReportsRequest struct {
@@ -28,7 +28,7 @@ type SourceInspection struct {
ReportID report.ID `json:"reportId"` ReportID report.ID `json:"reportId"`
SourceLocation string `json:"sourceLocation,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"` Sources []briefing.SourceMetadata `json:"sources,omitempty"`
Warnings []forecast.SourceWarning `json:"warnings,omitempty"` Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
} }
func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) { func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) {

View File

@@ -9,6 +9,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type Daily struct { type Daily struct {
@@ -17,7 +18,7 @@ type Daily struct {
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
OutdoorWindows OutdoorWindows `json:"outdoorWindows"` OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
Planning *TomorrowPlanning `json:"planning,omitempty"` Planning *TomorrowPlanning `json:"planning,omitempty"`
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"` NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"`
Discussion DiscussionContext `json:"discussion,omitempty"` Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
ForecastSummaryDate string `json:"forecastSummaryDate"` ForecastSummaryDate string `json:"forecastSummaryDate"`
@@ -250,7 +251,7 @@ func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.Day
return nil return nil
} }
func buildDiscussion(discussion *forecast.Discussion) DiscussionContext { func buildDiscussion(discussion *weatherdata.Discussion) DiscussionContext {
if discussion == nil { if discussion == nil {
return DiscussionContext{} return DiscussionContext{}
} }
@@ -267,7 +268,7 @@ func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
return ctx return ctx
} }
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext { func buildWeatherStory(bundle *weatherdata.Bundle) *WeatherStoryContext {
if bundle == nil || bundle.WeatherStory == nil { if bundle == nil || bundle.WeatherStory == nil {
return nil return nil
} }

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
@@ -20,7 +21,7 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
currentFeelsLike := 76.1 currentFeelsLike := 76.1
currentHumidity := 56.0 currentHumidity := 56.0
currentWind := 10.7 currentWind := 10.7
bundle.Current = &forecast.Current{ bundle.Current = &weatherdata.Current{
ConditionText: "Partly cloudy", ConditionText: "Partly cloudy",
IsDay: &currentIsDay, IsDay: &currentIsDay,
TemperatureF: &currentTemp, TemperatureF: &currentTemp,
@@ -29,7 +30,7 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
WindSpeedMph: &currentWind, WindSpeedMph: &currentWind,
} }
bundle.Sources[0].DataSHA256 = "abc123" 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) location := mustLocation(t)
resolved := mustResolveDaily(t, location) resolved := mustResolveDaily(t, location)
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
@@ -112,17 +113,17 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
func TestDailyBriefingQuietWeather(t *testing.T) { func TestDailyBriefingQuietWeather(t *testing.T) {
location := mustLocation(t) location := mustLocation(t)
resolved := mustResolveDaily(t, location) resolved := mustResolveDaily(t, location)
bundle := &forecast.Bundle{ bundle := &weatherdata.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{ Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72), quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
}}, }},
Alerts: &forecast.AlertRun{}, Alerts: &weatherdata.AlertRun{},
Sources: []forecast.Source{ Sources: []weatherdata.Source{
{Name: "hourly", FetchedAt: time.Now()}, {Name: "hourly", FetchedAt: time.Now()},
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()}, {Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true}, {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()) summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil { if err != nil {
@@ -163,7 +164,7 @@ func TestDailyBriefingAlertExclusion(t *testing.T) {
location := mustLocation(t) location := mustLocation(t)
resolved := mustResolveDaily(t, location) resolved := mustResolveDaily(t, location)
bundle := loadBundleFixture(t) 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"}`), 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()) 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() t.Helper()
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json")) data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
if err != nil { if err != nil {
t.Fatalf("read bundle fixture: %v", err) t.Fatalf("read bundle fixture: %v", err)
} }
var bundle forecast.Bundle var bundle weatherdata.Bundle
if err := json.Unmarshal(data, &bundle); err != nil { if err := json.Unmarshal(data, &bundle); err != nil {
t.Fatalf("decode bundle fixture: %v", err) 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 { func quietHour(start string, end string, temperature float64) weatherdata.ForecastPeriod {
return forecast.ForecastPeriod{ return weatherdata.ForecastPeriod{
StartTime: mustParse(start), StartTime: mustParse(start),
EndTime: mustParse(end), EndTime: mustParse(end),
TextDescription: "Clear", TextDescription: "Clear",

View File

@@ -6,9 +6,9 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "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/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const SchemaVersion = "weatherreporter.briefing.v1" const SchemaVersion = "weatherreporter.briefing.v1"
@@ -36,7 +36,7 @@ type Metadata struct {
SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"` Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
Alerts *AlertStatus `json:"alerts,omitempty"` Alerts *AlertStatus `json:"alerts,omitempty"`
} }
@@ -70,7 +70,7 @@ type SourceMetadata struct {
UpdatedAt *time.Time `json:"updatedAt,omitempty"` UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DataSHA256 string `json:"dataSha256,omitempty"` DataSHA256 string `json:"dataSha256,omitempty"`
Missing bool `json:"missing,omitempty"` Missing bool `json:"missing,omitempty"`
Warnings []forecast.SourceWarning `json:"warnings,omitempty"` Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
} }
type AlertStatus struct { type AlertStatus struct {
@@ -82,7 +82,7 @@ type AlertStatus struct {
type BuildContext struct { type BuildContext struct {
Resolved report.Resolved Resolved report.Resolved
Bundle *forecast.Bundle Bundle *weatherdata.Bundle
Units string Units string
Timezone string Timezone string
Location *LocationContext Location *LocationContext
@@ -125,7 +125,7 @@ func copyLocation(location *LocationContext) *LocationContext {
return &copied return &copied
} }
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext { func currentConditions(bundle *weatherdata.Bundle) *CurrentConditionsContext {
if bundle == nil || bundle.Current == nil { if bundle == nil || bundle.Current == nil {
return nil return nil
} }
@@ -184,11 +184,11 @@ func Save(path string, pkg Package) error {
return nil return nil
} }
func sourceLocation(bundle *forecast.Bundle) (string, string) { func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
if bundle == nil { if bundle == nil {
return "", "" 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 { if run == nil {
continue continue
} }
@@ -199,7 +199,7 @@ func sourceLocation(bundle *forecast.Bundle) (string, string) {
return "", "" return "", ""
} }
func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata { func sourceMetadata(bundle *weatherdata.Bundle) []SourceMetadata {
if bundle == nil { if bundle == nil {
return nil return nil
} }
@@ -219,14 +219,14 @@ func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
return out return out
} }
func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning { func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
if bundle == nil { if bundle == nil {
return nil return nil
} }
return bundle.Warnings return bundle.Warnings
} }
func alertStatus(bundle *forecast.Bundle) *AlertStatus { func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
if bundle == nil { if bundle == nil {
return nil return nil
} }

View File

@@ -6,6 +6,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type Storm struct { type Storm struct {
@@ -17,9 +18,9 @@ type Storm struct {
ConfidenceInputs []string `json:"confidenceInputs,omitempty"` ConfidenceInputs []string `json:"confidenceInputs,omitempty"`
WhatToWatchNext []string `json:"whatToWatchNext,omitempty"` WhatToWatchNext []string `json:"whatToWatchNext,omitempty"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
HourlyPeriods []forecast.ForecastPeriod `json:"hourlyPeriods,omitempty"` HourlyPeriods []weatherdata.ForecastPeriod `json:"hourlyPeriods,omitempty"`
DailyPeriods []forecast.ForecastPeriod `json:"dailyPeriods,omitempty"` DailyPeriods []weatherdata.ForecastPeriod `json:"dailyPeriods,omitempty"`
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"` NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"`
WindowSummary forecast.DaypartSummary `json:"windowSummary"` WindowSummary forecast.DaypartSummary `json:"windowSummary"`
Discussion DiscussionContext `json:"discussion,omitempty"` Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
@@ -102,7 +103,7 @@ func stormHazards(alerts []forecast.AlertOverlap, summary forecast.DaypartSummar
return out 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 var items []string
if summary.DominantCondition != "" { if summary.DominantCondition != "" {
items = append(items, "Dominant hourly condition: "+summary.DominantCondition+".") items = append(items, "Dominant hourly condition: "+summary.DominantCondition+".")
@@ -151,7 +152,7 @@ func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.Daypar
return items return items
} }
func stormConfidenceInputs(bundle *forecast.Bundle) []string { func stormConfidenceInputs(bundle *weatherdata.Bundle) []string {
var items []string var items []string
if bundle == nil { if bundle == nil {
return []string{"No source bundle was available for confidence context."} return []string{"No source bundle was available for confidence context."}
@@ -180,7 +181,7 @@ func stormConfidenceInputs(bundle *forecast.Bundle) []string {
return items 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 var items []string
if len(alerts) > 0 { if len(alerts) > 0 {
items = append(items, "Watch for alert extensions, cancellations, or upgrades.") items = append(items, "Watch for alert extensions, cancellations, or upgrades.")

View File

@@ -6,8 +6,8 @@ import (
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
func TestStormBriefingWithActiveAlert(t *testing.T) { func TestStormBriefingWithActiveAlert(t *testing.T) {
@@ -23,34 +23,34 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
} }
precip := 80.0 precip := 80.0
gust := 42.0 gust := 42.0
bundle := &forecast.Bundle{ bundle := &weatherdata.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{ Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{
StartTime: mustParse("2026-05-29T07:00:00-05:00"), StartTime: mustParse("2026-05-29T07:00:00-05:00"),
EndTime: mustParse("2026-05-29T08:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"),
TextDescription: "Severe thunderstorms and gusty wind", TextDescription: "Severe thunderstorms and gusty wind",
ProbabilityOfPrecipitationPercent: &precip, ProbabilityOfPrecipitationPercent: &precip,
WindGustMph: &gust, WindGustMph: &gust,
}}}, }}},
Daily: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{ Daily: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{
StartTime: mustParse("2026-05-29T06:00:00-05:00"), StartTime: mustParse("2026-05-29T06:00:00-05:00"),
EndTime: mustParse("2026-05-29T18:00:00-05:00"), EndTime: mustParse("2026-05-29T18:00:00-05:00"),
TextDescription: "Storms likely.", TextDescription: "Storms likely.",
}}}, }}},
Narrative: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{ Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{
StartTime: mustParse("2026-05-29T06:00:00-05:00"), StartTime: mustParse("2026-05-29T06:00:00-05:00"),
EndTime: mustParse("2026-05-29T18:00:00-05:00"), EndTime: mustParse("2026-05-29T18:00:00-05:00"),
TextDescription: "Damaging wind possible in stronger storms.", 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"}`), 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", Product: "discussion",
KeyMessages: []string{"Storms may intensify quickly."}, KeyMessages: []string{"Storms may intensify quickly."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."}, ShortTerm: &weatherdata.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."}, LongTerm: &weatherdata.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
}, },
WeatherStory: &forecast.WeatherStory{ WeatherStory: &weatherdata.WeatherStory{
OfficeID: "LSX", OfficeID: "LSX",
StartTime: mustParse("2026-05-29T06:00:00Z"), StartTime: mustParse("2026-05-29T06:00:00Z"),
EndTime: mustParse("2026-05-29T18: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.", AltText: "Weather story graphic showing storm risk.",
Order: 1, 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"}) 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 { if err != nil {
t.Fatalf("resolve storm: %v", err) t.Fatalf("resolve storm: %v", err)
} }
bundle := &forecast.Bundle{ bundle := &weatherdata.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"}}}, 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: &forecast.AlertRun{}, Alerts: &weatherdata.AlertRun{},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}}, Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}},
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"}) 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 { if err != nil {
t.Fatalf("resolve storm: %v", err) t.Fatalf("resolve storm: %v", err)
} }
bundle := &forecast.Bundle{ bundle := &weatherdata.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"}}}, 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: []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"}) pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})

View File

@@ -7,6 +7,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) { func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
@@ -43,11 +44,11 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
}, },
}, },
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}}, AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
Discussion: &forecast.Discussion{ Discussion: &weatherdata.Discussion{
Product: "discussion", Product: "discussion",
KeyMessages: []string{"Unsettled stretch."}, KeyMessages: []string{"Unsettled stretch."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."}, ShortTerm: &weatherdata.DiscussionSection{Text: "Short-term rain chances remain focused today."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."}, LongTerm: &weatherdata.DiscussionSection{Text: "Long-term warmth builds into the weekend."},
}, },
}, },
{ {

View File

@@ -6,6 +6,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type Weekend struct { type Weekend struct {
@@ -45,7 +46,7 @@ func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package,
return pkg, nil 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{} planning := WeekendPlanning{}
for _, day := range days { for _, day := range days {
if day.OutdoorWindows.Best != nil { if day.OutdoorWindows.Best != nil {

View File

@@ -7,6 +7,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) { func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
@@ -44,7 +45,7 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
Time: mustParse("2026-05-30T16:00:00-05:00"), Time: mustParse("2026-05-30T16:00:00-05:00"),
}, },
Indicators: forecast.Indicators{Wind: true}, Indicators: forecast.Indicators{Wind: true},
HourlyPeriods: []forecast.ForecastPeriod{ HourlyPeriods: []weatherdata.ForecastPeriod{
{ {
StartTime: mustParse("2026-05-30T15:00:00-05:00"), StartTime: mustParse("2026-05-30T15:00:00-05:00"),
EndTime: mustParse("2026-05-30T16: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"}}, AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
Discussion: &forecast.Discussion{ Discussion: &weatherdata.Discussion{
Product: "discussion", Product: "discussion",
KeyMessages: []string{"Timing may shift."}, KeyMessages: []string{"Timing may shift."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term showers exit before the weekend."}, ShortTerm: &weatherdata.DiscussionSection{Text: "Short-term showers exit before the weekend."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."}, LongTerm: &weatherdata.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."},
}, },
}, },
} }

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type DaypartDefinition struct { type DaypartDefinition struct {
@@ -48,6 +49,6 @@ func ResolveDayparts(date time.Time, location *time.Location, definitions []Dayp
return windows, nil 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} return timeutil.Period{Start: period.StartTime, End: period.EndTime}
} }

View File

@@ -8,23 +8,24 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type DailySummary struct { type DailySummary struct {
Date string `json:"date"` Date string `json:"date"`
Period timeutil.Period `json:"period"` Period timeutil.Period `json:"period"`
Dayparts []DaypartSummary `json:"dayparts"` Dayparts []DaypartSummary `json:"dayparts"`
NarrativePeriods []ForecastPeriod `json:"narrativePeriods,omitempty"` NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"`
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"` AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
Discussion *Discussion `json:"discussion,omitempty"` Discussion *weatherdata.Discussion `json:"discussion,omitempty"`
SourceWarnings []SourceWarning `json:"sourceWarnings,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
SourceProvenance []Source `json:"sourceProvenance,omitempty"` SourceProvenance []weatherdata.Source `json:"sourceProvenance,omitempty"`
} }
type DaypartSummary struct { type DaypartSummary struct {
Name string `json:"name"` Name string `json:"name"`
Period timeutil.Period `json:"period"` Period timeutil.Period `json:"period"`
HourlyPeriods []ForecastPeriod `json:"hourlyPeriods"` HourlyPeriods []weatherdata.ForecastPeriod `json:"hourlyPeriods"`
Temperature Range `json:"temperature,omitempty"` Temperature Range `json:"temperature,omitempty"`
ApparentTemperature Range `json:"apparentTemperature,omitempty"` ApparentTemperature Range `json:"apparentTemperature,omitempty"`
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"` MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
@@ -64,7 +65,7 @@ type AlertOverlap struct {
Description string `json:"description,omitempty"` 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 { if bundle == nil {
return nil, fmt.Errorf("forecast bundle is required") 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 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() { if !period.IsValid() {
return nil, fmt.Errorf("valid forecast period is required") return nil, fmt.Errorf("valid forecast period is required")
} }
@@ -121,7 +122,7 @@ func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location
return summaries, nil 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 { if bundle == nil {
return nil, fmt.Errorf("forecast bundle is required") return nil, fmt.Errorf("forecast bundle is required")
} }
@@ -155,11 +156,11 @@ func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location
return summary, nil return summary, nil
} }
func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod { func SelectHourlyPeriods(run *weatherdata.ForecastRun, period timeutil.Period) []weatherdata.ForecastPeriod {
if run == nil { if run == nil {
return nil return nil
} }
var selected []ForecastPeriod var selected []weatherdata.ForecastPeriod
for _, forecastPeriod := range run.Periods { for _, forecastPeriod := range run.Periods {
if PeriodForForecastPeriod(forecastPeriod).Overlaps(period) { if PeriodForForecastPeriod(forecastPeriod).Overlaps(period) {
selected = append(selected, forecastPeriod) selected = append(selected, forecastPeriod)
@@ -171,21 +172,21 @@ func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPer
return selected 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 { if bundle == nil || bundle.Narrative == nil {
return nil return nil
} }
return SelectHourlyPeriods(bundle.Narrative, period) return SelectHourlyPeriods(bundle.Narrative, period)
} }
func SelectDiscussion(bundle *Bundle) *Discussion { func SelectDiscussion(bundle *weatherdata.Bundle) *weatherdata.Discussion {
if bundle == nil { if bundle == nil {
return nil return nil
} }
return bundle.Discussion 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{ summary := DaypartSummary{
Name: name, Name: name,
Period: period, Period: period,
@@ -215,7 +216,7 @@ func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPer
return summary return summary
} }
func periodTemperatureValues(period ForecastPeriod) []*float64 { func periodTemperatureValues(period weatherdata.ForecastPeriod) []*float64 {
values := []*float64{} values := []*float64{}
values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...) values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...)
values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...) 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) windGust := firstValue(period.WindGustMph, period.WindGustKmh)
windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh) windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh)
indicators := Indicators{} 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 { if alertRun == nil {
return nil return nil
} }

View File

@@ -8,6 +8,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) { func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
@@ -72,7 +73,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read fixture bundle: %v", err) t.Fatalf("read fixture bundle: %v", err)
} }
var bundle Bundle var bundle weatherdata.Bundle
if err := json.Unmarshal(data, &bundle); err != nil { if err := json.Unmarshal(data, &bundle); err != nil {
t.Fatalf("decode fixture bundle: %v", err) t.Fatalf("decode fixture bundle: %v", err)
} }
@@ -98,7 +99,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
func TestOvernightGroupingAcrossMidnight(t *testing.T) { func TestOvernightGroupingAcrossMidnight(t *testing.T) {
location := time.FixedZone("Test", -5*60*60) location := time.FixedZone("Test", -5*60*60)
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location) 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-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-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), 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) { func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
location := time.FixedZone("Test", -5*60*60) location := time.FixedZone("Test", -5*60*60)
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location) 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-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-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), 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) { func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
location := time.UTC 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"}, {Name: "morning", Start: "06:00", End: "12:00"},
}) })
if err == nil { if err == nil {
@@ -155,7 +156,7 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) { func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
location := time.FixedZone("Test", -5*60*60) 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-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-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), 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) { func TestAlertOverlap(t *testing.T) {
location := time.FixedZone("Test", -5*60*60) 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"}`) 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{ period := timeutil.Period{
Start: mustParse("2026-05-29T06:00:00-05:00").In(location), Start: mustParse("2026-05-29T06:00:00-05:00").In(location),
End: mustParse("2026-05-29T09: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 { func testBundle(location *time.Location) *weatherdata.Bundle {
return &Bundle{ return &weatherdata.Bundle{
Hourly: &ForecastRun{Periods: []ForecastPeriod{ 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-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-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-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)), 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), 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"}`), 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."}}, Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
Sources: []Source{{Name: "hourly"}}, Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []SourceWarning{{Source: "daily", Code: "missing_source"}}, 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) startTime := mustParse(start).In(location)
endTime := mustParse(end).In(location) endTime := mustParse(end).In(location)
temp := temperature temp := temperature
return ForecastPeriod{ return weatherdata.ForecastPeriod{
StartTime: startTime, StartTime: startTime,
EndTime: endTime, EndTime: endTime,
TextDescription: text, TextDescription: text,

View File

@@ -8,9 +8,9 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "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/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const SchemaVersion = "weatherreporter.data_package.v1" const SchemaVersion = "weatherreporter.data_package.v1"
@@ -21,7 +21,7 @@ type Package struct {
Report Report `json:"report"` Report Report `json:"report"`
Briefing briefing.Package `json:"briefing"` Briefing briefing.Package `json:"briefing"`
RecentChanges RecentChanges `json:"recentChanges"` RecentChanges RecentChanges `json:"recentChanges"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
} }
type Report struct { type Report struct {

View File

@@ -4,9 +4,9 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "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/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const MetadataSchemaVersion = "weatherreporter.metadata.v1" const MetadataSchemaVersion = "weatherreporter.metadata.v1"
@@ -25,7 +25,7 @@ type Metadata struct {
SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"` Sources []briefing.SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
BriefingPath string `json:"briefingPath"` BriefingPath string `json:"briefingPath"`
DataPackagePath string `json:"dataPackagePath"` DataPackagePath string `json:"dataPackagePath"`
PreflightPath string `json:"preflightPath"` PreflightPath string `json:"preflightPath"`

View File

@@ -1,5 +1,5 @@
// Package forecast defines normalized weather data consumed by report builders. // Package weatherdata defines normalized weather data collected from sources.
package forecast package weatherdata
import ( import (
"encoding/json" "encoding/json"