Add SPC convective discussion briefing module
This commit is contained in:
@@ -29,7 +29,8 @@ Outputs:
|
|||||||
- `module.Output` values for source-oriented stanzas:
|
- `module.Output` values for source-oriented stanzas:
|
||||||
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
||||||
`alert_digest`, `spc_convective_outlooks`,
|
`alert_digest`, `spc_convective_outlooks`,
|
||||||
`area_forecast_discussion`, and `weather_story`
|
`area_forecast_discussion`, `spc_convective_discussion`, and
|
||||||
|
`weather_story`
|
||||||
- `module.Output` values for derived stanzas:
|
- `module.Output` values for derived stanzas:
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||||
`outdoor_windows`, and `tomorrow_planning`
|
`outdoor_windows`, and `tomorrow_planning`
|
||||||
@@ -90,6 +91,8 @@ return an error for invalid required inputs.
|
|||||||
- SPC convective outlook output distinguishes checked empty outlook data from
|
- SPC convective outlook output distinguishes checked empty outlook data from
|
||||||
missing outlook source data and omits GeoJSON geometry from prompt-facing
|
missing outlook source data and omits GeoJSON geometry from prompt-facing
|
||||||
fields.
|
fields.
|
||||||
|
- SPC convective discussion output is omitted unless a retained outlook meets
|
||||||
|
the internal severity threshold and matching discussion text is available.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ The registry recognizes these IDs:
|
|||||||
- `alert_digest`
|
- `alert_digest`
|
||||||
- `spc_convective_outlooks`
|
- `spc_convective_outlooks`
|
||||||
- `area_forecast_discussion`
|
- `area_forecast_discussion`
|
||||||
|
- `spc_convective_discussion`
|
||||||
- `weather_story`
|
- `weather_story`
|
||||||
- `outdoor_windows`
|
- `outdoor_windows`
|
||||||
- `tomorrow_planning`
|
- `tomorrow_planning`
|
||||||
@@ -46,10 +47,6 @@ The registry recognizes these IDs:
|
|||||||
Every registered module has a builder. Report composition entries that refer to
|
Every registered module has a builder. Report composition entries that refer to
|
||||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
The package also defines the `spc_convective_discussion` ID and empty option
|
|
||||||
struct for the collected contract. It is not a registered module in the
|
|
||||||
briefing registry until its builder exists.
|
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
Most modules use an empty options struct. `area_forecast_discussion` accepts:
|
Most modules use an empty options struct. `area_forecast_discussion` accepts:
|
||||||
|
|||||||
@@ -346,6 +346,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildAreaForecastDiscussionModule,
|
Builder: buildAreaForecastDiscussionModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveDiscussion,
|
||||||
|
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||||
|
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildSPCConvectiveDiscussionModule,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: module.WeatherStory,
|
ID: module.WeatherStory,
|
||||||
StanzaName: "weather_story",
|
StanzaName: "weather_story",
|
||||||
|
|||||||
71
internal/briefing/spc_convective_discussion_module.go
Normal file
71
internal/briefing/spc_convective_discussion_module.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3
|
||||||
|
|
||||||
|
type SPCConvectiveDiscussionModule struct {
|
||||||
|
IncludedBecause string `json:"included_because"`
|
||||||
|
Discussions []SPCConvectiveDiscussionRecord `json:"discussions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
if !hasSPCConvectiveDiscussionThreshold(ctx.Derived.SPCConvectiveOutlooks) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
records := spcConvectiveDiscussionRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := SPCConvectiveDiscussionModule{
|
||||||
|
IncludedBecause: fmt.Sprintf("severity_rank >= %d", defaultSPCConvectiveDiscussionMinimumSeverityRank),
|
||||||
|
Discussions: records,
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasSPCConvectiveDiscussionThreshold(outlooks []weatherdata.ConvectiveOutlook) bool {
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
if outlook.SeverityRank != nil && *outlook.SeverityRank >= defaultSPCConvectiveDiscussionMinimumSeverityRank {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcConvectiveDiscussionRecords(outlooks []weatherdata.ConvectiveOutlook, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
|
||||||
|
retainedDays := map[int]struct{}{}
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
retainedDays[outlook.Day] = struct{}{}
|
||||||
|
}
|
||||||
|
records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions))
|
||||||
|
for _, discussion := range discussions {
|
||||||
|
if _, ok := retainedDays[discussion.Day]; !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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
155
internal/briefing/spc_convective_discussion_module_test.go
Normal file
155
internal/briefing/spc_convective_discussion_module_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleOmitsBelowThresholdButOutlookRemains(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := spcConvectiveDiscussionContext(2, []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Lower risk", "General thunderstorms.", "No organized severe weather is expected.", "2026-05-29T08:30:00-05:00"),
|
||||||
|
})
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(discussion) error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("discussion output = %#v, want omitted below threshold", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
outlookOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(outlooks) error = %v", err)
|
||||||
|
}
|
||||||
|
outlookValue := moduleValue[SPCConvectiveOutlooksModule](t, outlookOutput)
|
||||||
|
if !outlookValue.Checked || outlookValue.OutlookCount != 1 {
|
||||||
|
t.Fatalf("outlook value = %#v, want lower-risk outlook still emitted", outlookValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Severe storms possible", "Scattered severe storms are possible.", "A few storms may become severe during the afternoon.", "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 || output.ID != module.SPCConvectiveDiscussion || output.StanzaName != "spc_convective_discussion" {
|
||||||
|
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 {
|
||||||
|
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.UpdatedAt != "2026-05-29 at 8:30 AM" {
|
||||||
|
t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, field := range []string{"included_because", "discussions", "headline", "summary", "discussion", "updated_at"} {
|
||||||
|
if !strings.Contains(text, field) {
|
||||||
|
t.Fatalf("json = %s, want field %s", text, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := spcConvectiveDiscussionContext(4, []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Enhanced severe risk", "Numerous severe storms are possible.", "Severe storms may produce damaging winds.", "2026-05-29T09:15: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 || value.Discussions[0].Headline != "Enhanced severe risk" {
|
||||||
|
t.Fatalf("value = %#v, want above-threshold discussion", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
{Day: 1, Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", UpdatedAt: ptrModuleTime("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 omitted when discussion text is missing", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleOmitsMissingSource(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = nil
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = nil
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = nil
|
||||||
|
|
||||||
|
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 omitted for missing optional source", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcConvectiveDiscussionContext(rank int, discussions []weatherdata.ConvectiveOutlookDiscussion) ModuleContext {
|
||||||
|
ctx := testModuleContext()
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-categorical",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "SLGT",
|
||||||
|
LabelText: "Slight 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{
|
||||||
|
AsOf: ptrModuleTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
IssuedAt: ptrModuleTime("2026-05-29T07:45:00-05:00"),
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = discussions
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcDiscussion(day int, headline string, summary string, discussion string, updatedAt string) weatherdata.ConvectiveOutlookDiscussion {
|
||||||
|
return weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
Day: day,
|
||||||
|
Headline: headline,
|
||||||
|
Summary: summary,
|
||||||
|
Discussion: discussion,
|
||||||
|
UpdatedAt: ptrModuleTime(updatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrModuleTime(value string) *time.Time {
|
||||||
|
parsed := mustParseModuleTime(value)
|
||||||
|
return &parsed
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user