Split weather data types from forecast derivation
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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, ¤t); err != nil {
|
if err := decodeSource(raw, ¤t); 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: ¤tIsDay,
|
IsDay: ¤tIsDay,
|
||||||
TemperatureF: ¤tTemp,
|
TemperatureF: ¤tTemp,
|
||||||
@@ -29,7 +30,7 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
WindSpeedMph: ¤tWind,
|
WindSpeedMph: ¤tWind,
|
||||||
}
|
}
|
||||||
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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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."},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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."},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user