From b0d8c6983d41ca533d436b3bbfa8b6314e8e3e87 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 12 Jun 2026 17:34:40 +0000 Subject: [PATCH] Support near-term derived facts --- internal/briefing/derived_modules_test.go | 16 +++++ internal/briefing/modules.go | 2 +- internal/facts/facts.go | 1 + internal/facts/facts_test.go | 73 +++++++++++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index 79611c9..a1b1144 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -136,6 +136,22 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { } } +func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := derivedModuleContext(report.NearTerm) + ctx.Derived.DailySummaries = nil + ctx.Derived.DaypartSummaries = nil + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + value := moduleValue[PrecipTimingModule](t, output) + if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 { + t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value) + } +} + func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index 2fb612b..02a442d 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -312,7 +312,7 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.PrecipTiming, StanzaName: "precip_timing", DefaultOptions: module.PrecipTimingOptions{}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, Builder: buildPrecipTimingModule, diff --git a/internal/facts/facts.go b/internal/facts/facts.go index 7d51dde..6d4defd 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -114,6 +114,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) { derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods) switch req.Resolved.Definition.ID { + case report.NearTerm: case report.DailyToday, report.DailyTomorrow: summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts) if err != nil { diff --git a/internal/facts/facts_test.go b/internal/facts/facts_test.go index 7e3a04c..3b85d1f 100644 --- a/internal/facts/facts_test.go +++ b/internal/facts/facts_test.go @@ -152,6 +152,63 @@ func TestBuildDerivedWeekendAndTomorrow(t *testing.T) { } } +func TestBuildDerivedNearTermUsesRollingWindowFacts(t *testing.T) { + location := testLocation() + resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:30:00-05:00"), location) + bundle := testBundle(location) + bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{ + json.RawMessage(`{"event":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`), + 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"}`), + json.RawMessage(`{"event":"Evening Advisory","headline":"Starts at end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}`), + }} + bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location) + + 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.ValidPeriodHourlyPeriods) != 4 { + t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4 rolling-window hours", len(derived.ValidPeriodHourlyPeriods)) + } + if derived.ValidPeriodHourlyPeriods[0].StartTime.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" || + derived.ValidPeriodHourlyPeriods[3].StartTime.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" { + t.Fatalf("ValidPeriodHourlyPeriods = %#v, want only hours overlapping 8:30 AM-2:30 PM", derived.ValidPeriodHourlyPeriods) + } + if len(derived.ValidPeriodNarrativePeriods) != 1 { + t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods)) + } + if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil { + t.Fatalf("near-term summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary) + } + if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" { + t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation) + } + if derived.PrecipTiming.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" { + t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation) + } + if len(derived.PrecipTiming.PrecipitationWindows) != 2 { + t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two near-term windows", derived.PrecipTiming.PrecipitationWindows) + } + if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" { + t.Fatalf("AlertOverlaps = %#v, want only alert overlapping near-term period", derived.AlertOverlaps) + } + if derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T14:30:00-05:00" { + t.Fatalf("Alert overlap end = %s, want clipped to near-term end", derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339)) + } + if got, want := outlookIDs(derived.SPCConvectiveOutlooks), []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}; strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, want) + } + if got, want := discussionHeadlines(derived.SPCConvectiveDiscussions), []string{"day1 early", "day1 late"}; strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("SPCConvectiveDiscussions = %#v, want retained discussions for overlapping SPC day %#v", got, want) + } +} + func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) { location := testLocation() resolved := resolveStormForTest(t, location) @@ -247,6 +304,22 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) { } } +func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) { + location := testLocation() + resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:00:00-05:00"), location) + resolved.Definition.ID = report.ID("future_report") + + _, err := BuildDerived(BuildDerivedRequest{ + Resolved: resolved, + Timezone: location.String(), + Dayparts: testDayparts(), + Collected: BuildCollected(testBundle(location)), + }) + if err == nil || !strings.Contains(err.Error(), `derived facts are not implemented for report "future_report"`) { + t.Fatalf("BuildDerived() error = %v, want actionable unsupported report error", err) + } +} + func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) { location := testLocation() resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)