From e6a4bb2d165cacdb9f35af0e053f5eb74a63651e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 12 Jun 2026 12:00:40 -0500 Subject: [PATCH] Clean up and normalize prompt output modules --- docs/internal/briefing.md | 15 +- docs/internal/module.md | 15 +- docs/internal/prompt-input.md | 3 + docs/roadmap/implementation.md | 23 +-- docs/roadmap/outlook.md | 14 +- internal/app/app_test.go | 58 +++++- internal/briefing/base_modules_test.go | 28 +-- .../derived_daypart_summaries_module.go | 6 +- internal/briefing/derived_modules_test.go | 21 ++- internal/briefing/hourly_forecast_module.go | 10 +- internal/briefing/module_format_helpers.go | 8 +- .../briefing/narrative_forecast_module.go | 10 +- internal/briefing/outdoor_windows_module.go | 26 +-- internal/briefing/precip_timing_module.go | 8 +- .../spc_convective_discussion_module.go | 69 +++++--- .../spc_convective_discussion_module_test.go | 166 +++++++++++++++++- .../spc_convective_outlooks_module.go | 23 ++- .../spc_convective_outlooks_module_test.go | 44 ++++- internal/briefing/summary_helpers.go | 7 +- internal/briefing/weather_story_module.go | 46 ++--- internal/changes/daily.go | 3 +- internal/changes/daily_test.go | 8 +- internal/changes/three_day_test.go | 3 +- 23 files changed, 456 insertions(+), 158 deletions(-) diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index c0ca214..bd886ad 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -64,13 +64,18 @@ subset of discussion fields. `spc_convective_outlooks` uses collected SPC run metadata and derived report-period outlooks. It emits `checked: true` for a successfully fetched empty run, reports `outlook_count`, and includes prompt-facing outlook fields -such as risk label, severity rank, valid times, source URL, image URL, and -whether the outlook contains the configured location. It does not emit GeoJSON -geometry. +such as risk label, `period_begins`, `period_ends`, image URL, and whether the +outlook contains the configured location. It does not emit GeoJSON geometry, +source URL, expiration time, or severity rank. + +Prompt-facing module intervals use friendly local `period_begins` and +`period_ends` labels. Canonical report metadata, source provenance, +`issued_at`, `updated_at`, and point-in-time fields remain separate. `spc_convective_discussion` uses the same derived report-period outlooks and -discussion records. It is omitted unless at least one retained outlook has -severity rank `3` or higher and matching discussion text exists. +discussion records. It is omitted unless at least one retained categorical +outlook for the same SPC day has severity rank `3` or higher and matching +discussion text exists. ## External Adapters Used diff --git a/docs/internal/module.md b/docs/internal/module.md index 881f1be..ef6a5f4 100644 --- a/docs/internal/module.md +++ b/docs/internal/module.md @@ -78,14 +78,17 @@ validation. - `outlooks` Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`, -`severity_rank`, `valid_start`, `valid_end`, `issued_at`, `expires_at`, -`contains_location`, `source_url`, and `image_url`. It omits GeoJSON geometry. +`period_begins`, `period_ends`, `issued_at`, `contains_location`, and +`image_url`. It omits GeoJSON geometry, source URL, expiration time, and +severity rank. `spc_convective_discussion` emits a narrative stanza only when a retained -report-period outlook has severity rank `3` or higher and matching discussion -text is available. Its output includes `included_because` and `discussions`; -each discussion may include `day`, `headline`, `summary`, `discussion`, and -`updated_at`. +report-period categorical outlook has severity rank `3` or higher and matching +discussion text is available. Its output includes `included_because` and +`discussions`; each discussion may include `day`, `period_begins`, +`period_ends`, `headline`, `summary`, `discussion`, and `updated_at`. +Discussions are included only for SPC days whose retained categorical outlooks +meet the severity threshold. ## Boundaries diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index 2a4989c..d996aaa 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -64,6 +64,9 @@ weather module stanzas under prompt-facing categories. This grouping is a YAML presentation concern only: module snapshots remain flat, and loaded `promptinput.Package` values expose flat stanza names in `Briefing.Values`. Within each category, stanza order follows the module snapshot output order. +Prompt-facing module intervals use local `period_begins` and `period_ends` +labels; canonical report metadata and source timestamps remain structured +timestamps where applicable. Current categories are: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 1ef7ba3..2c074b1 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -42,7 +42,7 @@ plan. - Do not depend on `/outlooks/convective/active` or `/outlooks/convective/location` for initial behavior. - Include SPC discussion text only when at least one retained report-period - outlook has `severity_rank >= 3`. + categorical outlook has `severity_rank >= 3`. - Define the discussion threshold as an internal constant, initially `3`, not a config field. @@ -305,13 +305,10 @@ Implementation: - `outlook_type`; - `label`; - `label_text`; - - `severity_rank`; - - `valid_start`; - - `valid_end`; + - `period_begins`; + - `period_ends`; - `issued_at`; - - `expires_at`; - `contains_location`; - - `source_url`; - `image_url`. - Use human-readable local time helpers consistent with current modules. - Do not emit GeoJSON geometry. @@ -359,22 +356,25 @@ Implementation: - Define a package-private constant near the module, for example: `defaultSPCConvectiveDiscussionMinimumSeverityRank = 3`. - Build from derived retained outlooks and discussions. -- Include discussion text only when at least one retained outlook has +- Include discussion text only when at least one retained categorical outlook + has `severityRank >= defaultSPCConvectiveDiscussionMinimumSeverityRank`. - When the threshold is not met, return `nil` output so the stanza is omitted. - When threshold is met, include discussions for retained outlook days with: - `day`; + - `period_begins`; + - `period_ends`; - `headline`; - `summary`; - `discussion`; - `updated_at`. - Include a concise reason field such as: - `included_because: "severity_rank >= 3"`. + `included_because: "categorical severity_rank >= 3"`. Acceptance criteria: -- Slight Risk or higher retained outlooks include matching discussion records - when available. +- Slight Risk or higher retained categorical outlooks include matching + discussion records when available. - Lower-risk retained outlooks still appear in `spc_convective_outlooks` but do not emit `spc_convective_discussion`. - Missing discussion text omits the stanza without failing report generation. @@ -542,7 +542,8 @@ Manual review: - Confirm no module builder performs Weather API calls. - Confirm prompt YAML omits GeoJSON geometry. - Confirm checked-empty outlook data is not represented as missing data. -- Confirm SPC discussion text appears only at severity rank `3` or higher. +- Confirm SPC discussion text appears only for categorical outlooks at severity + rank `3` or higher. - Confirm public CLI syntax, output paths, distributor upload behavior, and Scriptorium argv remain unchanged. diff --git a/docs/roadmap/outlook.md b/docs/roadmap/outlook.md index 7c54b65..7510a98 100644 --- a/docs/roadmap/outlook.md +++ b/docs/roadmap/outlook.md @@ -57,8 +57,8 @@ Important response semantics: - Place `spc_convective_outlooks` under `applicable_risk_products`. - Place `spc_convective_discussion` under `narrative_products`, immediately after `area_forecast_discussion` in report module order when both are present. -- Include SPC outlook discussion text only when at least one retained outlook - for the report valid period has `severity_rank >= 3`. +- Include SPC outlook discussion text only when at least one retained + categorical outlook for the report valid period has `severity_rank >= 3`. - Define that threshold as an internal constant so it can be adjusted later without searching through module code. - Treat a non-null run with empty arrays as checked empty data, not missing @@ -104,11 +104,9 @@ briefing: outlook_type: categorical label: SLGT label_text: Slight Risk - severity_rank: 3 - valid_start: "2026-06-12 at 8:00 AM" - valid_end: "2026-06-13 at 7:00 AM" + period_begins: "2026-06-12 at 8:00 AM" + period_ends: "2026-06-13 at 7:00 AM" contains_location: true - source_url: "https://..." image_url: "https://..." ``` @@ -119,9 +117,11 @@ briefing: narrative_products: area_forecast_discussion: {} spc_convective_discussion: - included_because: "severity_rank >= 3" + included_because: "categorical severity_rank >= 3" discussions: - day: 1 + period_begins: "2026-06-12 at 8:00 AM" + period_ends: "2026-06-13 at 7:00 AM" headline: "Severe storms possible" summary: "Scattered severe storms are possible." discussion: "SPC discussion text." diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f7af2b4..b3d704e 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "os" @@ -204,6 +205,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok { t.Fatal("data package metadata stanza missing") } + assertNoStaleModuleIntervalKeys(t, savedDataPackage.Briefing.Values) spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any) if !ok || spcOutlooks["checked"] != true || spcOutlooks["outlook_count"] != 0 { t.Fatalf("data package SPC convective outlooks = %#v, want checked empty source", savedDataPackage.Briefing.Values["spc_convective_outlooks"]) @@ -274,15 +276,21 @@ func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) { for _, want := range []string{ " spc_convective_outlooks:", " spc_convective_discussion:", - " included_because: severity_rank >= 3", + " included_because: categorical severity_rank >= 3", " label_text: Slight Risk", - " severity_rank: 3", + " period_begins:", + " period_ends:", " discussion: Severe thunderstorms may produce damaging winds during the afternoon.", } { if !strings.Contains(text, want) { t.Fatalf("data package missing %q:\n%s", want, text) } } + for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} { + if strings.Contains(text, omitted) { + t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text) + } + } riskIndex := strings.Index(text, " applicable_risk_products:") alertIndex := strings.Index(text, " alert_digest:") @@ -304,6 +312,7 @@ func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) { if err != nil { t.Fatalf("LoadYAML() error = %v", err) } + assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values) if _, ok := loaded.Briefing.Values["spc_convective_outlooks"]; !ok { t.Fatal("loaded package missing spc_convective_outlooks stanza") } @@ -324,9 +333,14 @@ func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T) t.Fatal("module snapshot has spc_convective_discussion stanza, want omitted below threshold") } text := string(readDataPackageForTest(t, result)) - if !strings.Contains(text, " spc_convective_outlooks:") || !strings.Contains(text, " label_text: Marginal Risk") || !strings.Contains(text, " severity_rank: 2") { + if !strings.Contains(text, " spc_convective_outlooks:") || !strings.Contains(text, " label_text: Marginal Risk") { t.Fatalf("data package missing lower-risk SPC outlook:\n%s", text) } + for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} { + if strings.Contains(text, omitted) { + t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text) + } + } if strings.Contains(text, "spc_convective_discussion:") || strings.Contains(text, "Low-end severe threat discussion.") { t.Fatalf("data package has SPC convective discussion, want omitted below threshold:\n%s", text) } @@ -1470,6 +1484,34 @@ func readDataPackageForTest(t *testing.T, result *ReportResult) []byte { return data } +func assertNoStaleModuleIntervalKeys(t *testing.T, values map[string]any) { + t.Helper() + for name, value := range values { + if name == "metadata" { + continue + } + assertNoStaleIntervalKeys(t, "briefing."+name, value) + } +} + +func assertNoStaleIntervalKeys(t *testing.T, path string, value any) { + t.Helper() + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + switch key { + case "start_time", "end_time", "period", "start", "end": + t.Fatalf("%s has stale interval key %q in %#v", path, key, typed) + } + assertNoStaleIntervalKeys(t, path+"."+key, child) + } + case []any: + for i, child := range typed { + assertNoStaleIntervalKeys(t, fmt.Sprintf("%s[%d]", path, i), child) + } + } +} + func assertPathsExist(t *testing.T, paths ...string) { t.Helper() for _, path := range paths { @@ -1515,9 +1557,10 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna }}, {ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{ "morning": map[string]any{ - "date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout), - "period": resolved.ValidPeriod.Start.Add(6*time.Hour).Format("2006-01-02 at 3:04 PM") + " to " + resolved.ValidPeriod.Start.Add(10*time.Hour).Format("2006-01-02 at 3:04 PM"), - "temp_range_f": "50-58", + "date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout), + "period_begins": resolved.ValidPeriod.Start.Add(6 * time.Hour).Format("2006-01-02 at 3:04 PM"), + "period_ends": resolved.ValidPeriod.Start.Add(10 * time.Hour).Format("2006-01-02 at 3:04 PM"), + "temp_range_f": "50-58", }, }}, {ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{ @@ -1539,7 +1582,8 @@ func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot { {ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{ date + "_morning": map[string]any{ "date": date, - "period": date + " at 6:00 AM to " + date + " at 10:00 AM", + "period_begins": date + " at 6:00 AM", + "period_ends": date + " at 10:00 AM", "temp_range_f": "50-58", "max_pop_percent": precip, "max_pop_time": "6 AM", diff --git a/internal/briefing/base_modules_test.go b/internal/briefing/base_modules_test.go index 8892347..3d63ad8 100644 --- a/internal/briefing/base_modules_test.go +++ b/internal/briefing/base_modules_test.go @@ -62,8 +62,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) { if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 { t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period) } - if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" { - t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime) + if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" { + t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds) } if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 { t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period) @@ -73,7 +73,7 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) { t.Fatalf("Marshal hourly forecast: %v", err) } jsonText := string(data) - for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} { + for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} { if !strings.Contains(jsonText, field) { t.Fatalf("hourly json = %s, want field %s", jsonText, field) } @@ -81,8 +81,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) { if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") { t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText) } - if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) { - t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText) + if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) { + t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText) } } @@ -113,8 +113,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) { if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." { t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period) } - if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" { - t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime) + if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" { + t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds) } if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 { t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period) @@ -127,7 +127,7 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) { t.Fatalf("Marshal narrative forecast: %v", err) } jsonText := string(data) - for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} { + for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} { if !strings.Contains(jsonText, field) { t.Fatalf("narrative json = %s, want field %s", jsonText, field) } @@ -135,8 +135,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) { if strings.Contains(jsonText, "wind_direction_degrees") { t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText) } - if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) { - t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText) + if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) { + t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText) } if strings.Contains(jsonText, "Tomorrow night") { t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText) @@ -285,13 +285,19 @@ func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) { if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." { t.Fatalf("WeatherStory = %#v, want structured story fields", story) } + if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" { + t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds) + } data, err := json.Marshal(storyOutput.Value) if err != nil { t.Fatalf("Marshal weather story: %v", err) } - if !strings.Contains(string(data), "download_url") { + if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") { t.Fatalf("weather story json = %s, want snake_case download_url", string(data)) } + if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") { + t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data)) + } } func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) { diff --git a/internal/briefing/derived_daypart_summaries_module.go b/internal/briefing/derived_daypart_summaries_module.go index 4f906d3..3859fa9 100644 --- a/internal/briefing/derived_daypart_summaries_module.go +++ b/internal/briefing/derived_daypart_summaries_module.go @@ -12,7 +12,8 @@ import ( type DerivedDaypartSummaryModule struct { Date string `json:"date,omitempty"` - Period string `json:"period,omitempty"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` TempRangeF string `json:"temp_range_f,omitempty"` ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"` @@ -46,7 +47,8 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { value := DerivedDaypartSummaryModule{ Date: localDateLabel(daypart.Period.Start, timezone), - Period: friendlyPeriodLabel(daypart.Period, timezone), + PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone), + PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone), TempRangeF: rangeLabel(daypart.Temperature), ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), DominantCondition: daypart.DominantCondition, diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index 168e341..79611c9 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -102,10 +102,10 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { if len(rainy.PrecipitationWindows) != 2 { t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows) } - if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 { + if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 { t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0]) } - if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 { + if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 { t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1]) } data, err := json.Marshal(output.Value) @@ -115,6 +115,9 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") { t.Fatalf("precip timing json = %s, want threshold and windows", string(data)) } + if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) { + t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data)) + } if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") { t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data)) } @@ -150,8 +153,8 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 { t.Fatalf("morning = %#v, want temp range and precip peak", morning) } - if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" { - t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period) + if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" { + t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds) } afternoon := value["afternoon"] if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 { @@ -166,12 +169,12 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { t.Fatalf("marshal daypart summaries: %v", err) } jsonText := string(data) - for _, field := range []string{"date", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} { + for _, field := range []string{"date", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} { if !strings.Contains(jsonText, field) { t.Fatalf("daypart json = %s, want field %s", jsonText, field) } } - if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) { + if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) { t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText) } } @@ -191,6 +194,12 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" { t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor) } + if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" { + t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best) + } + if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" { + t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst) + } planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning}) if err != nil { diff --git a/internal/briefing/hourly_forecast_module.go b/internal/briefing/hourly_forecast_module.go index 672d00c..247a206 100644 --- a/internal/briefing/hourly_forecast_module.go +++ b/internal/briefing/hourly_forecast_module.go @@ -4,6 +4,7 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) @@ -17,8 +18,8 @@ type HourlyForecastModule struct { } type HourlyForecastPeriod struct { - StartTime string `json:"start_time,omitempty"` - EndTime string `json:"end_time,omitempty"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` Name string `json:"name,omitempty"` IsDay *bool `json:"is_day,omitempty"` ConditionCode *int `json:"condition_code,omitempty"` @@ -74,9 +75,10 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod { out := make([]HourlyForecastPeriod, 0, len(periods)) for _, period := range periods { + validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime} out = append(out, HourlyForecastPeriod{ - StartTime: friendlyDateTimeLabel(period.StartTime, timezone), - EndTime: friendlyDateTimeLabel(period.EndTime, timezone), + PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone), + PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone), Name: period.Name, IsDay: copyBool(period.IsDay), ConditionCode: copyInt(period.ConditionCode), diff --git a/internal/briefing/module_format_helpers.go b/internal/briefing/module_format_helpers.go index cfec401..f61b48f 100644 --- a/internal/briefing/module_format_helpers.go +++ b/internal/briefing/module_format_helpers.go @@ -67,18 +67,18 @@ func timedClockLabel(value *forecast.TimedValue, timezone string) string { return clockLabel(value.Time, timezone) } -func periodClockLabel(period timeutil.Period, timezone string) string { +func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string { if !period.IsValid() { return "" } - return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone) + return friendlyDateTimeLabel(period.Start, timezone) } -func friendlyPeriodLabel(period timeutil.Period, timezone string) string { +func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string { if !period.IsValid() { return "" } - return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone) + return friendlyDateTimeLabel(period.End, timezone) } func friendlyDateTimeLabel(value time.Time, timezone string) string { diff --git a/internal/briefing/narrative_forecast_module.go b/internal/briefing/narrative_forecast_module.go index 38856c3..987bfcf 100644 --- a/internal/briefing/narrative_forecast_module.go +++ b/internal/briefing/narrative_forecast_module.go @@ -4,6 +4,7 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) @@ -18,8 +19,8 @@ type NarrativeForecastModule struct { type NarrativeForecastPeriod struct { Name string `json:"name,omitempty"` - StartTime string `json:"start_time,omitempty"` - EndTime string `json:"end_time,omitempty"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` IsDay *bool `json:"is_day,omitempty"` TextDescription string `json:"text_description,omitempty"` TemperatureC *float64 `json:"temperature_c,omitempty"` @@ -58,10 +59,11 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod { out := make([]NarrativeForecastPeriod, 0, len(periods)) for _, period := range periods { + validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime} out = append(out, NarrativeForecastPeriod{ Name: period.Name, - StartTime: friendlyDateTimeLabel(period.StartTime, timezone), - EndTime: friendlyDateTimeLabel(period.EndTime, timezone), + PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone), + PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone), IsDay: copyBool(period.IsDay), TextDescription: period.TextDescription, TemperatureC: copyFloat(period.TemperatureC), diff --git a/internal/briefing/outdoor_windows_module.go b/internal/briefing/outdoor_windows_module.go index acd8d08..dde8fef 100644 --- a/internal/briefing/outdoor_windows_module.go +++ b/internal/briefing/outdoor_windows_module.go @@ -8,31 +8,31 @@ type OutdoorWindowsModule struct { } type OutdoorWindowModule struct { - Daypart string `json:"daypart"` - Start string `json:"start"` - End string `json:"end"` - Reasons []string `json:"reasons,omitempty"` - Score float64 `json:"score"` + Daypart string `json:"daypart"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` + Reasons []string `json:"reasons,omitempty"` + Score float64 `json:"score"` } func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) { windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries) value := OutdoorWindowsModule{ - Best: outdoorWindowValue(windows.Best), - Worst: outdoorWindowValue(windows.Worst), + Best: outdoorWindowValue(windows.Best, ctx.Timezone), + Worst: outdoorWindowValue(windows.Worst, ctx.Timezone), } return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil } -func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule { +func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule { if window == nil { return nil } return &OutdoorWindowModule{ - Daypart: window.Daypart, - Start: window.Start, - End: window.End, - Reasons: append([]string(nil), window.Reasons...), - Score: window.Score, + Daypart: window.Daypart, + PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone), + PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone), + Reasons: append([]string(nil), window.Reasons...), + Score: window.Score, } } diff --git a/internal/briefing/precip_timing_module.go b/internal/briefing/precip_timing_module.go index 216a432..1d3540a 100644 --- a/internal/briefing/precip_timing_module.go +++ b/internal/briefing/precip_timing_module.go @@ -14,8 +14,8 @@ type PrecipTimingModule struct { } type PrecipitationWindowModule struct { - Start string `json:"start"` - End string `json:"end,omitempty"` + PeriodBegins string `json:"period_begins"` + PeriodEnds string `json:"period_ends,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"` } @@ -36,10 +36,10 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi } for _, window := range timing.PrecipitationWindows { item := PrecipitationWindowModule{ - Start: clockLabel(window.Start, timezone), + PeriodBegins: friendlyDateTimeLabel(window.Start, timezone), } if window.End != nil { - item.End = clockLabel(*window.End, timezone) + item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone) } item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value) item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone) diff --git a/internal/briefing/spc_convective_discussion_module.go b/internal/briefing/spc_convective_discussion_module.go index 3b1b4dd..28c55dd 100644 --- a/internal/briefing/spc_convective_discussion_module.go +++ b/internal/briefing/spc_convective_discussion_module.go @@ -4,10 +4,12 @@ import ( "fmt" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3 +const spcCategoricalOutlookType = "categorical" type SPCConvectiveDiscussionModule struct { IncludedBecause string `json:"included_because"` @@ -15,56 +17,77 @@ type SPCConvectiveDiscussionModule struct { } type SPCConvectiveDiscussionRecord struct { - Day int `json:"day,omitempty"` - Headline string `json:"headline,omitempty"` - Summary string `json:"summary,omitempty"` - Discussion string `json:"discussion,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` + Day int `json:"day,omitempty"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` + Headline string `json:"headline,omitempty"` + Summary string `json:"summary,omitempty"` + Discussion string `json:"discussion,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` } func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) { - if !hasSPCConvectiveDiscussionThreshold(ctx.Derived.SPCConvectiveOutlooks) { + qualifyingPeriods := spcConvectiveDiscussionQualifyingPeriods(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod) + if len(qualifyingPeriods) == 0 { return nil, nil } - records := spcConvectiveDiscussionRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone) + records := spcConvectiveDiscussionRecords(qualifyingPeriods, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone) if len(records) == 0 { return nil, nil } value := SPCConvectiveDiscussionModule{ - IncludedBecause: fmt.Sprintf("severity_rank >= %d", defaultSPCConvectiveDiscussionMinimumSeverityRank), + IncludedBecause: fmt.Sprintf("%s severity_rank >= %d", spcCategoricalOutlookType, defaultSPCConvectiveDiscussionMinimumSeverityRank), Discussions: records, } return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil } -func hasSPCConvectiveDiscussionThreshold(outlooks []weatherdata.ConvectiveOutlook) bool { +func spcConvectiveDiscussionQualifyingPeriods(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period) map[int]timeutil.Period { + periods := map[int]timeutil.Period{} for _, outlook := range outlooks { - if outlook.SeverityRank != nil && *outlook.SeverityRank >= defaultSPCConvectiveDiscussionMinimumSeverityRank { - return true + if outlook.OutlookType != spcCategoricalOutlookType { + continue } + if outlook.SeverityRank == nil || *outlook.SeverityRank < defaultSPCConvectiveDiscussionMinimumSeverityRank { + continue + } + outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo} + if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) { + continue + } + if existing, ok := periods[outlook.Day]; ok { + if outlookPeriod.Start.Before(existing.Start) { + existing.Start = outlookPeriod.Start + } + if outlookPeriod.End.After(existing.End) { + existing.End = outlookPeriod.End + } + periods[outlook.Day] = existing + continue + } + periods[outlook.Day] = outlookPeriod } - return false + return periods } -func spcConvectiveDiscussionRecords(outlooks []weatherdata.ConvectiveOutlook, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord { - retainedDays := map[int]struct{}{} - for _, outlook := range outlooks { - retainedDays[outlook.Day] = struct{}{} - } +func spcConvectiveDiscussionRecords(qualifyingPeriods map[int]timeutil.Period, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord { records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions)) for _, discussion := range discussions { - if _, ok := retainedDays[discussion.Day]; !ok { + period, ok := qualifyingPeriods[discussion.Day] + if !ok { continue } if discussion.Discussion == "" { continue } records = append(records, SPCConvectiveDiscussionRecord{ - Day: discussion.Day, - Headline: discussion.Headline, - Summary: discussion.Summary, - Discussion: discussion.Discussion, - UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone), + Day: discussion.Day, + PeriodBegins: friendlyPeriodBeginsLabel(period, timezone), + PeriodEnds: friendlyPeriodEndsLabel(period, timezone), + Headline: discussion.Headline, + Summary: discussion.Summary, + Discussion: discussion.Discussion, + UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone), }) } return records diff --git a/internal/briefing/spc_convective_discussion_module_test.go b/internal/briefing/spc_convective_discussion_module_test.go index 0c50c2a..636ccc7 100644 --- a/internal/briefing/spc_convective_discussion_module_test.go +++ b/internal/briefing/spc_convective_discussion_module_test.go @@ -48,13 +48,16 @@ func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testin t.Fatalf("output = %#v, want spc convective discussion stanza", output) } value := moduleValue[SPCConvectiveDiscussionModule](t, output) - if value.IncludedBecause != "severity_rank >= 3" || len(value.Discussions) != 1 { + if value.IncludedBecause != "categorical severity_rank >= 3" || len(value.Discussions) != 1 { t.Fatalf("value = %#v, want threshold reason and one discussion", value) } discussion := value.Discussions[0] if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" { t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion) } + if discussion.PeriodBegins != "2026-05-29 at 11:00 AM" || discussion.PeriodEnds != "2026-05-30 at 7:00 AM" { + t.Fatalf("Period = %q/%q, want qualifying outlook valid period", discussion.PeriodBegins, discussion.PeriodEnds) + } if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" { t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt) } @@ -63,11 +66,14 @@ func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testin t.Fatalf("Marshal() error = %v", err) } text := string(data) - for _, field := range []string{"included_because", "discussions", "headline", "summary", "discussion", "updated_at"} { + for _, field := range []string{"included_because", "discussions", "period_begins", "period_ends", "headline", "summary", "discussion", "updated_at"} { if !strings.Contains(text, field) { t.Fatalf("json = %s, want field %s", text, field) } } + if strings.Contains(text, `"period":`) { + t.Fatalf("json = %s, want period_begins/period_ends instead of period", text) + } } func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) { @@ -86,6 +92,162 @@ func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testin } } +func TestSPCConvectiveDiscussionModuleIgnoresHighRankNonCategoricalOutlook(t *testing.T) { + registry := MustDefaultModuleRegistry() + rank := 30 + ctx := testModuleContext() + outlook := weatherdata.ConvectiveOutlook{ + ID: "day1-wind-30", + Day: 1, + OutlookType: "wind", + Label: "30%", + LabelText: "30% Wind Risk", + SeverityRank: &rank, + ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"), + ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"), + } + ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ + Outlooks: []weatherdata.ConvectiveOutlook{outlook}, + } + ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook} + ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{ + spcDiscussion(1, "Wind risk discussion", "High wind probabilities.", "This discussion should not be emitted from wind severity rank.", "2026-05-29T08:30:00-05:00"), + } + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + if output != nil { + t.Fatalf("output = %#v, want non-categorical outlook ignored for discussion threshold", output) + } +} + +func TestSPCConvectiveDiscussionModuleRequiresCategoricalThresholdForMixedSameDayOutlooks(t *testing.T) { + registry := MustDefaultModuleRegistry() + rank2 := 2 + rank30 := 30 + ctx := testModuleContext() + categorical := weatherdata.ConvectiveOutlook{ + ID: "day1-marginal", + Day: 1, + OutlookType: "categorical", + Label: "MRGL", + LabelText: "Marginal Risk", + SeverityRank: &rank2, + ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"), + ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"), + } + wind := weatherdata.ConvectiveOutlook{ + ID: "day1-wind-30", + Day: 1, + OutlookType: "wind", + Label: "30%", + LabelText: "30% Wind Risk", + SeverityRank: &rank30, + ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"), + ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"), + } + ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ + Outlooks: []weatherdata.ConvectiveOutlook{categorical, wind}, + } + ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{categorical, wind} + ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{ + spcDiscussion(1, "Mixed risk discussion", "Only wind is high.", "This discussion should not be emitted without categorical threshold.", "2026-05-29T08:30:00-05:00"), + } + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + if output != nil { + t.Fatalf("output = %#v, want mixed day omitted when categorical outlook is below threshold", output) + } +} + +func TestSPCConvectiveDiscussionModuleIncludesOnlyQualifyingDays(t *testing.T) { + registry := MustDefaultModuleRegistry() + rank2 := 2 + rank4 := 4 + ctx := testModuleContext() + ctx.Resolved.ValidPeriod.Start = mustParseModuleTime("2026-05-30T00:00:00-05:00") + ctx.Resolved.ValidPeriod.End = mustParseModuleTime("2026-05-31T00:00:00-05:00") + day1Outlook := weatherdata.ConvectiveOutlook{ + ID: "day1-marginal", + Day: 1, + OutlookType: "categorical", + Label: "MRGL", + LabelText: "Marginal Risk", + SeverityRank: &rank2, + ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"), + ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"), + } + day2Outlook := weatherdata.ConvectiveOutlook{ + ID: "day2-enhanced", + Day: 2, + OutlookType: "categorical", + Label: "ENH", + LabelText: "Enhanced Risk", + SeverityRank: &rank4, + ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"), + ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"), + } + ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ + Outlooks: []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook}, + } + ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook} + ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{ + spcDiscussion(1, "Day 1 regional discussion", "Marginal risk discussion.", "Day 1 text should not be emitted.", "2026-05-29T08:30:00-05:00"), + spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should be emitted.", "2026-05-30T08:30:00-05:00"), + } + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + value := moduleValue[SPCConvectiveDiscussionModule](t, output) + if len(value.Discussions) != 1 { + t.Fatalf("Discussions = %#v, want only the qualifying day discussion", value.Discussions) + } + if value.Discussions[0].Day != 2 || value.Discussions[0].Headline != "Day 2 regional discussion" { + t.Fatalf("Discussions[0] = %#v, want day 2 discussion only", value.Discussions[0]) + } + if value.Discussions[0].PeriodBegins != "2026-05-30 at 7:00 AM" || value.Discussions[0].PeriodEnds != "2026-05-31 at 7:00 AM" { + t.Fatalf("Period = %q/%q, want qualifying day 2 outlook period", value.Discussions[0].PeriodBegins, value.Discussions[0].PeriodEnds) + } +} + +func TestSPCConvectiveDiscussionModuleOmitsNonOverlappingQualifyingOutlook(t *testing.T) { + registry := MustDefaultModuleRegistry() + rank := 5 + ctx := testModuleContext() + outlook := weatherdata.ConvectiveOutlook{ + ID: "day2-enhanced", + Day: 2, + OutlookType: "categorical", + Label: "ENH", + LabelText: "Enhanced Risk", + SeverityRank: &rank, + ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"), + ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"), + } + ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ + Outlooks: []weatherdata.ConvectiveOutlook{outlook}, + } + ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook} + ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{ + spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should not be emitted for today.", "2026-05-30T08:30:00-05:00"), + } + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + if output != nil { + t.Fatalf("output = %#v, want non-overlapping discussion omitted", output) + } +} + func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{ diff --git a/internal/briefing/spc_convective_outlooks_module.go b/internal/briefing/spc_convective_outlooks_module.go index 6fe7931..ba3a2dc 100644 --- a/internal/briefing/spc_convective_outlooks_module.go +++ b/internal/briefing/spc_convective_outlooks_module.go @@ -4,6 +4,7 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) @@ -22,13 +23,10 @@ type SPCConvectiveOutlookRecord struct { OutlookType string `json:"outlook_type,omitempty"` Label string `json:"label,omitempty"` LabelText string `json:"label_text,omitempty"` - SeverityRank *int `json:"severity_rank,omitempty"` - ValidStart string `json:"valid_start,omitempty"` - ValidEnd string `json:"valid_end,omitempty"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` IssuedAt string `json:"issued_at,omitempty"` - ExpiresAt string `json:"expires_at,omitempty"` ContainsLocation bool `json:"contains_location"` - SourceURL string `json:"source_url,omitempty"` ImageURL string `json:"image_url,omitempty"` } @@ -50,26 +48,27 @@ func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone) } - value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Timezone) + value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone) value.OutlookCount = len(value.Outlooks) return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil } -func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, timezone string) []SPCConvectiveOutlookRecord { +func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord { records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks)) for _, outlook := range outlooks { + outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo} + if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) { + continue + } records = append(records, SPCConvectiveOutlookRecord{ Day: outlook.Day, OutlookType: outlook.OutlookType, Label: outlook.Label, LabelText: outlook.LabelText, - SeverityRank: copyInt(outlook.SeverityRank), - ValidStart: friendlyDateTimeLabel(outlook.ValidFrom, timezone), - ValidEnd: friendlyDateTimeLabel(outlook.ValidTo, timezone), + PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone), + PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone), IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone), - ExpiresAt: friendlyOptionalTime(outlook.ExpiresAt, timezone), ContainsLocation: outlook.ContainsLocation, - SourceURL: outlook.SourceURL, ImageURL: outlook.ImageURL, }) } diff --git a/internal/briefing/spc_convective_outlooks_module_test.go b/internal/briefing/spc_convective_outlooks_module_test.go index 63e65e5..975206d 100644 --- a/internal/briefing/spc_convective_outlooks_module_test.go +++ b/internal/briefing/spc_convective_outlooks_module_test.go @@ -65,32 +65,60 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) { if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" { t.Fatalf("outlook = %#v, want categorical slight risk fields", got) } - if got.SeverityRank == nil || *got.SeverityRank != 3 { - t.Fatalf("SeverityRank = %#v, want 3", got.SeverityRank) - } - if got.ValidStart != "2026-05-29 at 11:00 AM" || got.ValidEnd != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" || got.ExpiresAt != "2026-05-30 at 7:00 AM" { + if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" { t.Fatalf("outlook times = %#v, want friendly local labels", got) } - if !got.ContainsLocation || got.SourceURL == "" || got.ImageURL == "" { - t.Fatalf("outlook = %#v, want location flag and source/image URLs", got) + if !got.ContainsLocation || got.ImageURL == "" { + t.Fatalf("outlook = %#v, want location flag and image URL", got) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal() error = %v", err) } text := string(data) - for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "valid_start", "valid_end", "contains_location", "source_url", "image_url"} { + for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "period_begins", "period_ends", "contains_location", "image_url"} { if !strings.Contains(text, field) { t.Fatalf("json = %s, want field %s", text, field) } } - for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider"} { + for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider", "severity_rank", "expires_at", "source_url", "valid_start", "valid_end", `"period":`} { if strings.Contains(text, omitted) { t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted) } } } +func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + rank := 5 + outlook := weatherdata.ConvectiveOutlook{ + ID: "tomorrow-enhanced", + Day: 2, + OutlookType: "categorical", + Label: "ENH", + LabelText: "Enhanced Risk", + SeverityRank: &rank, + ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"), + ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"), + ContainsLocation: true, + } + ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ + AsOf: ptrModuleTime("2026-05-29T14:00:00Z"), + Outlooks: []weatherdata.ConvectiveOutlook{outlook}, + } + ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook} + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + value := moduleValue[SPCConvectiveOutlooksModule](t, output) + if value.OutlookCount != 0 || len(value.Outlooks) != 0 { + t.Fatalf("value = %#v, want non-overlapping outlook omitted", value) + } +} + func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() diff --git a/internal/briefing/summary_helpers.go b/internal/briefing/summary_helpers.go index 00d5c47..65d88fd 100644 --- a/internal/briefing/summary_helpers.go +++ b/internal/briefing/summary_helpers.go @@ -7,6 +7,7 @@ import ( "strings" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) type OutdoorWindows struct { @@ -16,8 +17,7 @@ type OutdoorWindows struct { type OutdoorWindow struct { Daypart string - Start string - End string + Period timeutil.Period Reasons []string Score float64 } @@ -190,8 +190,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow { } return OutdoorWindow{ Daypart: daypart.Name, - Start: daypart.Period.Start.Format("15:04"), - End: daypart.Period.End.Format("15:04"), + Period: daypart.Period, Reasons: dedupe(reasons), Score: math.Round(score*10) / 10, } diff --git a/internal/briefing/weather_story_module.go b/internal/briefing/weather_story_module.go index 75d3217..18e74c2 100644 --- a/internal/briefing/weather_story_module.go +++ b/internal/briefing/weather_story_module.go @@ -4,20 +4,21 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) type WeatherStoryModule struct { - Available bool `json:"available"` - OfficeID string `json:"office_id,omitempty"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - AltText string `json:"alt_text,omitempty"` - Priority bool `json:"priority"` - Order int `json:"order"` - DownloadURL string `json:"download_url,omitempty"` + Available bool `json:"available"` + OfficeID string `json:"office_id,omitempty"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + AltText string `json:"alt_text,omitempty"` + Priority bool `json:"priority"` + Order int `json:"order"` + DownloadURL string `json:"download_url,omitempty"` } func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) { @@ -25,18 +26,19 @@ func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) { if story == nil { return nil, nil } + period := timeutil.Period{Start: story.StartTime, End: story.EndTime} value := WeatherStoryModule{ - Available: true, - OfficeID: story.OfficeID, - StartTime: story.StartTime, - EndTime: story.EndTime, - UpdatedAt: copyTime(story.UpdatedAt), - Title: story.Title, - Description: story.Description, - AltText: story.AltText, - Priority: story.Priority, - Order: story.Order, - DownloadURL: story.DownloadURL, + Available: true, + OfficeID: story.OfficeID, + PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone), + PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone), + UpdatedAt: copyTime(story.UpdatedAt), + Title: story.Title, + Description: story.Description, + AltText: story.AltText, + Priority: story.Priority, + Order: story.Order, + DownloadURL: story.DownloadURL, } return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil } diff --git a/internal/changes/daily.go b/internal/changes/daily.go index e1bc45b..43735b4 100644 --- a/internal/changes/daily.go +++ b/internal/changes/daily.go @@ -84,7 +84,8 @@ type dailySummaryStanza struct { type daypartSummaryStanza struct { Date string `json:"date,omitempty"` - Period string `json:"period,omitempty"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` TempRangeF string `json:"temp_range_f,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"` diff --git a/internal/changes/daily_test.go b/internal/changes/daily_test.go index 086ff81..438dbb1 100644 --- a/internal/changes/daily_test.go +++ b/internal/changes/daily_test.go @@ -96,7 +96,13 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin DailyPrecipitationProbability: &precip, }}, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ - "morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow}, + "morning": { + Date: "2026-05-29", + PeriodBegins: "2026-05-29 at 6:00 AM", + PeriodEnds: "2026-05-29 at 10:00 AM", + TempRangeF: "60-70", + Snow: snow, + }, }}, module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}}, module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}}, diff --git a/internal/changes/three_day_test.go b/internal/changes/three_day_test.go index d5d45cb..1b762f2 100644 --- a/internal/changes/three_day_test.go +++ b/internal/changes/three_day_test.go @@ -32,7 +32,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ date + "_morning": { Date: date, - Period: date + " at 6:00 AM to " + date + " at 10:00 AM", + PeriodBegins: date + " at 6:00 AM", + PeriodEnds: date + " at 10:00 AM", TempRangeF: tempRange, MaxPopPercent: &precip, MaxPopTime: precipTime,