Derive report-period SPC convective outlooks
This commit is contained in:
@@ -23,12 +23,16 @@ Outputs:
|
||||
provenance and warnings. SPC convective outlook source data is carried
|
||||
through when present in the bundle.
|
||||
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
|
||||
daily summaries, daypart summaries, and Storm Report window summary
|
||||
report-period SPC convective outlooks and discussions, daily summaries,
|
||||
daypart summaries, and Storm Report window summary
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns fact assembly and reusable deterministic derivation for a
|
||||
report run.
|
||||
- SPC convective outlook derivation selects already-collected outlooks whose
|
||||
half-open valid intervals overlap the resolved report period and retains
|
||||
discussions for represented outlook days.
|
||||
- It does not fetch upstream data, build prompt wording, compare prior
|
||||
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
||||
|
||||
@@ -57,6 +61,8 @@ and inspection.
|
||||
- Missing optional narrative, alert, discussion, daily, or weather story data
|
||||
produces empty or nil derived fields.
|
||||
- Missing optional SPC convective outlook data produces a nil collected field.
|
||||
- A present SPC convective outlook source with no report-period matches
|
||||
produces non-nil empty derived outlook and discussion slices.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -69,6 +75,8 @@ Inspect:
|
||||
|
||||
- Collected facts are built once from a fetched bundle.
|
||||
- Derived facts are scoped to one resolved report.
|
||||
- SPC convective outlook selection uses the resolved report period, not
|
||||
server-current active filtering.
|
||||
- Source provenance and warnings stay separate from ordinary fact fields.
|
||||
- Prompt-specific wording and one-off presentation decisions stay outside this
|
||||
package.
|
||||
|
||||
@@ -185,6 +185,8 @@ func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext)
|
||||
return len(ctx.Derived.DaypartSummaries) > 0
|
||||
case module.RequiresDerivedPrecipTiming:
|
||||
return true
|
||||
case module.RequiresDerivedSPCConvectiveOutlooks:
|
||||
return ctx.Derived.SPCConvectiveOutlooks != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -142,6 +142,18 @@ func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
||||
ctx := ModuleContext{}
|
||||
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
|
||||
}
|
||||
|
||||
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
||||
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
|
||||
}
|
||||
}
|
||||
|
||||
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package facts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
@@ -76,6 +77,8 @@ type DerivedFacts struct {
|
||||
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
||||
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
||||
AlertOverlaps []forecast.AlertOverlap
|
||||
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
|
||||
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
|
||||
DailySummaries []forecast.DailySummary
|
||||
DaypartSummaries []forecast.DaypartSummary
|
||||
PrecipTiming forecast.PrecipTiming
|
||||
@@ -99,11 +102,14 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
||||
}
|
||||
bundle := req.Collected.Bundle()
|
||||
period := req.Resolved.ValidPeriod
|
||||
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
|
||||
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),
|
||||
SPCConvectiveOutlooks: spcOutlooks,
|
||||
SPCConvectiveDiscussions: spcDiscussions,
|
||||
}
|
||||
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
|
||||
|
||||
@@ -142,3 +148,81 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func selectSPCConvectiveOutlooks(run *weatherdata.ConvectiveOutlookRun, period timeutil.Period) ([]weatherdata.ConvectiveOutlook, []weatherdata.ConvectiveOutlookDiscussion) {
|
||||
if run == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
outlooks := make([]weatherdata.ConvectiveOutlook, 0, len(run.Outlooks))
|
||||
days := map[int]struct{}{}
|
||||
for _, outlook := range run.Outlooks {
|
||||
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(period) {
|
||||
continue
|
||||
}
|
||||
outlooks = append(outlooks, outlook)
|
||||
days[outlook.Day] = struct{}{}
|
||||
}
|
||||
sort.SliceStable(outlooks, func(i, j int) bool {
|
||||
left := outlooks[i]
|
||||
right := outlooks[j]
|
||||
if left.Day != right.Day {
|
||||
return left.Day < right.Day
|
||||
}
|
||||
if left.OutlookType != right.OutlookType {
|
||||
return left.OutlookType < right.OutlookType
|
||||
}
|
||||
leftRank, leftRankOK := severityRank(left)
|
||||
rightRank, rightRankOK := severityRank(right)
|
||||
if leftRankOK != rightRankOK {
|
||||
return leftRankOK
|
||||
}
|
||||
if leftRankOK && leftRank != rightRank {
|
||||
return leftRank > rightRank
|
||||
}
|
||||
if !left.ValidFrom.Equal(right.ValidFrom) {
|
||||
return left.ValidFrom.Before(right.ValidFrom)
|
||||
}
|
||||
if left.Label != right.Label {
|
||||
return left.Label < right.Label
|
||||
}
|
||||
return left.ID < right.ID
|
||||
})
|
||||
|
||||
discussions := make([]weatherdata.ConvectiveOutlookDiscussion, 0, len(run.Discussions))
|
||||
for _, discussion := range run.Discussions {
|
||||
if _, ok := days[discussion.Day]; ok {
|
||||
discussions = append(discussions, discussion)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(discussions, func(i, j int) bool {
|
||||
left := discussions[i]
|
||||
right := discussions[j]
|
||||
if left.Day != right.Day {
|
||||
return left.Day < right.Day
|
||||
}
|
||||
if left.UpdatedAt != nil && right.UpdatedAt != nil && !left.UpdatedAt.Equal(*right.UpdatedAt) {
|
||||
return left.UpdatedAt.Before(*right.UpdatedAt)
|
||||
}
|
||||
if (left.UpdatedAt != nil) != (right.UpdatedAt != nil) {
|
||||
return left.UpdatedAt != nil
|
||||
}
|
||||
if left.Headline != right.Headline {
|
||||
return left.Headline < right.Headline
|
||||
}
|
||||
if left.Summary != right.Summary {
|
||||
return left.Summary < right.Summary
|
||||
}
|
||||
return left.Discussion < right.Discussion
|
||||
})
|
||||
|
||||
return outlooks, discussions
|
||||
}
|
||||
|
||||
func severityRank(outlook weatherdata.ConvectiveOutlook) (int, bool) {
|
||||
if outlook.SeverityRank == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *outlook.SeverityRank, true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package facts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -181,6 +182,108 @@ func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
|
||||
location := testLocation()
|
||||
now := mustParse("2026-05-29T08:00:00-05:00")
|
||||
bundle := testBundle(location)
|
||||
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resolved report.Resolved
|
||||
wantOutlookIDs []string
|
||||
wantDiscussion []string
|
||||
}{
|
||||
{
|
||||
name: "daily today",
|
||||
resolved: resolveForTest(t, report.DailyToday, now, location),
|
||||
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
|
||||
wantDiscussion: []string{"day1 early", "day1 late"},
|
||||
},
|
||||
{
|
||||
name: "daily tomorrow",
|
||||
resolved: resolveForTest(t, report.DailyTomorrow, now, location),
|
||||
wantOutlookIDs: []string{"sat-enhanced"},
|
||||
wantDiscussion: []string{"day2"},
|
||||
},
|
||||
{
|
||||
name: "three day",
|
||||
resolved: resolveForTest(t, report.ThreeDay, now, location),
|
||||
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic", "sat-enhanced", "sun-slight"},
|
||||
wantDiscussion: []string{"day1 early", "day1 late", "day2", "day3"},
|
||||
},
|
||||
{
|
||||
name: "weekend",
|
||||
resolved: resolveForTest(t, report.Weekend, now, location),
|
||||
wantOutlookIDs: []string{"sat-enhanced", "sun-slight"},
|
||||
wantDiscussion: []string{"day2", "day3"},
|
||||
},
|
||||
{
|
||||
name: "storm",
|
||||
resolved: resolveStormForTest(t, location),
|
||||
wantOutlookIDs: []string{"fri-storm", "fri-low"},
|
||||
wantDiscussion: []string{"day1 early", "day1 late"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: tt.resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(bundle),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived() error = %v", err)
|
||||
}
|
||||
if got := outlookIDs(derived.SPCConvectiveOutlooks); strings.Join(got, ",") != strings.Join(tt.wantOutlookIDs, ",") {
|
||||
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, tt.wantOutlookIDs)
|
||||
}
|
||||
if got := discussionHeadlines(derived.SPCConvectiveDiscussions); strings.Join(got, ",") != strings.Join(tt.wantDiscussion, ",") {
|
||||
t.Fatalf("SPCConvectiveDiscussions = %#v, want %#v", got, tt.wantDiscussion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
|
||||
location := testLocation()
|
||||
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
bundle := testBundle(location)
|
||||
bundle.SPCConvectiveOutlooks = nil
|
||||
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(bundle),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived(missing source) error = %v", err)
|
||||
}
|
||||
if derived.SPCConvectiveOutlooks != nil || derived.SPCConvectiveDiscussions != nil {
|
||||
t.Fatalf("missing source derived outlooks=%#v discussions=%#v, want nil slices", derived.SPCConvectiveOutlooks, derived.SPCConvectiveDiscussions)
|
||||
}
|
||||
|
||||
bundle.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{Outlooks: []weatherdata.ConvectiveOutlook{}, Discussions: []weatherdata.ConvectiveOutlookDiscussion{}}
|
||||
derived, err = BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(bundle),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived(checked empty source) error = %v", err)
|
||||
}
|
||||
if derived.SPCConvectiveOutlooks == nil || len(derived.SPCConvectiveOutlooks) != 0 {
|
||||
t.Fatalf("checked empty outlooks = %#v, want non-nil empty slice", derived.SPCConvectiveOutlooks)
|
||||
}
|
||||
if derived.SPCConvectiveDiscussions == nil || len(derived.SPCConvectiveDiscussions) != 0 {
|
||||
t.Fatalf("checked empty discussions = %#v, want non-nil empty slice", derived.SPCConvectiveDiscussions)
|
||||
}
|
||||
}
|
||||
|
||||
func testBundle(location *time.Location) *weatherdata.Bundle {
|
||||
return &weatherdata.Bundle{
|
||||
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
|
||||
@@ -208,6 +311,65 @@ func testBundle(location *time.Location) *weatherdata.Bundle {
|
||||
}
|
||||
}
|
||||
|
||||
func testConvectiveOutlookRun(location *time.Location) *weatherdata.ConvectiveOutlookRun {
|
||||
rank1 := 1
|
||||
rank2 := 2
|
||||
rank3 := 3
|
||||
rank4 := 4
|
||||
rank5 := 5
|
||||
return &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{
|
||||
convectiveOutlook(location, "mon-outside", 4, "categorical", "MDT", &rank5, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00"),
|
||||
convectiveOutlook(location, "sun-slight", 3, "categorical", "SLGT", &rank3, "2026-05-31T06:00:00-05:00", "2026-06-01T00:00:00-05:00"),
|
||||
convectiveOutlook(location, "fri-low", 1, "categorical", "MRGL", &rank1, "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00"),
|
||||
convectiveOutlook(location, "fri-missing-rank", 1, "categorical", "GEN", nil, "2026-05-29T08:00:00-05:00", "2026-05-29T10:00:00-05:00"),
|
||||
convectiveOutlook(location, "sat-enhanced", 2, "categorical", "ENH", &rank4, "2026-05-30T01:00:00-05:00", "2026-05-30T12:00:00-05:00"),
|
||||
convectiveOutlook(location, "fri-high", 1, "categorical", "SLGT", &rank3, "2026-05-29T07:00:00-05:00", "2026-05-29T11:00:00-05:00"),
|
||||
convectiveOutlook(location, "fri-probabilistic", 1, "probabilistic", "5%", &rank2, "2026-05-29T05:00:00-05:00", "2026-05-29T10:00:00-05:00"),
|
||||
convectiveOutlook(location, "fri-storm", 1, "categorical", "SLGT", &rank2, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00"),
|
||||
},
|
||||
Discussions: []weatherdata.ConvectiveOutlookDiscussion{
|
||||
{Day: 4, Headline: "day4", UpdatedAt: ptrTime(mustParse("2026-05-31T10:00:00-05:00"))},
|
||||
{Day: 1, Headline: "day1 late", UpdatedAt: ptrTime(mustParse("2026-05-29T09:00:00-05:00"))},
|
||||
{Day: 3, Headline: "day3", UpdatedAt: ptrTime(mustParse("2026-05-31T08:00:00-05:00"))},
|
||||
{Day: 1, Headline: "day1 early", UpdatedAt: ptrTime(mustParse("2026-05-29T08:00:00-05:00"))},
|
||||
{Day: 2, Headline: "day2", UpdatedAt: ptrTime(mustParse("2026-05-30T08:00:00-05:00"))},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convectiveOutlook(location *time.Location, id string, day int, outlookType string, label string, rank *int, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
|
||||
return weatherdata.ConvectiveOutlook{
|
||||
ID: id,
|
||||
Day: day,
|
||||
OutlookType: outlookType,
|
||||
Label: label,
|
||||
SeverityRank: rank,
|
||||
ValidFrom: mustParse(validFrom).In(location),
|
||||
ValidTo: mustParse(validTo).In(location),
|
||||
}
|
||||
}
|
||||
|
||||
func outlookIDs(outlooks []weatherdata.ConvectiveOutlook) []string {
|
||||
out := make([]string, 0, len(outlooks))
|
||||
for _, outlook := range outlooks {
|
||||
out = append(out, outlook.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func discussionHeadlines(discussions []weatherdata.ConvectiveOutlookDiscussion) []string {
|
||||
out := make([]string, 0, len(discussions))
|
||||
for _, discussion := range discussions {
|
||||
out = append(out, discussion.Headline)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
|
||||
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
|
||||
temperature := 70.0
|
||||
return weatherdata.ForecastPeriod{
|
||||
|
||||
Reference in New Issue
Block a user