Add collected and derived fact contracts

This commit is contained in:
2026-06-09 20:23:53 +00:00
parent 454f47b2b5
commit e9508089ab
10 changed files with 519 additions and 65 deletions

View File

@@ -6,10 +6,10 @@ This document describes the implemented workflow coordinator in `internal/app`.
`internal/app` coordinates the top-level use cases after CLI parsing and config
loading are complete. It resolves report definitions, fetches weather data,
builds briefing and prompt-input artifacts, invokes Scriptorium through the
adapter boundary, optionally notifies distributor through an app-owned notifier
boundary, persists managed state, runs batches, and reads existing artifacts for
inspection.
builds collected and derived facts, builds briefing and prompt-input artifacts,
invokes Scriptorium through the adapter boundary, optionally notifies
distributor through an app-owned notifier boundary, persists managed state, runs
batches, and reads existing artifacts for inspection.
## Inputs And Outputs
@@ -21,7 +21,7 @@ Inputs:
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests
and internal composition
- resolved report definitions from `internal/report`
- forecast bundles from `internal/adapters/weatherapi`
- weather data bundles from `internal/adapters/weatherapi`
- prior snapshots loaded from `internal/state`
- optional renderer, notifier, and state-store fakes for tests
@@ -39,11 +39,12 @@ Outputs:
## Boundaries
`internal/app` owns workflow order and request composition. It does not parse
CLI flags, load YAML files directly, implement HTTP transport, derive forecast
facts, define report periods, compare rendered Markdown, or construct
Scriptorium argv.
CLI flags, load YAML files directly, implement HTTP transport, own fact
derivation algorithms, define report periods, compare rendered Markdown, or
construct Scriptorium argv.
Report selection and report identity policy come from `internal/report`.
Collected and derived fact contracts come from `internal/facts`.
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
subprocess behavior stays in `internal/adapters/scriptorium`. Distributor
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and

View File

@@ -5,9 +5,8 @@ This document describes the implemented briefing package boundary.
## Purpose
`internal/briefing` builds structured report-specific briefing packages from
resolved report metadata, forecast bundles, and derived forecast summaries.
Briefings are curated inputs for prompt data packages, not rendered report
prose.
resolved report metadata, collected weather data, and derived forecast facts.
Briefings are curated inputs for prompt data packages, not rendered report prose.
## Inputs And Outputs
@@ -15,7 +14,7 @@ Inputs:
- resolved report definition, generation time, timezone, and valid period
- `weatherdata.Bundle` with source provenance and warnings
- derived daily or period summaries where required
- derived daily, period, or storm-window facts where required
- configured units, timezone, and descriptive location context
Outputs:
@@ -37,7 +36,7 @@ Outputs:
## Config Fields Used
The package receives configured units and timezone from the app layer. Daypart
configuration is consumed by `internal/forecast` before briefing builders run.
configuration is consumed by `internal/facts` before briefing builders run.
Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance.
Current conditions are copied from the normalized `/conditions/current` bundle

72
docs/internal/facts.md Normal file
View File

@@ -0,0 +1,72 @@
# Fact Contracts Internals
This document describes the implemented fact contract boundary.
## Purpose
`internal/facts` separates normalized upstream facts collected for a report run
from conservative report-scoped facts derived from them. The package gives app
orchestration one place to build reusable facts before briefing construction.
## Inputs And Outputs
Inputs:
- `weatherdata.Bundle` from the Weather API adapter
- resolved report definition and valid period
- report timezone
- configured daypart definitions
Outputs:
- `facts.CollectedFacts` with normalized source facts plus separate source
provenance and warnings
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
daily summaries, daypart summaries, and Storm Report window summary
## Boundaries
- This package owns fact assembly and reusable deterministic derivation for a
report run.
- It does not fetch upstream data, build prompt wording, compare prior
snapshots, write workflow state, invoke Scriptorium, or define modules.
## Config Fields Used
- `dayparts[].name`
- `dayparts[].start`
- `dayparts[].end`
- `weather_api.timezone`
## External Adapters Used
None directly. Collected facts are built from `weatherdata.Bundle`.
## State Or Manifest Behavior
None. Source provenance and warnings remain data fields for downstream metadata
and inspection.
## Failure Behavior
- Invalid or missing report valid periods return an error.
- Invalid timezone names return an error.
- Missing required hourly forecast data returns the underlying forecast
derivation error for reports that require daily summaries.
- Missing optional narrative, alert, discussion, daily, or weather story data
produces empty or nil derived fields.
## Tests
Inspect:
- `internal/facts/facts_test.go`
- `internal/app/app_test.go`
## Invariants
- Collected facts are built once from a fetched bundle.
- Derived facts are scoped to one resolved report.
- Source provenance and warnings stay separate from ordinary fact fields.
- Prompt-specific wording and one-off presentation decisions stay outside this
package.

View File

@@ -5,8 +5,8 @@ This document describes deterministic forecast summarization in
## Purpose
`internal/forecast` converts normalized `weatherdata` bundle data into daily
and period summaries used by briefing builders.
`internal/forecast` converts normalized weather data into daily and period
summaries used by fact builders and briefing builders.
## Inputs And Outputs

View File

@@ -19,6 +19,7 @@ Developers and LLM coding agents should use it with
- `internal/weatherdata`: normalized weather source facts, source metadata, and
source warnings.
- `internal/forecast`: deterministic forecast derivation.
- `internal/facts`: collected and derived report fact contracts.
- `internal/report`: report definitions, valid periods, batches, output names,
and comparison declarations.
- `internal/briefing`: report-specific briefing package builders.

View File

@@ -14,6 +14,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
@@ -789,10 +790,7 @@ func distributorUploadFiles(sourcePath string, bundlePaths []string) []distribut
}
func BuildBriefing(req BriefingRequest, bundle *weatherdata.Bundle) (briefing.Package, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return briefing.Package{}, err
}
collected := facts.BuildCollected(bundle)
dayparts := make([]forecast.DaypartDefinition, 0, len(req.Config.Dayparts))
for _, daypart := range req.Config.Dayparts {
dayparts = append(dayparts, forecast.DaypartDefinition{
@@ -801,48 +799,32 @@ func BuildBriefing(req BriefingRequest, bundle *weatherdata.Bundle) (briefing.Pa
End: daypart.End,
})
}
derived, err := facts.BuildDerived(facts.BuildDerivedRequest{
Resolved: req.Resolved,
Timezone: req.Config.WeatherAPI.Timezone,
Dayparts: dayparts,
Collected: collected,
})
if err != nil {
return briefing.Package{}, err
}
buildContext := briefing.BuildContext{
Resolved: req.Resolved,
Bundle: collected.Bundle(),
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}
switch req.Resolved.Definition.ID {
case report.DailyToday, report.DailyTomorrow:
summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts)
if err != nil {
return briefing.Package{}, err
}
return briefing.BuildDaily(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summary)
return briefing.BuildDaily(buildContext, derived.FirstDailySummary())
case report.ThreeDay, report.Weekend:
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
if err != nil {
return briefing.Package{}, err
}
if req.Resolved.Definition.ID == report.Weekend {
return briefing.BuildWeekend(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summaries)
return briefing.BuildWeekend(buildContext, derived.DailySummaries)
}
return briefing.BuildThreeDay(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summaries)
return briefing.BuildThreeDay(buildContext, derived.DailySummaries)
case report.Storm:
return briefing.BuildStorm(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
})
return briefing.BuildStorm(buildContext, derived)
default:
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
}

View File

@@ -3,6 +3,7 @@ package briefing
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -26,7 +27,7 @@ type Storm struct {
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
}
func BuildStorm(ctx BuildContext) (Package, error) {
func BuildStorm(ctx BuildContext, derived facts.DerivedFacts) (Package, error) {
if ctx.Resolved.Definition.ID != report.Storm {
return Package{}, fmt.Errorf("storm briefing requires a storm report definition")
}
@@ -34,12 +35,14 @@ func BuildStorm(ctx BuildContext) (Package, error) {
return Package{}, fmt.Errorf("forecast bundle is required")
}
period := ctx.Resolved.ValidPeriod
hourly := forecast.SelectHourlyPeriods(ctx.Bundle.Hourly, period)
narrative := forecast.SelectNarrativePeriods(ctx.Bundle, period)
daily := forecast.SelectHourlyPeriods(ctx.Bundle.Daily, period)
alerts := forecast.AlertOverlaps(ctx.Bundle.Alerts, period)
summary := forecast.SummarizeDaypart("storm window", period, hourly)
summary.AlertOverlaps = alerts
hourly := derived.ValidPeriodHourlyPeriods
narrative := derived.ValidPeriodNarrativePeriods
daily := derived.ValidPeriodDailyPeriods
alerts := derived.AlertOverlaps
if derived.StormWindowSummary == nil {
return Package{}, fmt.Errorf("storm window summary is required")
}
summary := *derived.StormWindowSummary
storm := &Storm{
TimingWindow: period,

View File

@@ -6,6 +6,8 @@ import (
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
@@ -62,7 +64,7 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
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"}, stormDerivedFacts(t, resolved, bundle))
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
@@ -120,7 +122,7 @@ func TestStormBriefingWithDiscussionButNoAlert(t *testing.T) {
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"}, stormDerivedFacts(t, resolved, bundle))
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
@@ -155,7 +157,7 @@ func TestStormBriefingQuietWindow(t *testing.T) {
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"}, stormDerivedFacts(t, resolved, bundle))
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
@@ -167,3 +169,17 @@ func TestStormBriefingQuietWindow(t *testing.T) {
t.Fatalf("WhatToWatchNext = %#v, want watch fallback", pkg.Storm.WhatToWatchNext)
}
}
func stormDerivedFacts(t *testing.T, resolved report.Resolved, bundle *weatherdata.Bundle) facts.DerivedFacts {
t.Helper()
derived, err := facts.BuildDerived(facts.BuildDerivedRequest{
Resolved: resolved,
Timezone: "America/Chicago",
Dayparts: []forecast.DaypartDefinition{{Name: "morning", Start: "06:00", End: "12:00"}},
Collected: facts.BuildCollected(bundle),
})
if err != nil {
t.Fatalf("build derived facts: %v", err)
}
return derived
}

139
internal/facts/facts.go Normal file
View File

@@ -0,0 +1,139 @@
// Package facts defines collected and derived report facts.
package facts
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type CollectedFacts struct {
FetchedAt time.Time
Observation *weatherdata.Observation
Current *weatherdata.Current
Hourly *weatherdata.ForecastRun
Narrative *weatherdata.ForecastRun
Alerts *weatherdata.AlertRun
Discussion *weatherdata.Discussion
Daily *weatherdata.ForecastRun
WeatherStory *weatherdata.WeatherStory
SourceProvenance []weatherdata.Source
SourceWarnings []weatherdata.SourceWarning
}
func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
if bundle == nil {
return CollectedFacts{}
}
return CollectedFacts{
FetchedAt: bundle.FetchedAt,
Observation: bundle.Observation,
Current: bundle.Current,
Hourly: bundle.Hourly,
Narrative: bundle.Narrative,
Alerts: bundle.Alerts,
Discussion: bundle.Discussion,
Daily: bundle.Daily,
WeatherStory: bundle.WeatherStory,
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
}
}
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
return &weatherdata.Bundle{
FetchedAt: f.FetchedAt,
Observation: f.Observation,
Current: f.Current,
Hourly: f.Hourly,
Narrative: f.Narrative,
Alerts: f.Alerts,
Discussion: f.Discussion,
Daily: f.Daily,
WeatherStory: f.WeatherStory,
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
}
}
type BuildDerivedRequest struct {
Resolved report.Resolved
Timezone string
Dayparts []forecast.DaypartDefinition
Collected CollectedFacts
}
type DerivedFacts struct {
ValidPeriodHourlyPeriods []weatherdata.ForecastPeriod
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
AlertOverlaps []forecast.AlertOverlap
DailySummaries []forecast.DailySummary
DaypartSummaries []forecast.DaypartSummary
StormWindowSummary *forecast.DaypartSummary
}
func (f DerivedFacts) FirstDailySummary() *forecast.DailySummary {
if len(f.DailySummaries) == 0 {
return nil
}
return &f.DailySummaries[0]
}
func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
if !req.Resolved.ValidPeriod.IsValid() {
return DerivedFacts{}, fmt.Errorf("resolved valid period is required")
}
location, err := timeutil.LoadLocation(req.Timezone)
if err != nil {
return DerivedFacts{}, fmt.Errorf("load report timezone %q: %w", req.Timezone, err)
}
bundle := req.Collected.Bundle()
period := req.Resolved.ValidPeriod
derived := DerivedFacts{
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
}
switch req.Resolved.Definition.ID {
case report.DailyToday, report.DailyTomorrow:
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
if err != nil {
return DerivedFacts{}, err
}
derived.DailySummaries = []forecast.DailySummary{*summary}
case report.ThreeDay, report.Weekend:
summaries, err := forecast.BuildPeriodDailySummaries(bundle, period, location, req.Dayparts)
if err != nil {
return DerivedFacts{}, err
}
derived.DailySummaries = summaries
case report.Storm:
summary := forecast.SummarizeDaypart("storm window", period, derived.ValidPeriodHourlyPeriods)
summary.AlertOverlaps = derived.AlertOverlaps
derived.StormWindowSummary = &summary
default:
return DerivedFacts{}, fmt.Errorf("derived facts are not implemented for report %q", req.Resolved.Definition.ID)
}
derived.DaypartSummaries = collectDaypartSummaries(derived)
return derived, nil
}
func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
var out []forecast.DaypartSummary
for _, summary := range derived.DailySummaries {
out = append(out, summary.Dayparts...)
}
if derived.StormWindowSummary != nil {
out = append(out, *derived.StormWindowSummary)
}
return out
}

View File

@@ -0,0 +1,241 @@
package facts
import (
"encoding/json"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
fetchedAt := mustParse("2026-05-29T10:00:00Z")
bundle := &weatherdata.Bundle{
FetchedAt: fetchedAt,
Current: &weatherdata.Current{ConditionText: "Clear"},
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
}
collected := BuildCollected(bundle)
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
}
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
}
if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "daily" {
t.Fatalf("SourceWarnings = %#v, want daily warning", collected.SourceWarnings)
}
bundle.Sources[0].Name = "changed"
bundle.Warnings[0].Source = "changed"
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "daily" {
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
}
}
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(testBundle(location)),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if len(derived.ValidPeriodHourlyPeriods) != 4 {
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4", len(derived.ValidPeriodHourlyPeriods))
}
if len(derived.ValidPeriodNarrativePeriods) != 1 {
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 1", len(derived.ValidPeriodNarrativePeriods))
}
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
t.Fatalf("AlertOverlaps = %#v, want Flood Watch overlap", derived.AlertOverlaps)
}
if len(derived.DailySummaries) != 1 || len(derived.DailySummaries[0].Dayparts) != 3 {
t.Fatalf("DailySummaries = %#v, want one summary with dayparts", derived.DailySummaries)
}
if len(derived.DaypartSummaries) != 3 {
t.Fatalf("DaypartSummaries length = %d, want 3", len(derived.DaypartSummaries))
}
morning := derived.DailySummaries[0].Dayparts[0]
if len(morning.HourlyPeriods) != 1 || morning.MaxPrecipitationProbability == nil || morning.MaxPrecipitationProbability.Value != 60 {
t.Fatalf("morning summary = %#v, want sliced hour with precip max", morning)
}
}
func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.ThreeDay, mustParse("2026-05-29T08:00:00-05:00"), location)
bundle := testBundle(location)
bundle.Narrative = nil
bundle.Alerts = nil
bundle.Discussion = nil
bundle.WeatherStory = nil
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if len(derived.DailySummaries) != 3 {
t.Fatalf("DailySummaries length = %d, want 3 partial-day summaries", len(derived.DailySummaries))
}
if len(derived.ValidPeriodNarrativePeriods) != 0 {
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 0 for missing optional source", len(derived.ValidPeriodNarrativePeriods))
}
if len(derived.AlertOverlaps) != 0 {
t.Fatalf("AlertOverlaps = %#v, want none for missing optional alerts", derived.AlertOverlaps)
}
if derived.DailySummaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
t.Fatalf("first summary start = %s, want valid-period start", derived.DailySummaries[0].Period.Start.Format(time.RFC3339))
}
}
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
location := testLocation()
for _, id := range []report.ID{report.DailyTomorrow, report.Weekend} {
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(testBundle(location)),
})
if err != nil {
t.Fatalf("BuildDerived(%s) error = %v", id, err)
}
if len(derived.DailySummaries) == 0 {
t.Fatalf("BuildDerived(%s) DailySummaries length = 0, want summaries", id)
}
}
}
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
location := testLocation()
resolved := resolveStormForTest(t, location)
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(testBundle(location)),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if len(derived.ValidPeriodHourlyPeriods) != 2 {
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 2 storm-window hours", len(derived.ValidPeriodHourlyPeriods))
}
if len(derived.ValidPeriodDailyPeriods) != 1 {
t.Fatalf("ValidPeriodDailyPeriods length = %d, want 1 daily period", len(derived.ValidPeriodDailyPeriods))
}
if derived.StormWindowSummary == nil {
t.Fatal("StormWindowSummary = nil, want summary")
}
if derived.StormWindowSummary.MaxPrecipitationProbability == nil || derived.StormWindowSummary.MaxPrecipitationProbability.Value != 80 {
t.Fatalf("StormWindowSummary = %#v, want peak precipitation", derived.StormWindowSummary)
}
if len(derived.StormWindowSummary.AlertOverlaps) != 1 {
t.Fatalf("StormWindowSummary.AlertOverlaps length = %d, want 1", len(derived.StormWindowSummary.AlertOverlaps))
}
}
func testBundle(location *time.Location) *weatherdata.Bundle {
return &weatherdata.Bundle{
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
Current: &weatherdata.Current{ConditionText: "Cloudy"},
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, 10),
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 80, 35),
hour(location, "2026-05-29T13:00:00-05:00", "2026-05-29T14:00:00-05:00", "Heavy rain", 70, 25),
hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot", 10, 15),
hour(location, "2026-05-30T09:00:00-05:00", "2026-05-30T10:00:00-05:00", "Clear", 0, 5),
}},
Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storm chances peak midday.", 70, 20),
}},
Daily: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00", "Storms", 70, 20),
}},
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`),
}},
Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storm confidence is moderate."}},
WeatherStory: &weatherdata.WeatherStory{Title: "Storm Risk"},
Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
}
}
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
temperature := 70.0
return weatherdata.ForecastPeriod{
StartTime: mustParse(start).In(location),
EndTime: mustParse(end).In(location),
TextDescription: text,
TemperatureF: &temperature,
ProbabilityOfPrecipitationPercent: &precip,
WindGustMph: &gust,
}
}
func testDayparts() []forecast.DaypartDefinition {
return []forecast.DaypartDefinition{
{Name: "morning", Start: "06:00", End: "12:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"},
{Name: "evening", Start: "18:00", End: "24:00"},
}
}
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
t.Helper()
resolved, err := report.Resolve(id, report.ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("resolve %s: %v", id, err)
}
return resolved
}
func resolveStormForTest(t *testing.T, location *time.Location) report.Resolved {
t.Helper()
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T08:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T11:30:00-05:00"),
StormEnd: mustParse("2026-05-29T13:30:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
return resolved
}
func testLocation() *time.Location {
location, err := time.LoadLocation("America/Chicago")
if err != nil {
panic(err)
}
return location
}
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}